<<<

issy

Source lives at github.com/davidemerson/issy.

issy is a text editor that looks like a printed page, not a terminal application. It’s written in Zig with zero external dependencies, runs on Linux, macOS, and OpenBSD, and ships as a single binary. No runtime, no package tree. Current release is v1.2.3.

why

I spend a lot of my day staring at text, and wanted something closer to the feeling of reading a well-typeset book: restrained contrast, gentle luminance shifts, structure you parse with your eyes rather than with your retina.

I also wanted a real print path. I want the PDF to look typeset, not like someone screenshot a terminal and hit print.

two themes

Pick the one that matches your environment. In both options, only a couple of token types get real chromatic contrast so the eye parses structure through gentle luminance shifts instead of a rainbow.

default dark theme with restrained syntax highlighting
the default theme. black background, violet keywords, soft green strings, dim comments.

paper theme based on solarized light
the paper theme. Solarized Light, warm cream background, violet keywords, cyan strings. Designed for readability in bright environments.

Neither is take-it-or-leave-it: every color in either theme can be overridden per-key in ~/.issyrc, and since config reloads live, you can tune a color and watch it apply on the next frame.

Ctrl+P (or --print on the command line) renders the current buffer to a real PDF 1.4 file with TTF/OTF font embedding, per-token syntax colors from a separate ink-on-paper print theme, automatic page breaks, long-line wrapping, and a filename and page-number header on each page when the top margin has room for one. Font size and all four margins are config keys, line numbers carry over, and tokenizer state survives page breaks, so a multi-line construct that resumes on the next page keeps its colors. Text you copy out of the PDF comes out as real characters, because the writer emits a proper ToUnicode CMap rather than an identity map. No external dependencies, no temporary PostScript; the PDF writer is hand-rolled in Zig.

printed pdf export of editor.zig in berkeley mono

Headless from the command line:

issy --font "Berkeley Mono.ttf" --print output.pdf source.py

The print theme is tuned for ink on white paper and never inherits the TUI theme. Recommended fonts: Berkeley Mono, Iosevka, JetBrains Mono, Commit Mono.

multiple cursors

Ctrl+D selects the word under the cursor and adds a cursor at the next occurrence. Press it again to keep adding. Every subsequent edit, whether typing, backspace, delete, or paste, applies to all cursors simultaneously, and Ctrl+Z undoes the whole multi-cursor tick as one step.

multi-cursor rename demo

Ctrl+F enters search mode; each keystroke re-runs the search from the position where you started and jumps the cursor to the first match. While the prompt is open, every visible match is highlighted, the match at the cursor is underlined, and a dim 3/17-style counter sits after the pattern. Search is smart-case: all-lowercase patterns match case-insensitively, any uppercase letter makes the match exact. Tab toggles whole-word matching (shown as [w] in the prompt). Down or Ctrl+G walks to the next match, Up walks to the previous one, Enter confirms, and Escape cancels and returns the cursor to where it started.

incremental search demo

keyboard and mouse selection

Shift with an arrow key extends the selection one character at a time; Ctrl+Shift+Left and Ctrl+Shift+Right grow it a word at a time. Click places the cursor, double-click selects the word under it, triple-click selects the line, and shift-click extends from the existing anchor. Drag past the viewport edge and the view autoscrolls.

word-wise keyboard selection demo

Copy and cut push to the OS clipboard via OSC 52, so yanking out of an SSH session lands in the host clipboard without extra plumbing (tmux wants set -g set-clipboard on first). Copies over 100KB stay internal-only with a status notice, because pasting a silently truncated clipboard somewhere else would be data corruption. Pastes from the terminal arrive as bracketed paste (DECSET 2004): during a paste, auto-indent is suppressed and tabs land as literal \t, so already-indented content comes in verbatim instead of compounding.

path completion

Ctrl+O opens the file prompt seeded with the current directory. Type a partial directory or filename and press Tab to auto-complete against what’s on disk.

path completion in the open-file prompt

swap files

While a buffer has unsaved changes, issy writes them to a sibling .<name>.swp file every couple of seconds, and removes it on save, reload, file switch, or clean exit (SIGTERM and SIGHUP shut down gracefully and clean up too). If issy (or your SSH session, or your laptop battery) dies with edits in flight, the next open of that file points you at the swap in the status bar. It never auto-loads the swap; it just tells you it’s there. On by default, swap_files = false in ~/.issyrc turns it off.

Related: if the open file changes on disk underneath you (a git pull, another editor), the status bar says so within a second, even while issy sits idle. Ctrl+R reloads, with a confirm prompt if your buffer is dirty.

per-file cursor memory

Quit a file and issy remembers the cursor position at ~/.cache/issy/positions.txt; reopening the same file restores the caret automatically. Positions are keyed by absolute path, capped at 300 entries with a newest-on-top LRU layout, and degrade gracefully (corrupt file or missing HOME is a silent no-op). An explicit file:line on the command line always wins over the saved position.

quiet defaults

Some behaviors are on by default because I always want them. Long lines soft-wrap at the right margin; the buffer itself is never modified. Opening a file scans the first 100 lines and, if more than 60% agree on tabs or spaces, overrides the configured indent style for that file. Trailing whitespace and indentation that doesn’t match the detected file style get a faint highlight, quiet enough to ignore until you care. Matching ()[]{} pairs highlight as the cursor moves.

Save is atomic: issy writes a temp sibling, fsyncs, and renames it into place, following symlinks to rewrite the target and preserving permission bits. A crash mid-save can’t leave you half a file.

opt-in editing aids

Two behaviors I like but didn’t want to force on anyone, both off by default:

auto_close_brackets = true makes typing an opener insert the matching pair with the cursor between them, as one undo step. Typing a closer that already sits at the cursor steps over it, backspacing an empty pair deletes both, and typing an opener over a selection wraps it. Quote pairing is suppressed next to word characters, so apostrophes in prose stay literal.

wrap_indent = true makes soft-wrap continuation rows hang under the wrapped line’s own leading whitespace instead of a flat 2 columns. The editor, the renderer, and the PDF writer all share the same indent math, so print output wraps the same way the screen does.

install

one-line curl (linux, openbsd)

curl -sSL https://raw.githubusercontent.com/davidemerson/issy/main/install.sh | sh

Drops issy at ~/.local/bin/issy, verifies an Ed25519 signature over the release manifest against a public key baked into the script, verifies the binary’s SHA-256 against that signed manifest, seeds ~/.issyrc with commented defaults if you don’t already have one, and wires up the opt-in auto-update path. Prebuilt binaries cover Linux amd64/arm64 and OpenBSD amd64. On platforms without a prebuilt (or when you invoke the installer on macOS), it falls through to zig build -Doptimize=ReleaseSafe from source.

Flags: --prefix DIR to relocate, --version VER to pin to a specific release, --no-rc to skip the ~/.issyrc seed, --help for the rest. Prefer to read the script before piping it to a shell? Fetch it with -o install.sh and run it once you’re happy.

Prefer to skip the script entirely? The raw binaries (and the signed manifest to check them against) are attached to every GitHub release.

macos via homebrew

brew tap davidemerson/issy https://github.com/davidemerson/issy
brew install issy

The formula tracks tagged releases (vX.Y.Z), so upgrades are just brew upgrade issy, the same one-liner you’d use for any Homebrew package. No --fetch-HEAD, no uninstall-and-reinstall dance. CI rewrites the formula’s url and sha256 on every tag push via a release-tag job, so the versioned formula stays current without manual edits, and it stamps the release commit into the build so issy --version reports the real SHA and update notifications work.

Want the bleeding edge between releases? brew install --HEAD issy still builds from main, and brew upgrade --fetch-HEAD issy pulls the latest commit.

The formula pins its build dependency to zig@0.15 rather than the unversioned zig formula. Homebrew bumped zig to 0.16, which broke the build; zig@0.15 is pinned, carries Apple’s Xcode 26.4 TBD compatibility patch, and won’t move out from under us. The pin is kept in sync with the ZIG_VERSION used by CI.

Prebuilt macOS binaries aren’t shipped because cross-compiled Mach-O from Linux has no code signature and Apple Silicon refuses to run it. Homebrew, or the curl installer (which falls through to a source build on macOS), produces a native host-signed binary that runs on both Intel and Apple Silicon with no xattr or codesign workarounds.

openbsd

The curl installer downloads a prebuilt amd64 binary. Builds are verified on every push by a real OpenBSD 7.9 amd64 VM in CI (openbsd-test job); the full unit and integration suite must pass on OpenBSD before main accepts a merge. An editors/issy ports submission is in flight. Once it lands, pkg_add issy will be the preferred path.

Building from source: pkg_add zig then zig build -Doptimize=ReleaseSafe. bash and expect (also via pkg_add) are needed if you want to run the integration test suite.

build from source

Requires Zig 0.15.x (0.15.2 recommended). Zig 0.16 changed the std APIs issy depends on, so build.zig rejects it at compile time with an actionable error instead of a page of inscrutable ones.

git clone https://github.com/davidemerson/issy
cd issy
zig build -Doptimize=ReleaseSafe
install -m 0755 zig-out/bin/issy ~/.local/bin/issy

cross compile

Zig makes this trivial for Linux and macOS:

zig build -Dtarget=x86_64-linux-gnu
zig build -Dtarget=aarch64-linux-gnu
zig build -Dtarget=x86_64-macos
zig build -Dtarget=aarch64-macos

Or zig build cross to build all targets at once. The exception is OpenBSD: Zig doesn’t ship OpenBSD libc headers, and modern OpenBSD kills processes that make raw syscalls outside libc, so the OpenBSD release binary is built natively inside the CI VM instead of cross-compiled.

usage

issy [options] [file[:line]]

issy main.zig
issy src/editor.zig:42    # open at line 42
issy newdoc.md            # start a new file at that path
issy                      # empty buffer

Command-line options:

FlagDescription
--version, -vPrint version and exit
--help, -hPrint usage and exit
--config FILEUse a specific config file
--theme NAMEOverride theme (default, paper)
--font PATHTTF/OTF font for PDF output
--no-configSkip loading config file
--print FILEExport to PDF and exit (no TUI)
--rollbackSwap in the previous binary (if auto-update has run) and exit

keybindings

The ones you’d expect, mostly.

KeyAction
Ctrl+SSave (a buffer with no filename prompts for a path, with Tab completion)
Ctrl+Q / Ctrl+WQuit (on unsaved changes, Enter or Ctrl+Q again discards; Escape cancels)
Ctrl+Z / Ctrl+YUndo / redo (typing runs coalesce within 500ms, one step per word)
Ctrl+C / Ctrl+X / Ctrl+VCopy / cut / paste (copy and cut also push to the OS clipboard via OSC 52)
Ctrl+ASelect all
Ctrl+FIncremental search (Tab toggles whole-word, Up/Down walk matches)
Ctrl+GFind next match
Ctrl+HSearch and replace (Tab switches fields, Enter replaces next, Ctrl+A replaces all as one undo step)
Ctrl+OOpen file
Ctrl+NNew empty buffer
Ctrl+PExport to PDF
Ctrl+RReload file from disk
Ctrl+LGo to line
Ctrl+DAdd cursor at next occurrence of word
Ctrl+Left / Ctrl+RightJump by word
Shift+ArrowExtend selection by character
Ctrl+Shift+Left / RightExtend selection by word
Ctrl+/ or F1Show keybindings overlay

configuration

The installer seeds ~/.issyrc on first run with every setting commented out, so you can see what’s available and uncomment what you want. Unknown keys are ignored; missing keys fall back to compiled-in defaults. The editor watches ~/.issyrc for mtime changes and reloads live, so edits to config land in the running editor without a restart. The full reference is in CONFIGURATION.md in the repo. A minimal example:

tab_width = 4
expand_tabs = true
line_numbers = true
right_margin = 100
cursor_style = bar
font_file = "/path/to/font.ttf"

[theme.paper]

Newer keys worth knowing about: swap_files (on by default, see above), auto_close_brackets and wrap_indent (opt-in, see above), and autoupdate (below).

syntax highlighting

C, C++, Zig, Python, JavaScript, TypeScript, Rust, Go, Ruby, Java, Shell, HTML, CSS, JSON, YAML, TOML, Makefile, Dockerfile, Markdown, and TeX/LaTeX. Twenty in total, which covers just about everything I touch on a given day. Language is detected by file extension or well-known filename (Makefile, Dockerfile, Gemfile, shell dotfiles like .bashrc).

Multi-line constructs carry across lines: C block comments, Python triple-quoted strings, JS/TS template literals, and Zig \\ line strings all highlight correctly, even when their opening delimiter is scrolled off-screen. The renderer keeps a per-line syntax-state cache so this stays cheap.

auto update

Release builds check for newer versions on startup. The check is a one-shot HTTPS request to a commit.txt asset on the latest GitHub release, made by a detached grandchild process so the editor itself never blocks on the network. If the commit SHA on the release differs from the one the running binary was built from, the footer shows update available: <sha>. Dev builds skip the check entirely; only builds stamped as releases (a build from a clean tree, CI, or the Homebrew stable formula) participate.

By default the editor only notifies. Opt into automatic download and in-session apply with autoupdate = true in ~/.issyrc. One platform caveat: there are no prebuilt macOS binaries, so on macOS the worker has nothing to stage. You get the notify footer, and brew upgrade issy does the rest. Elsewhere, with auto-apply on:

  1. The background worker downloads sha256sums.txt and its Ed25519 signature, verifies the signature against the public key embedded in src/update_key.zig, and checks the manifest’s embedded commit binding and monotonic epoch. A replayed older release, authentic but stale, is rejected as a downgrade. Then it downloads the platform-specific binary and checks it against the signed manifest.
  2. The verified binary is staged under ~/.cache/issy/issy.staged and the footer switches to update staged: <sha>.
  3. Next time the buffer is clean and the editor has been idle for 60 seconds, it re-verifies the staged binary against the cached signed manifest, writes a small resume record, snapshots the current binary to ~/.cache/issy/issy.prev, atomically renames the staged binary over its own executable, tears down the terminal, and execve()s the new binary with --resume <path>. The terminal state survives execve, so the visible effect is a single re-render and the cursor lands back where it was.
  4. If anything goes wrong (non-writable binary, signature mismatch, dirty buffer, failed rename) the editor falls back to notify-only and keeps running the old version.

The signing private key is held as a GitHub Actions secret and only the repo’s CI workflow can sign releases. A tampered manifest or binary fails signature verification and staging aborts. The worker runs as the editor’s user, not root, and refuses to operate on root-owned install paths (so a root-owned /usr/bin/issy silently stays in notify-only mode).

To roll back after an apply: issy --rollback swaps the previous binary back in a one-shot atomic rename, after verifying the snapshot against the checksum recorded at apply time.

If you’d rather not think about any of this, notify_updates = false disables the check entirely.

hardening

Between v1.1.0 and v1.2.3, issy went through four adversarial audits: independent review agents combing the code for crashes, data loss, and hostile-input problems, with every finding fixed and pinned by a regression test. Some favorites from the pile:

The same passes bought a panic handler and SIGTERM/SIGHUP handlers that restore the terminal on every exit path, escape-sequence parsing that survives sequences split across reads (SSH), and CRLF files that normalize to LF in memory and round-trip byte-for-byte on save.

architecture and tests

Gap buffer with an O(1) line index for text storage, hand-rolled tokenizer per language, hand-rolled PDF writer, and a TUI layer that speaks raw terminal escapes. Rendering is O(visible rows) rather than O(rows times file size), byte-position to line/column mapping is a binary search, idle ticks skip repainting entirely, and a goto-line deep into a soft-wrapped file positions the viewport in O(visible rows) too (it used to be quadratic, which on a big file meant a freeze you could make coffee during). There’s an ARCHITECTURE.md in the repo that walks through the source code if you’re curious.

Tests run in two layers:

zig build test              # unit tests: gap buffer, unicode, tokenizer, editor ops, search, update verification
bash tests/run_tests.sh     # 15 integration suites via expect

There are 252 distinct unit tests; the runner reports over a thousand because each source file compiles as its own test unit and re-runs the modules it imports. The integration suite launches the real binary in a PTY, sends keystrokes, and verifies outcomes by checking saved file contents. It’s slower than pure unit tests but it catches the kind of bug where the editor thinks it saved a file but actually didn’t. The OpenBSD CI job runs the same suites inside a real OpenBSD 7.9 amd64 VM on every push, so OpenBSD isn’t a “should work” target. It’s a gate.

home | about | github | mastodon

XXIIVV webring

built
epoch
1783976164