A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.

Files
README.md

harrier #

A GPU terminal emulator in Zig 0.17. The terminal itself — parsing, the screen, scrollback, modes, key and mouse encoding — is libghostty-vt. harrier is everything around it: windows of tabs and split panes, and a program on a pseudo-terminal or a serial port at the other end. It draws them with zig-aerie — the window system, Vulkan renderer and font stack that began as harrier's own and is now a library other programs draw with too.

$ harrier                                   # your shell
$ harrier -e htop                           # a program
$ harrier --serial /dev/ttyUSB0 --baud 9600 # a serial port

One harrier serves every window. A launch with a harrier already running hands its window to that one and exits, so a new window opens at once. Where the systemd user service is installed, the session bus starts harrier for the first window too; see Running as a service.

It is new, and runs on Linux under Wayland, and on Windows 10 1809 and later, where some of what follows is not there yet; see Windows.

What it does #

  • The terminal is Ghostty's. It uses libghostty-vt from the zig-0.17-jeff branch of my fork of Ghostty: Ghostty's zig-0.17 branch (pull request #14519) with my work not yet merged upstream on top. That is xterm and kitty keyboard and mouse encoding, synchronized output, clipboard reads and writes with OSC 52 and with kitty's clipboard protocol (OSC 5522) and its paste events, titles, scrollback, size reports down to CSI 19 t, and the rest of what Ghostty's own terminal does.

  • A pty or a serial port. The window shows whatever the backend is connected to. A program runs on a pseudo-terminal with TERM, COLORTERM=truecolor and TERM_PROGRAM=harrier set. A serial port is opened raw through zig-serial, at any rate the driver accepts.

  • Wayland, without libwayland. The window and its input go through zig-aerie's platform, on zig-wayland-native and zig-xkb:

    • fractional scaling;
    • server-side decorations;
    • key repeat;
    • dead keys and the Compose key;
    • the clipboard, and the primary selection: select to copy, middle-click to paste, and a toast over the pane saying what was copied, or what a program did with the clipboard without asking;
    • input methods through text-input-v3;
    • cursor shapes;
    • its own title bar and frame where the compositor draws none — the title in the desktop's interface font, as other windows have it — with the focused pane's location under the title: the directory its program is in, and the machine when the shell is on another one, over ssh, say (see window.subtitle);
    • resizing in whole cells: a floating window with one pane takes, of each size the compositor offers, the largest that fits a whole number of cells, so no strip of a cell is left empty along the right or bottom edge. A maximized, fullscreen or tiled window has to take the size it is given, and a window split into panes leaves each pane's own strip.
  • Vulkan, through dma-bufs, by zig-aerie's renderer. Every frame is instanced quads drawn from a glyph atlas and the images programs show. There is no wl_display for a Vulkan swapchain, so zig-aerie does what a swapchain does itself: it allocates exportable images the compositor reads directly, in a layout (DRM format modifier) both sides share. The frame is drawn straight into one of these.

    • Without modifier support (RADV on Polaris and older, say), there is no image both can use. The buffer is then a linear dma-buf, and the frame is copied into it on the GPU.
    • Sync is explicit (linux-drm-syncobj) where the compositor offers it, and implicit otherwise.
    • Where no memory can be shared at all, the frame is read back into shared memory.
  • Jeff's font stack, through zig-aerie.

    • Fonts are found the way fontconfig finds them, by zig-font-config.

    • Text is shaped by zig-font-shaper, a port of HarfBuzz, so ligatures and combining marks work. Only rows that changed are shaped again.

    • Glyphs are drawn by zig-font-renderer, color ones too: COLR, SVG , and the bitmap emoji of CBDT and sbix.

    • Emoji are drawn in color. A character Unicode shows as an emoji (UTS #51's Emoji_Presentation), or one followed by U+FE0F, is drawn from the first font in the fallback order that has it in color, even when fontconfig ranks a monochrome emoji font first; a character shown as text, such as ❤ alone or 😐 followed by U+FE0E, is drawn from the first that has it as an outline. A selector Unicode does not define for the character before it, as U+FE0E is not for 😀, is ignored, except under grapheme_width = .legacy. The property is read at compile time from the Unicode data the shaper's uucode carries.

    • A grapheme takes the cells Unicode says: ➡️, ➡ followed by U+FE0F, is wide like any other emoji, and a flag or a family is one grapheme of two cells, with mode 2027 on by default. A color glyph that is still wider than its cells, as an emoji is in a narrow cell under grapheme_width = .legacy, is shrunk to fit them rather than covering the next. Text harrier draws itself in cells, in tabs and the text an input method is composing, is measured the same way.

    • Some glyphs are drawn rather than taken from the font, the way Ghostty does it, with Ghostty's own drawing code:

      • box drawing, block elements and shades;
      • braille;
      • Powerline's separators and branch symbols;
      • geometric shapes;
      • the symbols for legacy computing (sextants, octants, wedges).

      Drawn to the cell, lines meet their neighbours without gaps and braille dots fill the cell, whatever the font's own versions look like.

  • Tabs and splits. A window holds tabs, with a strip of them above once there are two, and a tab holds panes, split side by side or one above another as often as is wanted. New tabs and panes start in the directory the focused pane is in.

  • Drop files to type their paths. Dropped on a pane whose program takes no drops, files are typed in as their paths, quoted for a shell and separated by spaces, and dropped text is typed as it is. Nothing is followed by a newline, so a drop never runs anything, and a drop is always a copy, never a move.

  • Drag and drop for the programs in it. A program speaking kitty's drag and drop protocol (OSC 72), like kitten dnd, can take what is dragged onto its pane, from another program or another pane, and drag things out of its pane to anywhere that takes drops. A program on another machine, over ssh, works too: it is sent the files dropped on it, and the files it drags are written under ~/.cache/harrier/drags (on Windows, %LOCALAPPDATA%\harrier\drags) and handed on from there. drag_and_drop = false turns the protocol off.

  • Images. A program drawing with kitty's graphics protocol, like kitten icat, can show pictures in its pane:

    • RGB, RGBA and PNG, the PNGs decoded with Wuffs;
    • sent in the escape sequences themselves, or, from a program on this machine, as a file, a temporary file or shared memory;
    • cropped and scaled to the cells asked for, and moving with the text as it scrolls;
    • drawn over the text, under it, or under the cells' backgrounds too, as each placement's z-index asks;
    • placed by unicode placeholders, which survive tmux;
    • animated.

    images.enabled = false turns them off.

  • Searching the scrollback. ctrl+shift+f opens a search field at the top right of the focused pane, and what is typed is looked for through the whole of its terminal: the screen and every line of scrollback, following the program onto the alternate screen and back, and keeping up as output arrives. Every match in view is highlighted, and the count says how many there are; Enter goes to the next older match, scrolling to it, and Shift+Enter (or the arrow keys) back down. Escape closes the field, and the search is kept: ctrl+shift+g and ctrl+shift+h go on to the next older and newer match with the field closed, showing only the one they land on, until something is typed to the program. With nothing searched for yet they open the field, and ctrl+shift+f opens it again on the last search. While it is open, pastes go into it rather than to the program — ctrl+shift+v, shift+insert and the middle button alike — made one line, each newline or tab a space. A long scrollback is searched a piece at a time between frames, so the window stays responsive and the count fills in as it goes.

  • Profiles. ctrl+shift+p, or the ▾ button in the title bar or the tab bar, opens the profile selector, which lists the hosts, serial consoles and programs the configuration names as profiles, and opens the one picked in a window of its own, with its own environment and colors. See Profiles.

  • Links open with ctrl+click. A URL in the text, or a hyperlink a program makes (OSC 8), is underlined while ctrl is held over it, and ctrl+click opens it with whatever the desktop opens it with, through the XDG desktop portal: a web page in the browser, a file on this machine in its application. This works even when the program has the mouse. link_detection = false leaves plain-text URLs alone.

  • Desktop notifications. A program asking for one with OSC 9 or OSC 777 — a long build when it finishes, say — gets one through the XDG desktop portal, titled with its tab's title when it gives none. Clicking it shows that tab and pane, raising the window, and looking at the pane withdraws it. By default a pane being looked at sends none.

  • The bell. A program ringing its bell (BEL) in a pane you are not looking at marks that pane's tab and the window's title with 🔔 until you look, and asks the desktop for attention when the window does not have the focus: "is ready" on GNOME and KDE, an urgent border on sway, a flashing taskbar button on Windows. A sound — the desktop's own bell, or a sound file of your choosing played by zig-chime, for all panes or a profile's — a flash of the pane and a desktop notification can be turned on too; see bell under Configuration.

  • Progress bars. A program reporting its progress with OSC 9;4, as ConEmu and Windows Terminal take it, gets a thin bar along the top of its pane: filled as far as it says, in its palette's blue, red for an error and yellow when paused, or a stretch sweeping across when it cannot say how far it is — colors the configuration can change. Its tab on the tab bar shows the same along its bottom edge, so that progress in a hidden tab can be seen: an error in any of the tab's panes first, then the focused pane's progress, then any other pane's. The bar goes when the program says it is done, or after fifteen seconds without word from it. A dock or task manager that reads Unity's launcher API — KDE Plasma's, Dash to Dock, Dash to Panel — shows the progress on harrier's icon too: the mean of every pane that says how far it is, with the icon marked urgent while one reports an error or is waiting on the user.

  • Program status. A program that says what it is doing with OSC 7501 — working, waiting on the user, done, failed, idle — keeps a record for itself and for each piece of work it names, in a tree: deploy/us-east beneath deploy. Coming into working, waiting or done, a record sends a desktop notification, titled with its title or its program, saying what happened and the program's message; a pane sends at most four in ten seconds. Its pane's progress bar follows the most recent report: green when done, yellow while waiting. Once a program has reported with OSC 7501, its OSC 9;4 progress is set aside until a full reset, as the protocol asks. What was going on ends when the program exits or the shell shows its next prompt; what is done or failed stays until the program clears it. A pane keeps 256 records; a new one past that takes the place of the one reported least recently.

    A status button is always shown. While anything is reported, it is the most pressing report's mark, in its color, and how many there are, such as ✗ 3 when something has failed; with nothing reported it is an empty circle, greyed out. It sits in harrier's title bar when harrier draws one, otherwise in the tab bar when that shows, and otherwise as a small badge at the top right of the terminal. Pressing it, or ctrl+shift+s, opens the status window: every terminal's progress and records as a tree (windows, tabs, panes and the records beneath each) in a window of its own. It is a child of the window it was opened from, so most compositors float it and keep it above that window. It can be moved aside and left open while working, and it follows the reports as they change. A click on a row shows that pane, and Escape closes it.

  • A cgroup for each program. Each terminal's program runs in a systemd scope of its own, app-dev.jcollie.harrier-<pid>.scope, made before it starts, so that it and everything it starts are accounted for, limited, and killed under memory pressure apart from harrier and from the other terminals: a runaway build in one tab is not the whole terminal.

  • One process, many windows. The first harrier owns dev.jcollie.harrier on the session bus and serves org.freedesktop.Application. Later launches hand their windows to it with their command, working directory and environment, and launchers use the same interface through the desktop entry's DBusActivatable=true. Windows opened this way take focus through xdg-activation.

  • Found from GNOME Shell's search. Typing in the overview finds harrier's open panes by title and location, and picking one shows it, and its profiles, which picking starts.

  • A terminal for other programs to launch in. harrier implements the Terminal Intent, org.freedesktop.Terminal1, so a launcher can run a program whose desktop file says Terminal=true in it without knowing harrier's command line.

  • Configured in struthio. Its programs can ask which machine they are running on.

  • Ready to be translated. What harrier itself says goes through Project Fluent, by zig-fluent. English is the only translation so far; see Translations.

Building and running #

$ nix develop
$ zig build run                       # build, then run your shell
$ zig build run -- -e vim README.md
$ zig build test --summary all

The development shell has Zig 0.17.0, the Vulkan loader and its validation layer, and REUSE. --validation turns the layer on. zig-aerie's shaders are Zig, compiled by Zig's own SPIR-V backend, so the build needs nothing but Zig.

The font libraries are built ReleaseSafe even in a Debug build. Reading fontconfig's cached patterns takes seconds when they are unoptimized.

So is libghostty-vt. Built Debug, it checks a whole page's integrity after every change to it, which turns output full of hyperlinks into minutes of a core at full tilt — 3,000 hyperlinked lines take over two minutes rather than a moment. -Dvt-debug builds it Debug anyway, to debug it.

The package and the VM test:

$ nix build                                   # result/bin/harrier
$ nix build .#checks.x86_64-linux.sway        # harrier in a NixOS VM

The package's Zig dependencies come from build.zig.zon.nix, which zon2nix generates. After changing build.zig.zon, regenerate it:

$ nix develop -c tools/zon2nix.sh

It leaves out the dependencies of Ghostty's own application (its fonts, images, GUI toolkits and shader compilers), which libghostty-vt never builds.

The VM test runs harrier in a headless sway, with lavapipe standing in for a GPU. pixman offers no dma-bufs, so this run presents through shared memory. It checks that the screenshot has text in it.

On Windows #

Zig is all it takes, and a GPU driver with Vulkan 1.2, which every current one has:

> zig build run
> zig build test --summary all

harrier is built for the GNU ABI, x86_64-windows-gnu, on Zig's own MinGW headers, so no Visual Studio is needed; a Windows target that names no ABI gets that one. Ghostty's build would otherwise take MSVC for itself.

The installer is an MSI, built by zixl from dist/windows/harrier.wxs as one more step of the build, on any machine Zig runs on:

$ zig build msi -Dtarget=x86_64-windows-gnu
$ nix build .#msi

Either puts harrier-VERSION-x64.msi in zig-out or result, with its ProductCode beside it in harrier-VERSION-x64.msi.ProductCode. The build is reproducible: built with Nix, one commit makes one MSI, bit for bit, and so one ProductCode.

Each release has one, built when a tag v and the version in build.zig.zon is pushed. It installs harrier for all users, in Program Files\harrier, with a Start menu shortcut, the directory on the PATH, an Open in harrier item in Explorer's menu for a folder, a drive and a folder's background (on Windows 11, under Show more options), and harrier as a source in the Event Log. An MSI replaces any harrier of its version or an older one, removing it first; it will not install over a newer one.

Installing with winget #

Every release is also published to the JCOllie winget source, as JCOllie.Harrier. Add the source once, from an elevated prompt:

> winget source add -n JCOllie -a https://winget.jcollie.dev/ -t Microsoft.PreIndexed.Package --trust-level trusted

then install harrier, and later upgrade it, as any other package:

> winget install JCOllie.Harrier -s JCOllie
> winget upgrade JCOllie.Harrier -s JCOllie

The MSI is not signed. winget marks an installer from an untrusted source as downloaded from the internet, and Microsoft Defender SmartScreen then stops an unsigned one with "Windows protected your PC". A source added with --trust-level trusted skips that; one added without it can be removed with winget source remove JCOllie and added again. An MSI downloaded from the release page gets the same warning, and Run anyway installs it.

Windows #

harrier on Windows is the same terminal in a Win32 window, presenting through a Vulkan swapchain, with programs on a pseudoconsole (ConPTY). What is different:

  • The pseudoconsole is harrier's own: the conpty.dll beside harrier.exe and the x64\OpenConsole.exe it runs, from Microsoft's ConPTY package, the console host Windows Terminal ships. Windows' own keeps escape sequences it does not know, OSC 7501 among them, from harrier, and answers some queries in its place; it is used only when harrier's own is missing, and harrier logs a warning when it is. The console host counts a character's cells as the pane's grapheme_width says.

  • The shell is PowerShell (pwsh), or else Windows PowerShell, or else COMSPEC, as Windows Terminal chooses; command and -e run anything else.

  • Where a window starts is where harrier was run, as on Linux, but not the system directory, C:\Windows\System32, where Windows starts a harrier from the Start Menu, a pin or a jump list item: there, and wherever nothing says, it is the user's home, %USERPROFILE%.

  • TERM is xterm-256color by default: Windows has no terminfo to install harrier's entry in, and ssh hands TERM to the server.

  • The title bar is harrier's own, as on a Linux desktop that leaves it to programs, with the profile selector's ▾ and the status button, in a frame Windows draws: its shadow and rounded corners, the edges it resizes by, Aero Snap, and the snap layouts offered over the maximize button. window.title_bar = .server has Windows draw its own title bar instead, dark or light as programs are set to be in Settings, and .none has no title bar at all. harrier's title is in Segoe UI, as Windows' own are. The window menu — Alt+Space, or a right press on the title bar — also offers a new window, a new tab and the profile selector. Its subtitle shows a location only when the shell reports one with OSC 7, since nothing on Windows can ask another process where it is.

  • The primary selection is harrier's own: selecting copies and the middle button pastes between its panes, but other programs do not see it.

  • One process, many windows, through a named pipe, \\.\pipe\dev.jcollie.harrier-<SID>, open only to the user, rather than the session bus. A launch checks that whoever serves it is the same user before it sends its command and environment.

  • As administrator, a program runs in a harrier of its own that is elevated, as in Windows Terminal: a program that is not elevated cannot start one that is in its own window. harrier --elevate, or a profile with .elevate = true, starts harrier again through Windows' "Run as administrator", which asks first; that harrier owns the elevated windows, and later elevated launches open theirs in it. Each one from a program that is not elevated is asked about again, as in Windows Terminal: only Windows' prompt may lift it; one from an elevated shell is not. The elevated harrier serves a pipe of its own, …-<SID>-admin, labelled so that nothing below high integrity can connect and checked again for each launch, so a program that is not elevated can never have it run something. Its windows' titles start with "Administrator: ". Windows keeps Explorer, which is not elevated, from dropping files on them.

  • Warnings and errors go to the Application log too, under the source harrier, since a GUI program's standard error is seen only where it is redirected; one that stops harrier starting is also shown in a message box when there is nowhere else to say it. harrier.exe carries the message table Event Viewer shows them through, but naming it takes a key under HKEY_LOCAL_MACHINE, so the first harrier run as administrator writes SYSTEM\CurrentControlSet\Services\EventLog\Application\harrier; until then Event Viewer says it found no description before each message.

  • Fonts are Windows' own and the user's, found by zig-font-config; monospace is Cascadia Mono, or Consolas where that is not installed. %APPDATA%\harrier\fonts.conf is read as a fontconfig file, for aliases and fonts of your own.

  • Configuration is %APPDATA%\harrier\config.struthio.

  • It is a GUI program, so that started from Explorer or a shortcut it opens no console window. What it writes — --help, --version, its log, why it could not start — is seen only where standard output and error are redirected (harrier --help > help.txt), and not in the console it was started from.

  • Input methods go through IMM32: what is being composed is drawn by harrier at the cursor, as on Wayland, and the input method's candidate window opens just below it.

  • Global keybinds are registered with RegisterHotKey.

  • Links open with whatever Windows has for them.

  • Serial ports are COM3 and the like, looked at every 10 ms rather than waited on.

  • Programs run in a job object that ends them with their pane, as a hangup does on Linux.

  • PNGs that programs send are decoded by z2dimg, not wuffs: translate-c turns the fortified string functions of an optimized MinGW build into Zig that does not compile, and wuffs' header pulls them in.

  • Drops from other programs come through OLE: files dropped on a pane whose program takes no drops are typed as Windows paths, in double quotes when they need them, which suits cmd and PowerShell alike. A program taking drops gets the files as a list of file: URIs, or the text; the program on the other side is always told it was a copy, since after a move it would delete what the program may not have read yet.

  • Drags out of a program go through OLE too, as files when the program offers a text/uri-list of paths on this machine, and as text when it offers text. A drop target asks for the data the moment it is dropped, so harrier asks the program and waits for it, up to five seconds without word from it, turning its loop meanwhile.

  • A program on another machine, or under WSL, which counts as one, is sent the files dropped on it, and the files it drags are written under %LOCALAPPDATA%\harrier\drags. A name Windows cannot hold — one with : or ? in it, or CON — has _ in place of what it cannot hold, and a symbolic link is left out, since making one takes a privilege the user may not have. A drop target waits for them all, however long they take, as long as they keep coming.

  • The jump list — right-click harrier's taskbar button, or its pin in Start — lists the open panes, each by its title and location, and the profiles. Picking a pane shows it; picking a profile starts it. Windows has nothing like GNOME Shell's search provider: Start asks no running program what it has as the user types, so this is the nearest thing. Each pane's item runs harrier --focus-pane PRIMARY:ID, which hands off to the harrier running. Windows keeps the list after harrier exits, so harrier takes the panes out of it as it does, and an item left by one that crashed brings forward the last window, or opens one, rather than showing another harrier's pane with the same ID. Only a harrier that is not elevated writes it; the elevated one's button has none. With Show recently opened items in Start, Jump Lists and File Explorer off, Windows takes no panes in it, only the profiles. The list is replaced at most once a second, however often titles change.

  • The taskbar button is dev.jcollie.harrier's, harrier's AppUserModelID, which its Start Menu shortcut carries too, so harrier pinned from Start and its windows share one button. An elevated harrier's windows are dev.jcollie.harrier.elevated's, on a button of their own. A harrier pinned by a version that did not set its ID yet is a button apart from its windows until it is pinned again.

  • Notifications are toasts, one for each pane, replaced and withdrawn as on Linux; a click shows the pane. Windows shows a program's toasts only under an application ID it knows, so the first one registers dev.jcollie.harrier under HKEY_CURRENT_USER\Software\Classes\AppUserModelId, with harrier's name and an icon it writes to %LOCALAPPDATA%\harrier. Windows takes them away when harrier exits.

  • The desktop's settings are Windows', as the portal's standard appearance keys: ctx.appearance has the color scheme programs are set to, the accent color, high contrast, and whether animation effects are off. A change to one evaluates the configuration again, as on Linux. @setting sees only those keys, in org.freedesktop.appearance.

  • Progress goes on every window's taskbar button, where a dock shows it on Linux: the mean of every pane that says how far it is, red while one reports an error or is waiting on the user, full red when none says how far. Each window has a button, and Windows may combine them, so each shows the programs' progress in all of them.

Not there: the search provider, Terminal Intent, cgroups and service mode, which are the Linux desktop's.

Usage #

harrier [options] [-e command [args...]]
harrier validate-config [--hostname NAME] [--color-scheme SCHEME] FILE

  -e, --command PROG ARGS...  run PROG in the window instead of the shell
  --serial PATH               open a serial port instead of running a program
  --baud RATE                 the serial port's rate (default 115200)
  --config PATH               the configuration file to read; also keeps
                              the window in this process
  --no-config                 read no configuration at all, not even the
                              default file: the built-in defaults; also
                              keeps the window in this process
  --profile NAME              open the profile NAME, as the profile
                              selector would
  --working-directory DIR     start the program in DIR
  --focus-pane PRIMARY:ID     show pane ID of the harrier whose process
                              ID is PRIMARY, as its jump list does on
                              Windows; its last window, or a new one,
                              when that harrier has gone
  --tab                       open a tab in the harrier window last used,
                              rather than a window
  --elevate                   on Windows, run as administrator: in a
                              harrier of its own, after Windows asks
  --standalone                open the window in this process rather than
                              handing it to a harrier already running
  --service                   run as the session bus service, with no
                              window until one is asked for
  --validation                turn on Vulkan's validation layer
  --version, -h, --help

The default key bindings:

Keys Action
ctrl+shift+c / ctrl+shift+v copy / paste
shift+insert, middle button paste the primary selection
shift+page_up / shift+page_down scroll a page
ctrl+shift+home / ctrl+shift+end scroll to the top / bottom
ctrl+shift+arrow_up / ctrl+shift+arrow_down scroll a line
ctrl+shift+z / ctrl+shift+x scroll to the previous / next shell prompt
ctrl+=, ctrl+-, ctrl+0 font size larger, smaller, as configured
ctrl+shift+n / ctrl+shift+q new window / close window
ctrl+shift+, read the configuration again
ctrl+shift+t new tab
ctrl+shift+w close the focused pane, and the tab with its last
ctrl+page_down / ctrl+page_up, ctrl+tab / ctrl+shift+tab next / previous tab
ctrl+shift+page_up / ctrl+shift+page_down move the tab left / right
alt+1 … alt+8, alt+9 that tab, the last tab
ctrl+shift+o / ctrl+shift+e split: a new pane to the right / below
ctrl+alt+arrows the pane in that direction
ctrl+super+] / ctrl+super+[ the next / previous pane
ctrl+super+shift+arrows move the nearest divider two cells that way
ctrl+super+shift+= even the splits out
ctrl+shift+enter zoom the focused pane to fill the tab, and back
ctrl+shift+s open or close the status window: every terminal's progress and program status
ctrl+shift+f search the focused pane's terminal and scrollback; Enter for the next older match, Shift+Enter for newer, Escape to close
ctrl+shift+g, ctrl+shift+h the next older or newer match of the last search, with the search field closed
ctrl+shift+p open or close the profile selector

Holding shift takes the mouse back from a program that has asked for it, so that text can still be selected.

Dragging a selection past the top or bottom of its pane scrolls the pane, a row at a time for as long as the pointer stays there, so that the selection can take in more than is on screen. Just past the edge it scrolls slowly, to stop on a line; further out, faster.

The tab bindings are GNOME Terminal's and the split bindings Ghostty's. alt+1 to alt+9 are taken from programs that use them, as GNOME Terminal takes them; .unbind them to give them back. close_tab closes a whole tab, and has no key of its own.

Each pane has a scrollbar over its right edge, shown while the pane is scrolled back or the pointer is at that edge: dragging the thumb scrolls, and a press on the track jumps there. The prompt keys need a shell that marks its prompts (OSC 133): fish does on its own; bash and zsh need shell integration, such as Ghostty's or kitty's.

A press in a pane focuses it; the focused pane is drawn as it is and the others faded toward their backgrounds, with a hollow cursor. Keys go to the focused pane, the wheel to the pane under the pointer. A divider is dragged to move it, and a pane whose program exits gives its place to its neighbour.

Panes are rearranged by dragging them. With the pointer near the top of a pane, in a tab of more than one, a handle shows at the middle of its top edge; taken by it, the pane can be dropped on another, beside whichever of that pane's edges the pointer is nearest, the half it would take marked while it is held there. The two share that pane's place, the one dropped taking the focus, and what the moved pane leaves goes to its old neighbour.

On the tab bar, a press shows a tab and dragging it along the bar moves it, the middle button or the × closes one, and + or a double-click on the empty part opens one.

Dragged well above or below the bar, a tab leaves its window, through the compositor's drag and drop, with a picture of it under the pointer:

  • dropped on a tab bar — another window's, or its own — it goes there, where a line marks the place. A window with one tab and no bar takes it along its top;
  • dropped anywhere else, or the drag cancelled, it gets a window of its own;
  • a window whose last tab leaves closes. Closing the last tab closes the window. With keep-terminal-open or a program that could not be started, a tab stays after its program has gone, and a key closes it.

Profiles #

A profile is a named way of opening a pane: an ssh to a host, a serial console, or any other program, listed in the configuration's profiles. Each can set environment variables for its program and give its panes colors of their own, so that a window on another machine looks like one.

.{
    .profiles = .{
        .{
            .name = "router",
            .command = .{ "ssh", "admin@router" },
            // The router has no terminfo entry for harrier.
            .env = .{ .{ .name = "TERM", .value = "xterm-256color" } },
            .colors = .{ .background = "#2a1010" },
        },
        .{ .name = "nas", .command = .{ "ssh", "nas.lan" } },
        .{
            .name = "console",
            .serial = .{ .path = "/dev/ttyUSB0", .baud = 9600 },
            .open = .tab,
        },
    },
}

A profile has:

  • name: what the profile selector lists it as, and its window's title until its program sets one. Each profile needs a name of its own.
  • command or serial: the program to run, or the serial port to open, with the same settings as the top-level serial. With neither, the profile runs the shell.
  • cwd: where the program starts.
  • env: environment variables, as name and value pairs, set over harrier's own, TERM included.
  • grapheme_width: .unicode or .legacy, in place of the configuration's (see Configuration).
  • bell_sound: what its panes' bell plays, in place of the configuration's bell.sound, written the same way (see Configuration).
  • clipboard_read and clipboard_write: .ask, .allow or .deny, in place of the configuration's clipboard.read and clipboard.write: .deny for a host whose programs should never see the clipboard, say (see Configuration).
  • colors: foreground, background, cursor and palette, each in place of the configuration's. Whatever is left out comes from the configuration, and follows it when it changes, light and dark alike. The title bar and tab bar take the colors of the focused pane, so a window on another machine is colored all over.
  • open: .window (the default) or .tab, in the window the profile selector was opened from.
  • elevate: on Windows, true runs it as administrator, after Windows asks, in a harrier of its own (see Windows). harrier --profile NAME opens a profile from the command line, the same way.

On Windows, in %APPDATA%\harrier\config.struthio, with each \ in a path written \\:

.{
    .profiles = .{
        // PowerShell as administrator, after Windows asks.
        .{ .name = "admin pwsh", .command = .{ "pwsh.exe", "-NoLogo" }, .elevate = true, .colors = .{ .background = "#2a1010" } },
        .{ .name = "cmd", .command = .{"cmd.exe"}, .open = .tab },
        // A WSL distribution, in its home directory.
        .{ .name = "ubuntu", .command = .{ "wsl.exe", "-d", "Ubuntu", "--cd", "~" } },
        // Git's bash, as a login shell.
        .{ .name = "git bash", .command = .{ "C:\\Program Files\\Git\\bin\\bash.exe", "--login", "-i" } },
        // Windows' own OpenSSH.
        .{ .name = "router", .command = .{ "ssh.exe", "admin@router" }, .env = .{ .{ .name = "TERM", .value = "xterm-256color" } } },
        // Visual Studio's developer shell.
        .{
            .name = "vs dev",
            .command = .{ "pwsh.exe", "-NoExit", "-Command", "& { Import-Module \"$env:ProgramFiles\\Microsoft Visual Studio\\2022\\Community\\Common7\\Tools\\Microsoft.VisualStudio.DevShell.dll\"; Enter-VsDevShell -VsInstallPath \"$env:ProgramFiles\\Microsoft Visual Studio\\2022\\Community\" -SkipAutomaticLocation }" },
        },
        .{ .name = "console", .serial = .{ .path = "COM3", .baud = 115200 }, .open = .tab },
        .{ .name = "projects", .cwd = "C:\\Users\\me\\src" },
    },
}

harrier --profile "admin pwsh" opens the first from a shortcut or the Run dialog.

ctrl+shift+p, or the ▾ button, opens the profile selector over the window. The button is at the left of harrier's title bar and beside the tab bar's +, and is only there when there are profiles. Typing narrows the list with zf's fuzzy matching: each word typed must be found, its letters in order but not necessarily together, in a profile's name or in what it runs. rtr finds router, and ssh bld the profile named build-server that runs ssh builder. The best matches come first: those whose words were all found in the name, then by how closely the letters sit together and how near the start of a word they fall. The letters found are underlined.

Keys Action
arrows, ctrl+p / ctrl+n the previous / next profile
page_up / page_down a page up / down
Enter, or a click open the profile where it says
Shift+Enter, or a shift-click open it in the other place: a tab instead of a window, or a window instead of a tab
ctrl+u clear what has been typed
Escape, ctrl+shift+p, or a click outside close the profile selector

Pastes and the middle button go into the profile selector while it is open.

A new tab or split made from a profile's pane runs the profile again, with its environment and colors: a split of an ssh session is another ssh session to the same host. A serial port cannot be opened twice, so a split of a serial console runs the shell, in the console's colors.

When a profile's program exits with an error, its pane stays open to say how it ended, so an ssh that could not reach its host says why rather than its window vanishing. A key closes it. A program that cannot be started at all, or a port that cannot be opened, is reported the same way.

TERM and terminfo #

harrier has a terminfo entry of its own, harrier (also named xterm-harrier). It describes everything harrier's terminal does that xterm-256color does not:

  • 24-bit color;
  • curly and colored underlines, and overlines;
  • cursor shapes, and resetting the cursor to the configured one;
  • synchronized output and bracketed paste;
  • focus reports and SGR mouse reports;
  • OSC 52 clipboard reads and writes;
  • clearing the scrollback.

harrier's terminal is Ghostty's, so the entry is Ghostty's, under harrier's names. Its source is dist/terminfo/harrier.terminfo.

zig build installs the source, the compiled entry under share/terminfo (when ncurses' tic is on the path) and a termcap version, share/terminfo/harrier.termcap. harrier points the programs it runs at the copy beside it with TERMINFO, so on the machine harrier runs on, TERM=harrier, the default, needs nothing else.

A machine reached over ssh needs the entry too, or programs there stop with "unknown terminal type". To install the entry on a server:

  • With Nix, the harrier-terminfo package holds the entry alone. It builds in seconds and pulls in nothing of harrier's:

    nix profile install git+https://git.jcollie.dev/jeff/harrier.git#harrier-terminfo
    
  • On NixOS, the module installs it alone with programs.harrier.terminfo.enable = true; (programs.harrier.enable installs it with the rest of harrier).

  • Anywhere else, copy it from a harrier terminal: tic compiles it into ~/.terminfo on the server, or into the system database when run as root.

    infocmp -x harrier | ssh server -- tic -x -
    

Where the entry cannot be installed, xterm-256color is on every machine already, at the cost of what it does not describe:

.{ .term = "xterm-256color" }

or, for one ssh session, on its command line, since ssh hands the server the TERM it was started with:

TERM=xterm-256color ssh old-server

or, for a host visited often, in a profile for it, with TERM in its env.

Translations #

harrier's own text is in locales/, one Fluent file per language, en-US.ftl the source the others follow. That covers:

  • the status window;
  • the search field: its placeholder and its count of matches;
  • program status notifications;
  • what a pane says when its program has gone or could not start;
  • the names of actions, as the desktop shows them when it asks about global shortcuts.

Log messages and the command line's help stay in English.

The language is chosen when harrier starts, from LANGUAGE, LC_ALL, LC_MESSAGES and LANG, or from the configuration's language, a list in the same form as LANGUAGE:

.{ .language = "pt-BR:pt" }

The closest translation wins: the same tag, then the same language and script, then the same language; with none, English. A message a translation leaves out comes from English. Numbers in messages are written the way LC_NUMERIC says. Changing language in a running harrier takes effect when the configuration is reloaded.

Translations are embedded in the binary, so there is no data directory to install. CONTRIBUTING.md says how to add one.

Running as a service #

zig build -Dbindir=/where/harrier/is installs five things beside the binary. Each of the first three names the binary by its absolute path, which is why the option exists; the Nix package passes it.

  • share/applications/dev.jcollie.harrier.desktop: the desktop entry, with DBusActivatable=true, Implements=org.freedesktop.Terminal1 and a New Window action.
  • share/dbus-1/services/dev.jcollie.harrier.service: lets the session bus start harrier when a window is asked for, through systemd.
  • share/systemd/user/app-dev.jcollie.harrier.service: harrier as a user service, harrier --service. It starts with no window, opens the ones it is asked for, and exits five minutes after the last one closes (see service.idle_exit_seconds). Stopping it closes its windows.
  • share/icons/hicolor/*/apps/dev.jcollie.harrier.png: the icon.
  • share/gnome-shell/search-providers/dev.jcollie.harrier.search-provider.ini: GNOME Shell's search, below. This one is installed without -Dbindir.

On Windows, the jump list is what finds a pane; see Windows.

/dev/jcollie/harrier/SearchProvider serves org.gnome.Shell.SearchProvider2. As the overview is typed in, it finds the open panes by their title and location, and shows each with its title, its location, and harrier's icon. It matches as the profile selector does, with zf: each word typed must be found, its letters in order, ignoring case, in the title or else the location, so hrr finds ~/dev/harrier. The location is as the title bar's subtitle writes it — ~/dev/harrier, or router:/etc/nginx for a shell on another machine — so a host's name finds the panes on it, and it is matched as a path, a match in its last part counting for more. The best matches come first, those found in the title before those found only in the location. Picking one shows that pane, in its tab, and raises its window.

After the panes come the profiles, best first, found by their name or else what they run, each shown as its name and "Profile · " and what it runs: router finds both the ssh session already open to the router and, after it, the profile that would open another. Picking a profile starts it as the profile selector would, in a window or a tab of the last window as it says. Picking harrier's own icon beside the results shows the first pane found, or else starts the first profile found, or opens a window when neither is.

  • Only while harrier runs. The registration file says AutoStart=false, so the Shell asks only a harrier already on the bus. Without one there are no panes to find, and though there are profiles, starting harrier to offer them would cost a font scan on the first keystroke of every search in the overview, for anyone with harrier installed.
  • Raising the window. The Shell passes only a timestamp, not an activation token, and the token harrier could ask for itself carries the serial of its own last input, which is stale — the click went to the Shell — so Mutter would only say "harrier is ready". On GNOME, harrier registers a startup sequence with gtk_shell1.notify_launch, Mutter's protocol with GTK, and activates the window by that, as GTK does for what it launches; focus stealing prevention still applies. Elsewhere it asks for a token as usual.
  • Where it is found. The Shell reads registration files from $XDG_DATA_DIRS, not ~/.local/share. The NixOS module links them into the system profile; with Home Manager alone, useUserPackages puts the package in a profile that is on that path.
  • Where its results appear is the user's to choose: GNOME Settings' Search page orders the providers and switches them off.

It can be tried without the Shell:

$ busctl --user call dev.jcollie.harrier /dev/jcollie/harrier/SearchProvider \
    org.gnome.Shell.SearchProvider2 GetInitialResultSet as 1 vim
as 1 "pane-3"
$ busctl --user call dev.jcollie.harrier /dev/jcollie/harrier/SearchProvider \
    org.gnome.Shell.SearchProvider2 ActivateResult sasu pane-3 1 vim 0

The Terminal Intent #

The same object, /dev/jcollie/harrier, serves org.freedesktop.Terminal1 beside org.freedesktop.Application, as the Terminal Intent asks. LaunchCommand opens one window with a tab for each command it is given, as the specification suggests, or with the shell for none:

  • exec is the program and its arguments; empty runs the shell.
  • env is set on top of harrier's environment rather than replacing it.
  • working_directory is where it starts. Without one, the desktop entry's Path is used, and failing that $HOME.
  • desktop_entry, when given, names the tabs after the entry's Name. One that is not a desktop entry refuses the call with org.freedesktop.DBus.Error.InvalidArgs.
  • keep-terminal-open keeps each tab after its program exits, saying how it exited, until a key is pressed.
  • The activation token in platform_data goes to the window.

A program that cannot be started still gets its tab, saying why and waiting for a key, and the rest of the commands are launched regardless. The paths and arguments are ay; the NUL that GLib's bytestrings end with is dropped.

To try it by hand, busctl takes each ay as a length and its bytes:

$ busctl --user call dev.jcollie.harrier /dev/jcollie/harrier \
    org.freedesktop.Terminal1 LaunchCommand 'aa{sv}aya{sv}a{sv}' \
    1 1 exec aay 1 3 116 111 112 \
    0 1 keep-terminal-open b true 0

(gdbus call with b'…' literals nested in variants is no use here: it sent corrupted bytes when this was tested.)

On NixOS, the flake's module does all of it:

{
  inputs.harrier.url = "git+https://git.jcollie.dev/jeff/harrier.git";

  # in the NixOS configuration:
  imports = [ inputs.harrier.nixosModules.harrier ];
  programs.harrier.enable = true;
}

It installs the package, with its terminfo entry; the D-Bus service file and the systemd user unit (services.dbus.packages, systemd.packages; programs.harrier.service.enable turns them off); and links share/gnome-shell/search-providers into the system profile (programs.harrier.searchProvider.enable), which NixOS does not do by default — only GNOME's own module links all of share. On a server only reached from harrier over ssh, programs.harrier.terminfo.enable installs the terminfo entry alone. The configuration file is each user's, and the Home Manager module writes and checks it.

The service gets its environment from the systemd user manager, which needs the graphical session's: WAYLAND_DISPLAY above all. Desktop sessions export it at login with dbus-update-activation-environment --systemd WAYLAND_DISPLAY, or systemctl --user import-environment. A window handed over from a shell still runs with that shell's environment and directory, because the launch sends both.

Configuration #

harrier reads harrier/config.struthio from the configuration directory known-folders finds: $XDG_CONFIG_HOME, or ~/.config. Without the file, it uses the defaults, and --no-config uses them whatever files there are — to see whether a problem is the configuration's. A struthio file is ZON that can make decisions:

.{
    .font = .{
        .family = "Iosevka Term",
        .size = if (ctx.hostname == "laptop") 11 else 13,
        .features = .{ "ss05" },
    },
    .colors = .{ .background = "#1d1f21", .foreground = "#c5c8c6" },
    .cursor = .{ .style = .bar, .blink = false },
    .command = .{ "fish", "--login" },
    .keybind = .{
        .{ .keys = "super+c", .action = .copy },
        .{ .keys = "ctrl+shift+n", .action = .unbind },
    },
}

ctx.hostname and ctx.os say where it is running, and @env("NAME") reads the environment.

link_detection, on by default, makes URLs in the text open with ctrl+click as hyperlinks a program makes (OSC 8) do; off, only those do.

grapheme_width is how many cells a grapheme takes. .unicode, the default, gives it the width Unicode does, so that ➡️ and 👍🏽 are wide, and turns on mode 2027 for programs to see. .legacy gives it the width of its first code point, which is what a program counting with an older wcwidth expects. Either way a program can switch to the other with mode 2027, and a change applies to panes opened afterwards. A profile can set its own, for a host whose programs count the old way:

.{ .profiles = .{ .{ .name = "old box", .command = .{ "ssh", "old-box" }, .grapheme_width = .legacy } } }

cgroup puts each program in a systemd scope of its own, on by default:

.{
    .cgroup = .{
        .memory_high = 4 * 1024 * 1024 * 1024, // MemoryHigh, in bytes
        .tasks_max = 4096, // TasksMax
        .required = false, // true: no scope, no program
    },
}

Every scope is also given ManagedOOMMemoryPressure=kill, so that systemd-oomd kills that program rather than harrier. Without systemd, a program runs in harrier's own cgroup, with a warning, unless required is set. .enabled = false turns scopes off.

notifications says when a program's desktop notification is shown, asked for with OSC 9 or OSC 777 or sent for its program status: .unfocused, the default, only when its pane is not the one being looked at; .always; or .never.

bell says what a program ringing its pane's bell does, each on its own:

  • sound: what it plays. .none, the default, plays nothing. .desktop plays the desktop's own bell sound, through xdg-system-bell-v1, or GNOME's own protocol, or Windows'; nothing where the compositor has neither, as on sway. .{ .file = .{ .path = "...", .volume = 0.5 } } plays a sound file — WAV, FLAC, Opus or MP3, of at most 30 seconds — through PipeWire or Windows' audio, at a volume from 0 to 1 (1 if left out), under the desktop's own volume for notifications. ~/ in the path is the home directory, and a relative path is taken from the directory of the configuration file, even when it is written in a file that one @includes. The file is read when the configuration is loaded, and again on every reload; one that cannot be played — not there, not a format harrier reads, too long, or no sound output — rings the desktop's bell instead, and the log says why. A bell rung while its sound is still playing lets it finish. A profile's bell_sound takes the place of sound for its panes.
  • flash: the pane lightens for a moment. Off by default.
  • attention: when the window does not have the focus, the desktop is asked to show that it wants attention, until it is focused. On by default.
  • mark: 🔔 before the title of the window, and of the tab, holding a pane whose bell rang while it was not being looked at, until it is. On by default.
  • notify: a desktop notification, as notifications allows. Off by default.

Attention, the mark and the notification come only for a pane that is not being looked at — the focused pane of the shown tab of the focused window — and once until it is; rings a quarter of a second apart or closer count as one.

.{ .bell = .{ .sound = .{ .file = .{ .path = "~/sounds/bell.flac" } }, .flash = true } }

clipboard says what programs may do with the clipboard, and which pastes harrier asks about first. Asking is a confirmation over the pane: Enter gives the highlighted answer, which starts on the one that does nothing, Escape is that one, and Left, Right and Tab move between them. The text that would be pasted or written is shown as it is, in a box of the pane's own colors under a line saying how many lines and bytes it is: each line on a row of its own, its line break marked ⏎ — \n, \r\n, or a lone \r, any of which reaches the program as Enter — a long line wrapped with ↪ before each row after its first, tabs at stops of eight, and control characters shown dim as their pictures (␛, ␇, ␡), or as � with what is not UTF-8. The confirmation grows with the text until it fills the pane, and then the text scrolls: with the wheel, Up and Down, and PageUp and PageDown.

  • read: a program reading the clipboard or the primary selection, with OSC 52 or with kitty's clipboard protocol (OSC 5522). Anything run in a pane, an ssh's far end included, could take what was copied, a password say, so .ask, the default, asks first: Allow, Allow for this pane, which holds until the pane closes, or Deny. .allow lets every program read without asking, and .deny refuses them all without asking. A program refused with OSC 52 is answered with an empty clipboard, as xterm answers it; with kitty's protocol, with EPERM. With kitty's protocol a program may read any of the clipboard's types, an image say, and is handed text under whichever text type it asks for; OSC 52 reads only text. On Windows a bitmap on the clipboard, which is what most screenshot tools put there, is read as image/png. Two of kitty's reads are not asked about, as kitty does not ask: one for only the list of types on the clipboard, which .deny still refuses; and a program fetching what the user has just pasted, which is never refused. A program that turns on kitty's paste events (mode 5522) is told of a paste rather than sent it, and fetches it that way.
  • write: a program putting something on the clipboard, with OSC 52 or kitty's protocol, in place of what is there: text, or with kitty's protocol any types, an image beside its text say. .allow, the default, lets it; .ask asks first, showing the text, a PNG image itself above it, and each type with its size; .deny refuses it. A program writing with kitty's protocol is told how it went once it has: DONE when the write is on the clipboard, EPERM when it was refused. On Windows an image is offered as a bitmap as well as a PNG, for the programs that take nothing else, and a type Windows has no format for is put there under its own name.
  • confirm_unsafe_paste: asks before a paste that would run as it arrives, showing the text: one with a line break in it, into a program that has not asked for bracketed paste. Paste or Cancel. Every way of pasting to a program is asked about: the paste key, the selection's, the middle button, and dropping text or files. On by default. A paste into a program that turned on kitty's paste events is not asked about: nothing of it runs as it arrives, since the program fetches it itself.
  • toast: a toast, a line at the bottom of the pane for a moment, saying what was just done with the clipboard or the primary selection without asking: what the user copied with the copy key ("Copied 42 characters"), and what a program wrote or read, named by its foreground program ("ssh copied 3 lines to the clipboard", "nvim read the primary selection"). Text is counted in lines, or in characters when it is one line; anything else is listed by type and size. A program writing nothing has "Cleared the clipboard". .always, the default, shows them all; .programs only what programs do; .never none. Copying by selecting has none, since it happens at every release of the button, and neither has anything the user has just answered a confirmation about. A copy that fails says so whatever this is. A newer toast replaces the one shown; how long one stays is window.toast_ms.

A pane asking while it is not being looked at waits for an answer there, and 📋 marks its tab and the window's title until it is looked at; the window asks for attention as for a bell. A pane asks one thing at a time: a program asking again replaces its own question, which is answered no, and a paste waiting to be confirmed is never replaced.

.{ .clipboard = .{ .read = .deny, .write = .ask } }

A key binding with .global = true works wherever the focus is, not only in harrier's windows: a global hotkey.

.{
    .keybind = .{
        .{ .keys = "super+backquote", .action = .present_window, .global = true },
        .{ .keys = "ctrl+alt+t", .action = .new_window, .global = true },
    },
}

present_window raises the window last used, or opens one when there is none; new_window and new_tab open one; any other action acts on the window last used, raised. harrier binds them, in this order of choice:

  1. through the compositor, with xx-hotkey-v1, where it has it;
  2. through the compositor, with vicinae-hotkey-v1, the same protocol under the name some compositors shipped first — Hyprland, from 0.56;
  3. through the desktop portal's GlobalShortcuts, which KDE Plasma and GNOME, among others, provide.

With either protocol, the binding is exactly the key asked for, for as long as harrier runs, and a key the compositor refuses or takes back is said in the log with its reason. The portal takes the keys only as what harrier would prefer: the desktop may ask the user, and the user may choose others, which it remembers by action. Only the harrier that owns the session bus name binds them, so a second one does not take them from the first. A hotkey's press is an input the compositor counts, so the window it raises or opens takes the focus.

drag_and_drop, on by default, lets programs take part in drag and drop with kitty's protocol. Off, harrier ignores the protocol, as a terminal that has never heard of it would, and programs fall back to whatever they do without it.

images says what programs may do with kitty's graphics protocol:

.{
    .images = .{
        // Whether programs may show images at all; off, a program
        // asking is told they are not supported.
        .enabled = true,
        // The most of its images' pixels each terminal keeps, per
        // screen, before the oldest go.
        .storage_bytes = 320 * 1000 * 1000,
        // Whether a program may send an image as a file, a temporary
        // file or shared memory. Never for a serial port, whose other
        // end is another machine, and whose file names would be read
        // here.
        .files = true,
    },
}

Including other files #

@include("path") stands for another file's value, so a configuration can keep a theme, or its key bindings, in a file of its own:

let theme = @include("themes/one.struthio");
.{ .colors = if (ctx.appearance.color_scheme == .light) theme.light else theme.dark }

A relative path is resolved against where the including file really is, symbolic links followed: a config.struthio linked from a dotfiles repository includes the files next to it there. Included files are read with the configuration, and again on every reload. A mistake in one is reported at that file and line.

Following the desktop #

harrier reads the desktop's settings from the XDG desktop portal (org.freedesktop.portal.Settings) when it starts, watches them, and evaluates the configuration again whenever one changes, applying the result to everything open; on Windows, from Windows' own (see Windows). The configuration sees them two ways:

  • ctx.appearance, the portal's standard appearance keys: color_scheme (.dark, .light or .no_preference), accent_color ("#rrggbb", or null when the desktop has none), contrast (.normal or .high) and reduced_motion (.reduce or .no_preference).
  • @setting("namespace", "key"), any setting the desktop publishes, or null: a string, a number, a boolean, or a list.
let light = ctx.appearance.color_scheme == .light;
.{
    .colors = .{
        .background = if (light) "#fafafa" else "#1d1f21",
        .foreground = if (light) "#383a42" else "#c5c8c6",
        .cursor = ctx.appearance.accent_color,
    },
    .font = .{ .family = @setting("org.gnome.desktop.interface", "monospace-font-name") orelse "monospace" },
}

A reload changes the colors — the defaults, so a color a program set stays — the font, the cursor, the key bindings, the padding and the tab bar of every window open. What is fixed when a window is made, the program it runs and who draws its frame, applies to new ones. A configuration that no longer evaluates is reported and the one in use kept. ctrl+shift+, reloads by hand, after editing the file. Without a portal, the configuration sees the defaults; validate-config checks a file under every color scheme.

Every field is in src/config/Config.zig:

  • font: the family, as a fontconfig pattern; the size in points; OpenType features; ligatures on or off; and builtin_glyphs, on by default, which draws box drawing, blocks, braille and the like to fit the cell rather than taking them from the font.

  • colors: the foreground, the background, the cursor, and palette overrides. A color is anything libghostty-vt parses, X11 names included. progress colors the progress bars: .normal, .@"error", .paused (for waiting on the user too) and .done, each unset by default, for the palette's blue, red, yellow and green as the program has them. search colors search matches: .background and .foreground for each, .selected_background and .selected_foreground for the one selected; Ghostty's yellow and orange by default.

  • cursor: the style and whether it blinks.

  • window: the padding, the initial size in cells, and in title_bar, who draws the title bar and frame:

    • .server (the default) asks the compositor, and harrier draws its own where the compositor will not, as on GNOME or weston;
    • .client always draws harrier's;
    • .none draws nothing.

    harrier's own title is drawn in the desktop's interface font, as the desktop's own title bars are: GNOME's font-name, through the portal's settings — Cantarell 11, say — or sans-serif at 10.5 points.

    And subtitle, a second, smaller line under harrier's own title: .location (the default) shows where the focused pane's program is, and .none nothing. A location on this machine is its directory, with the home directory written ~; on another it is host:path, as scp writes it. Only the shell can say where it is on another machine, with OSC 7, which its prompt can be set up to send, so nothing is guessed from an ssh command line. On Linux, until a shell first says, the location is the directory the program on the pty is in. A location too long to fit loses the start of its path. The bar is two lines tall whenever the subtitle is .location, whether or not there is a location to show, so the terminal keeps its rows as one comes and goes. A title bar the compositor or Windows draws has no subtitle.

    And tab_bar: .auto (the default) shows the tabs once there are two, .always from the first, .never not at all. When the bar comes or goes, a floating window grows or shrinks by its height, so the terminals keep their size; a maximized, fullscreen or tiled window keeps the size the compositor gave it, and the terminals give up the room or take it back. And scrollbar: .auto (the default) while a pane is scrolled back or pointed at, .always whenever it has scrollback, .never not at all. And resize_overlay: when a pane's columns and rows change, they are shown in a box at its middle for resize_overlay_ms (750 by default) — .after_first (the default) once the pane has been shown at its first size, .always from that first size on, .never not at all. toast_ms (3000 by default) is how long a toast, such as the clipboard's, stays over its pane; 0 shows none. progress_bar = false draws no progress bars, on panes or tabs, and dock_progress = false puts none on harrier's icon, or on Windows' taskbar buttons.

    And opacity, from 0 to 1 (1 by default): how opaque the background is. Text, and cells a program colored itself, stay opaque, as do harrier's title bar and tabs. blur = true asks the compositor to blur what is behind a translucent window, through ext-background-effect-v1; where the compositor does not offer it, nothing is blurred and the log says so.

  • command or serial: what the window shows. serial takes the path, rate, data bits, parity, stop bits and flow control.

  • term: what TERM is set to (harrier, harrier's own terminfo entry). See TERM and terminfo.

  • language: the language harrier's own text is in, as a list of tags ("de", "pt-BR:pt"). Unset follows the environment. See Translations.

  • scrollback_bytes: how much scrollback to keep.

  • present: .auto, .dmabuf or .shm.

  • gpu: part of a Vulkan device's name ("Radeon", "llvmpipe"), to choose one on a machine with several. Unset uses the device the compositor composites with.

  • service: idle_exit_seconds, how long the service keeps running without a window. The default is 300; null keeps it running.

  • keybind: bindings added on top of the defaults, and global hotkeys, with .global = true.

  • profiles: named ways of opening a pane, which the profile selector lists. See Profiles.

  • foreground_poll_ms: how often, in milliseconds, each pane's foreground program is looked for (250 by default), for a configuration that reads ctx.foreground; 0 looks only when a key is pressed and when the program writes something. See Following the foreground program.

Following the foreground program #

On Linux, a configuration can depend on what each pane's terminal belongs to right now — its foreground program: the editor or ssh or root shell the pane's shell has handed the terminal to, or the shell itself when nothing else is running. ctx.foreground is null, or:

  • name: what it was started as, without the directory (ssh, nvim);
  • exe: where its executable is, links followed, or null when that cannot be read, as for another user's program;
  • uid: the user it runs as, its effective UID;
  • user: that user's name in /etc/passwd, or null. A user known only to systemd-homed or a directory service has none.
.{
    .colors = .{
        .background = if (ctx.foreground) |f| switch (f.name) {
            "ssh" => "#002030",
            "pwsh" => "#012456",
            else => if (f.uid == 0) "#3a0000" else "#1d1f21",
        } else "#1d1f21",
    },
    .keybind = if (ctx.foreground) |f| (if (f.name == "nvim") .{
        .{ .keys = "ctrl+shift+c", .action = .unbind },
    } else .{}) else .{},
}

A configuration that reads ctx.foreground is evaluated again for each pane, with its foreground program, and the pane takes from that its colors, its keybind entries but the global ones, bell, cursor, clipboard, link_detection, notifications, drag_and_drop and images, with its profile's colors, bell sound and clipboard access over them. Everything else — fonts, windows, the profiles, global bindings — comes from the evaluation that sees ctx.foreground as null, as do grapheme_width, fixed for a pane's life, and all of a serial port's or a message's. A pane's evaluation giving one of those another value is logged, and reported by validate-config, and has no effect. Each program is evaluated once and kept until the next reload; one whose evaluation fails is logged, and its panes keep the app's configuration. A configuration that never reads ctx.foreground is evaluated once, as before, and nothing is looked for.

harrier looks before handling each key, so that a program's bindings are its own from the first key; after the program writes something; and every foreground_poll_ms, which also notices an exec or a setuid that leaves the process group as it was. A program that starts and writes nothing is seen that long after.

What harrier can see is only its own pty's foreground program. sudo runs its command on a pty of its own, so sudo -i and sudo ssh host are both sudo, with UID 0; tmux and screen are themselves; and what runs on the far side of an ssh is invisible. On Windows, whose pseudoconsole has no foreground process group, ctx.foreground is always null.

Checking a configuration #

$ harrier validate-config ~/.config/harrier/config.struthio
/home/me/.config/harrier/config.struthio: colors.palette[3]: "gren" is not a color
/home/me/.config/harrier/config.struthio: keybind[0]: "hyper+x" is not a key harrier can read

validate-config evaluates the file and checks what struthio sees only as strings: the colors, the key bindings and the font features. It reports every problem rather than the first, exits 1 if there were any, and opens no window. A running harrier would refuse to start over a bad color but only warn about a bad binding or feature; this treats them all as mistakes. --hostname NAME sets what the file sees as ctx.hostname, to check a file meant for another machine. The file is evaluated and checked once for each color scheme it could see as ctx.appearance.color_scheme — no_preference, dark and light — so that a mistake on one side of an if is found whichever side the desktop is on; a problem found under only some of them says which. --color-scheme SCHEME checks under that one alone. @setting gives null, as it does without a desktop.

A file that reads ctx.foreground is also checked as a pane would see it, with each foreground program --foreground NAME[:UID] names — the UID is yours unless given, and the program has no user name — or, without one, as sh run by you and by root. A mistake there, f.user.? for a program with no user name, say, is reported with the program it was found for, as is anything such a pane says that only the app's evaluation decides.

Home Manager #

The flake has a Home Manager module, homeModules.harrier. It installs harrier, writes its configuration, and installs the D-Bus service file and systemd user unit through dbus.packages and systemd.user.packages, so that one harrier opens every window.

The configuration is a struthio file, given as text or as a file, not generated from Nix settings:

{
  inputs.harrier.url = "git+https://git.jcollie.dev/jeff/harrier.git";

  # in a Home Manager configuration:
  imports = [ inputs.harrier.homeModules.harrier ];

  programs.harrier = {
    enable = true;
    configText = ''
      .{
          .font = .{ .family = "Iosevka Term", .size = if (ctx.hostname == "laptop") 11 else 13 },
      }
    '';
    # or: configFile = ./harrier.struthio;
    # what ctx.hostname is while it is checked; the sandbox is `localhost`
    validateHostname = "laptop";
  };
}
  • configText or configFile: one or the other, or neither for the defaults. A Nix path in configFile is copied into the store; a string naming a file outside it ("/home/me/dotfiles/harrier.struthio") is linked to where it is, so editing it needs no rebuild.

  • extraFiles: files beside the configuration, for it to @include, each by its path relative to the configuration, with text or a source:

    extraFiles."themes/one.struthio".source = ./themes/one.struthio;
    

    They are checked with the configuration, laid out together as they will be installed, so that an include finds the same file at build time.

  • validate (on by default): the file is run through harrier validate-config as it is built, so a mistake fails home-manager switch rather than the next window. A file linked from outside the store cannot be read by the build and is not checked. The check runs in the build sandbox, where @env finds almost nothing set.

  • validateHostname: what ctx.hostname is during the check.

  • service.enable (on by default): the D-Bus and systemd user service. The service reads its configuration when it starts, and stopping it closes every window, so switching does not restart it; a new configuration applies once it next starts.

  • package: harrier from this flake, by default.

Design #

                    ┌────────────── main thread ──────────────┐
 compositor ◀──────▶│ aerie     ─▶ Window ─▶ frame.Builder    │
 (Wayland)          │ platform       │      ─▶ aerie Renderer ─▶ Target ─▶ dma-buf / shm
                    │                │ lock                    │
                    └────────────────┼─────────────────────────┘
                                     ▼
                              termio.Session  (Terminal + stream, behind a mutex)
                                     ▲ nextSlice     │ outbox
                         IO thread ──┘               ▼
                              Backend: Pty | Serial | Message
  • backend/: what is on the other side.
    • Backend is a tagged union of three: Pty, which forks the program onto a pseudo-terminal using raw system calls; Serial, a serial port; and Message, nothing on the other side but text saying why a program could not be started or a port opened, so that the pane can stay open to say so.
    • On Windows Pty is ConPty: the program on a pseudoconsole, in a job that ends it with its pane. An anonymous pipe cannot be waited on, so a thread of its own reads what the pseudoconsole writes, a little ahead of the IO thread, and sets an event; the process is waited on as well, since the pipe ends only once the pseudoconsole is closed. The pseudoconsole is the conpty.dll beside harrier.exe's, loaded by its full path, or Windows' own when it or its OpenConsole.exe is missing.
  • desktop: which of the next three there are. On Windows, Instance stands in for Activation, windows/Portal for Portal, and Scopes is a stand-in that is never there.
  • Instance: one harrier with many windows on Windows, over a named pipe open only to the user, with a thread of its own that queues the windows asked for as Activation's does. A launch checks the user at the other end before it sends anything. An elevated harrier has a pipe of its own, with a high integrity label, and each end checks that the other is elevated; windows/elevation tells whether a process is, and starts harrier again with the shell's runas.
  • Activation: the session bus. It owns the name and serves org.freedesktop.Application and org.freedesktop.Terminal1, on a thread of its own, through zig-dbus-service. Its handlers only queue the windows and tabs asked for and wake the main loop. It is also the client a launch uses to hand its window, or with --tab its tab, over: the new-window and new-tab actions. A click on a notification can come back through it too, as focus-pane.
  • Scopes: a cgroup for each program. The program's process is forked and waits, before it execs, on a pipe; meanwhile harrier asks the user's systemd for a transient scope holding it (StartTransientUnit, on the session bus), waits until /proc/<pid>/cgroup says it is there, and then lets it go. So the program, and everything it forks, starts in the scope, rather than being moved there while it runs.
  • Portal: the XDG desktop portal, on a thread and connections of its own: the desktop's settings, watched; and notifications, which the main thread queues for it. Notifications go on a second connection, opened for the first one, so that the harrier owning the bus name can first register as dev.jcollie.harrier (org.freedesktop.host.portal.Registry), which has to come before anything else on a connection. The desktop then shows them as harrier's and activates the application when one is clicked. Any other harrier stays anonymous and names its action without app., so that the click comes back to it as ActionInvoked rather than going to the other by name. A notification is named for its process and pane, so that each pane has one at a time and a click finds only its own. Links open on the same connection, so that the desktop opens them on harrier's behalf: OpenURI for a URI, and OpenFile, with the file open, for a file on this machine, which OpenURI refuses.
  • windows/Portal: Portal's part on Windows: the desktop's settings, and notifications as WinRT toasts. The settings are read where Windows keeps them — the registry, UISettings, SystemParametersInfoW — and again when a hidden top-level window of its own is told one changed, which a message-only window is not; on the main thread, so with no lock. zigwin32 has no WinRT, so the few interfaces used are declared there, as far as the slots called; the IID of the generic click handler is derived at compile time, as WinRT derives it, from its signature. A click comes on a thread of Windows' own, is queued under a lock, and wakes the main loop.
  • windows/JumpList: the jump list, replaced whole in one ICustomDestinationList BeginList … CommitList: a category of IShellLinkW shortcuts for the panes, titled through their property store, and the profiles as tasks. What the items say and run is jump_list, apart from it, so that it is tested on Linux too.
  • links: where a URL in a line of text begins and ends, around the cell clicked. A scheme a desktop opens starts it; the first space, control character or character a URL cannot hold ends it; and the punctuation of the sentence around it, or a closing bracket it did not open, is left off its end. The window gives it the line, soft wraps and all, and a map from its bytes back to the cells.
  • termio/Session: one terminal.
    • Its IO thread is the only reader and writer of the backend. It feeds what it reads through libghostty-vt's stream with the terminal's lock held.
    • Keystrokes, and the terminal's own replies to queries, go out through a locked outbox, so the main thread never blocks on a slow port.
    • A resize is made on the IO thread too. Reflowing a long scrollback to a new width takes tens of milliseconds, so the main thread only says what size is wanted, and the IO thread makes the latest when it gets to it: a divider dragged across forty columns reflows a few times, not forty. Meanwhile the window draws the pane as it last was, cut to its new size, rather than wait for the terminal's lock. Nothing else in the main loop waits for it either: a title or progress report that arrives mid-reflow is taken on a later turn, and the panes published to GNOME Shell's search keep a busy pane's last known directory. A reflow of a large scrollback takes hundreds of milliseconds, and a wait for it would stop the whole window — a divider dragged across would follow in jumps.
  • zig-aerie: the window system, the renderer and the fonts, in a library of their own. harrier uses them as any program would.
    • platform is the window system, chosen at compile time — Wayland, or Win32 on Windows — speaking the portable types of its Event.zig. harrier names its window menu's items when it starts it, and a tab dragged out is the platform's local drag, offered under a type made from harrier's application ID that only its own windows take. On Windows, wait is MsgWaitForMultipleObjectsEx on the thread's queue and the handles the app names. Such a wait takes at most 64 handles, so every session signals one event the app shares between them, rather than one of its own; woken, the windows ask each pane for its events, as they do every turn anyway. While a window's frame is dragged, Windows runs a loop of its own inside the dispatch, so the platform runs a turn of harrier's from there (setModalTurn) — on each new size and on a timer — and the window is drawn at the size it is being dragged to.
    • gpu draws a frame of instanced quads in one call, plus one for each image, which is a texture of its own, sampled with filtering as it is scaled; textures not drawn for a while, or past a budget, are let go. Its Target says where the frame goes: dma-bufs or shared memory on Wayland, a swapchain on VK_KHR_win32_surface on Windows.
    • text is the fonts, scanned once (Fonts), a family in four styled fallback lists (Family), and the glyph cache and its atlases (Glyphs, Atlas).
    • decor is the title bar and frame; desktop the portal's settings.
  • renderer/:
    • frame.Builder turns libghostty-vt's RenderState into zig-aerie's rectangles.
    • images reads where a terminal's kitty images are, with it locked, into pixel positions, and copies out only the pixels of those the renderer does not hold yet: an image is uploaded once, and again only when it is sent again or its animation moves on. An image is named to the renderer by its pane, and its screen and number there.
  • Window, Tab and Pane: a window is the platform window, the renderer and its tabs; a tab is a tree of splits with panes at its leaves, and which pane has the focus; a pane is a session and what the window keeps about it — what was last drawn of it and its rows shaped, its selection, its title, where it is laid out. The tree is only geometry: where each pane goes, which one a point or a direction finds, which divider the pointer is on. Only the shown tab's panes are drawn, but every pane's session is polled, so a hidden program's output is read and its exit noticed, and every pane is resized with the window.
  • decor/: the decorations — everything harrier draws in a window besides the terminal. decor/decor has what they all draw with: colors mixed from a pane's, and titles cut to fit.
  • decor/TitleBar: harrier's own title bar and frame, which is zig-aerie's: the insets around the grid, the hit-test that turns a press into a move or resize request, and the title and subtitle, drawn into the same frame as the grid. harrier keeps room in it for its two buttons, the profile selector's at the left and the status button left of the window's own, and draws those. The title is in the desktop's interface font, a family App.titleFamily makes the first time a window draws its own bar; the subtitle is that family at 0.8 of its size, and a location too long for it is cut at its start, keeping its host. Where the window system draws the frame around it, as Windows does, it is the bar alone.
  • location: a pane's location, from the URL in an OSC 7 report or a directory /proc names, and how it is written.
  • StatusWindow: the status window, a toplevel of its own with a renderer of its own, made a child of a terminal window with xdg_toplevel.set_parent, and handed to another when that one closes. It also works out what decor/StatusButton shows: the count of reports and the most pressing of them, which the title bar, the tab bar or the badge carries.
  • Search: a pane's search: what has been typed, and libghostty-vt's searcher for it. The window feeds it with the terminal locked, a screen and a stretch of scrollback at a time, and it works through that with the lock let go, a turn of the loop at a time; matches in view become highlights in the render state, which the frame colors.
  • l10n: harrier's text through zig-fluent. It holds one bundle per translation in locales/, embedded by a module build.zig generates, chooses one at startup, and falls back to English for a missing message. Text with no arguments is formatted once and kept, since some of it is drawn every frame.
  • decor/TabBar: the strip of tabs, drawn by harrier under whoever's title bar, likewise as insets, a hit-test and quads, and the arithmetic of dragging along it and dropping onto it. A tab dragged off it goes through the platform's drag and drop (wl_data_device), offered as a type only harrier's own windows take; its picture is drawn by zig-aerie's gpu.cpu, which draws the renderer's instances on the CPU into shared memory. On Windows every window a tab can go to is this process's, so the drag needs no OLE: the window it came from keeps the mouse captured, each move finds the window under the pointer, and the release drops it there; its picture follows the pointer in a layered window the pointer goes through.
  • Dnd: kitty's drag and drop protocol, connected to the window system. libghostty-vt keeps the protocol's state; the session copies what the program asks for onto a queue for the main thread, and Dnd answers it through the platform's drop target and drag source. A drop is accepted only once the program says it would take it, and its data is read down non-blocking pipes the loop waits on, a chunk a turn, so a drop from one pane to another is harrier at both ends of one pipe. For a program on another machine, it reads the files a drop names, a component at a time without following links, and writes out the files that program drags, likewise. On Windows, OLE gives a drop's data only while it is being dropped, so the platform takes all of it then, and Dnd reads it from memory a chunk a turn, the loop not waiting while there is more. A drag out is OLE's DoDragDrop, which keeps the thread until the drop: it starts from a message posted to the window, so not in the middle of the app's handling of the program, and a drop target asking for data the program has not sent turns the app's loop from inside it until the program has, as a window's frame being dragged does. The files of a drag from another machine are written with std.Io there, each part of a path made a Windows name first, so that with no link ever made a path cannot lead out of the drag's directory.
  • font/: what a terminal's cells want of zig-aerie's text.
    • The grid is a zig-aerie Family: four styled fallback lists, and the cell, which is the family's line. Resolving a list sorts every font fontconfig knows, tens of milliseconds each with thousands installed; only the regular one is needed to open a window, so the other three are resolved on a thread meanwhile and waited for the first time bold or italic text wants a face. The scan and the regular list run on a thread of their own at startup, beside connecting to the compositor and setting up the GPU, and again when the configuration names another family, so that a font installed since is found.
    • shape: one row at a time, with each cell's index as its cluster.
    • cell_glyphs: a glyph out of zig-aerie's cache, fitted to the cells it spans, or one of harrier's own, drawn the first time it is asked for and kept in the same atlas.
    • sprite/: the glyphs harrier draws itself. draw/ and canvas.zig are Ghostty's src/font/sprite, drawing with z2d; Sprite.zig collects the draw<CP> and draw<MIN>_<MAX> functions into a table of ranges at compile time, as Ghostty's Face.zig does. The shaper hands such a cell over without shaping it.

Testing #

Startup time #

HARRIER_STARTUP_TIMES=1 harrier prints how long each phase of starting took, measured from when the process began, on standard error:

$ HARRIER_STARTUP_TIMES=1 harrier --standalone
startup:     4.1 ms  (+   0.2)  configuration
startup:     6.7 ms  (+   2.6)  window system
startup:    47.6 ms  (+  41.0)  vulkan device
startup:   199.1 ms  (+ 151.5)  fonts: scan
startup:   257.3 ms  (+  58.2)  fonts: the regular style resolved
startup:   274.7 ms  (+   7.1)  first frame drawn

The fonts are most of it. Their scan reads zig-font-config's cache, one file per font directory in ~/.cache/zig-font-config. A harrier built with different font-scanning code does not trust another's cache, so the first start after such an update, or after running a harrier of another version, scans every font again: seconds, with thousands installed.

Tests #

zig build test covers:

  • the pty and serial backends, against real pseudo-terminals;
  • the title bar's room for the profile selector's button and the status button, only when there is one (its frame, insets and what is under the pointer are zig-aerie's, and tested there);
  • the status button: its width as the count grows, and which state is the most pressing;
  • resizing in whole cells: an offered size rounded down to the padding plus whole cells, at whole and fractional scales, without losing a cell to rounding;
  • searching: matches found through the scrollback, newest first, and stepped through both ways with wrapping and scrolling; an empty query finding nothing; backspace taking off a whole character; a paste made one line;
  • translations: every catalog parses cleanly, every action has a name, messages take their arguments, a missing message is its name, and the closest translation is chosen;
  • a session end to end: a program's output, a device-attributes query answered back through the pty, titles, OSC 52, progress reports and their end, status reports kept as records with what to tell the user, keystrokes, resizes;
  • program status records: a report replacing its record and telling of a change of state but not of more progress, children taking their program from their parents and cleared with them, a prompt ending what was going on, the order a tree shows them in and the lines that draw it, and the progress bar following the most recent report;
  • a global hotkey's keysym and the trigger the portal is given for it, and global bindings kept apart from the window's own;
  • configuration and key bindings, and validate-config's checks; the desktop's settings as the configuration sees them;
  • the scrollbar: when it shows, the thumb's size and place, and the row a dragged thumb stands for;
  • the split tree: where panes go, which a point or a direction finds, closing one, dragging and resizing dividers, equalizing and zooming, and a pane moved beside another, on the side its drop was nearest;
  • the tab bar: when it shows, what is under the pointer as tabs multiply and narrow, and where a dragged tab goes as it passes its neighbours, and the status button making room for itself;
  • the atlas, and shaping and rasterizing with whatever fonts the machine has;
  • the built-in glyphs: every code point in every range draws, and lines and blocks land on the cell's edges;
  • emoji presentation: Unicode's default for a character, and a variation selector overriding it;
  • finding links: a URL from anywhere in it, the punctuation and brackets around it left off, two in a row told apart, and which file: URIs name files on this machine;
  • drag and drop: a directory's entries as the protocol lists them, a drag image's pixels, the list of a drag from another machine rewritten to name the copies, paths that would lead out of the copies' directory refused, and a dropped list of files as the quoted paths it is typed as;
  • images: one sent to a terminal coming out placed, with its pixels to upload; a PNG decoded; an image sent again under its number placed where it was sent last; an animation moving on to its next frame when it says it will, with new pixels; pixels premultiplied for filtering; placements cut to the grid with their source cut to match; and the three layers by z-index.

The Wayland and Vulkan code is compiled by the tests, run in the VM test, and exercised by running it. It has been run under a headless sway: typing, Compose, dead keys, CJK fallback, 256-color and SGR rendering, dma-buf presentation with explicit sync, a serial port through a socat pty pair, kitty images (kitten icat's, and others sent every way the protocol allows, in each of its layers, and cut where they scroll off the top), harrier's own title bar and frame, both forced on sway and falling back on weston, tabs dragged along the bar by a virtual pointer, panes dragged by their handles, the status button in the title bar, the tab bar and as a badge, and the status window it opens (floated by sway as a dialog, showing a clicked row's tab while staying open), a floating window asked for 803×611 settling at 798×597 (79×31 cells and its padding), a search through 2,000 lines of scrollback (Enter scrolling to the second-newest match, a common word's 1,993 matches all counted, the field clear of the status badge, and with the field closed ctrl+shift+g twice landing on the second-newest match), the profile selector (opened by a click on its ▾ in the title bar, shown beside the tab bar's + too, narrowed by typing, ranked, saying when nothing matches; a profile opened by a click on its row, one opened as a tab, and ssh to a host it could not resolve staying open, with status 255, to say so), and the built-in glyphs beside the font's own. Drawing straight into a dma-buf has been run on lavapipe (gpu = "llvmpipe"), whose images sway imports, including a resize, with the validation layer clean. The RX 480 it was developed on has no modifier support, so there it copies.

nix flake check runs the package's tests in the sandbox, builds harrier-terminfo (checking that both of the entry's names compile and read back), and runs these NixOS VM tests:

  • sway: a window on a headless sway, with text in it; then a translucent one, half black over a red background coming out half red; then a program run with TERM=harrier finding harrier's terminfo entry in harrier's own store path, through the TERMINFO harrier gives it; then, with Noto's monochrome and color emoji fonts both installed and fontconfig ranking the monochrome one first, 😀 drawn in color and 😀 followed by U+FE0E drawn without; then a 64×64 PNG sent with kitty's graphics protocol drawn pixel for pixel, and not at all with images turned off; then a profile opened from the profile selector by its key and a narrowing, in a window of its own with the profile's magenta background, its program given the profile's TERM=xterm-256color, and a split of it running the profile again; then a program started in a systemd scope of its own with the configured MemoryHigh and TasksMax, harrier not in it, and the scope gone with the program; then the subtitle on harrier's own title bar, showing the directory /proc finds, then a directory under the home directory reported with OSC 7 and written ~/…, then a host and a path, with the terminal's rows the same throughout, and the title alone with it turned off; then the bell rung in a hidden tab, marking the window's title until the tab is shown, and rung in a window without the focus, which sway then shows as urgent;
  • activation: harrier installed by the NixOS module, its search provider linked where GNOME Shell reads it; two launches, one systemd user service started by the session bus, taking both windows; --tab, opening a tab in the last window rather than a window; GNOME Shell's search finding that tab, narrowing, its result's name and icon, and activating it to show it again, and a pane whose shell reports another machine found by that host's name, its location its description, and --focus-pane showing a pane, and not one named for another primary; a profile found by its name, described as one, started in a tab when picked, and then listed after its pane; then LaunchCommand: one window with a tab per command, named after the desktop entry, one held after its program exits and one saying its program does not exist, a bad desktop entry refused, and keys closing the held tabs and then the window; then progress on harrier's icon through Unity's launcher API, a tab at 40% and one with an error at 80% making 60% and urgent, asked with Query and heard as Update signals naming harrier's desktop file;
  • tabs: tabs dragged by a virtual pointer (tests/nixos/drag.py, since a headless sway has no pointer of its own): one torn off into a window of its own, one moved onto another window's bar, and the window it left closing with its last tab; then splits, a pane opened right and below and the focused one closed, counted by the programs they run;
  • splits: three panes, each filling itself with a color of its own, rearranged by dragging their handles with the virtual pointer — one dropped at another's left edge, one at another's bottom — with a screenshot showing which color is where after each, and a press away from any handle moving nothing;
  • hotkeys: Hyprland, logged in on the console, binding global hotkeys with vicinae-hotkey-v1: ctrl+alt+t bound, and pressed on the machine's keyboard opening a window; a bare x refused, with Hyprland's reason in the log;
  • shortcuts: KDE Plasma on Wayland, whose compositor has no hotkey protocol, so that the binding goes through the portal's GlobalShortcuts: the portal's dialog asking whether to add it, answered, the trigger the desktop reports, and the shortcut pressed opening a window;
  • dnd: two windows side by side, kitten dnd offering a drag in one and taking drops in the other, and the virtual pointer dragging between them: text, then a directory tree with a symbolic link in it. Then the same tree to a kitten that, by bubblewrap and a different /etc/machine-id, is on another machine, so harrier sends it the files; and from such a kitten, so harrier writes its files out and names those. Last, files and then text dropped on a program that takes no drops, typed in as quoted paths and as text;
  • portal: a real XDG desktop portal, with its GTK backend reading GSettings: the configuration chooses the background by ctx.appearance.color_scheme, and gsettings changing the color scheme while harrier runs changes the window's background. Then notifications, through the same portal to dunst: one from a tab behind another, titled with that tab's title and clicked, shows the tab, through harrier's bus name; and one from a second, --standalone harrier, clicked, shows its tab, coming back through the portal to it and not to the first. Last, links: ctrl+click on a URL in the text, and on a hyperlink a program made to a file, each opened in the application the desktop opens such things with — here one that writes down what it was given — the URL without the word after it, the file as itself, and a click without ctrl opening nothing. Then program status: a program reporting working, more progress, waiting for permission and done, which dunst shows as three notifications, titled with the program and saying each;
  • home-manager: the Home Manager module alone: the configuration written where harrier reads it, the service found through what the module linked, and a window drawn in the configured background.

config-rejected checks that a configuration with mistakes in it fails the build that wants it, naming each one — one of them made only under the dark color scheme, and said to be.

Not yet #

  • Not drawn yet:
    • a confirmation for pastes that contain a newline (they go through as xterm sends them).
  • Platforms: the BSDs and macOS have no serial backend in zig-serial, and harrier has no macOS platform.
  • On Windows: symbolic links a program on another machine drags. See Windows.

Where this lives #

The canonical repository is on my Forgejo instance, and Tangled and Radicle carry copies of it:

git clone https://git.jcollie.dev/jeff/harrier.git

CI runs on the Forgejo repository.

Report issues and send pull requests on the Tangled mirror, https://tangled.org/jcollie.dev/harrier. CONTRIBUTING.md says what a change needs before it goes in.

License #

MIT; see LICENSES/MIT.txt. The project follows the REUSE specification.

Everything in src/font/sprite/ but Sprite.zig and font.zig are ported from Ghostty, © 2024 Mitchell Hashimoto and Ghostty contributors, also MIT. So is how src/renderer/images.zig works out where an image's placements go, and the terminfo entry in dist/terminfo/harrier.terminfo.

The icon, in dist/icons, is cropped from Becky Matsubara's photograph Circus hudsonius, male perched, Berkeley, California (2018), licensed CC BY 2.0. It was scaled and converted to PNG.

What harrier is built with, and those packages' own notices, is installed beside it as share/licenses/harrier/THIRD-PARTY-NOTICES.txt, and on Windows in the install's LICENSES folder. The build writes it (tools/notices.zig) from the packages harrier's own code and C sources come from, for the target built, so it lists what that build has in it; a package with no notice it can find stops the build. Ghostty's vendored simdutf, Zig's runtime and MinGW-w64's, whose notices are not in a package, are in dist/notices.

References cited #