max / shop
git clone https://makenot.work/git/max/shop.git
git clone git@ssh.makenot.work:max/shop.git
| Name | Size | |
|---|---|---|
| .cargo/ | ||
| crates/ | ||
| scripts/ | ||
| .gitignore | 474 B | |
| ATTRIBUTIONS.md | 702 B | |
| Cargo.lock | 58.4 KB | |
| Cargo.toml | 4.1 KB | |
| CONTRIBUTING.md | 1.3 KB | |
| deny.toml | 1003 B | |
| justfile | 840 B | |
| LICENSE | 1.0 KB | |
| README.md | 13.3 KB | |
| rust-toolchain.toml | 86 B | |
| todo.md | 106 B |
README
shop
Wayland-only, GPU-accelerated terminal emulator. Foot-inspired: small, focused, no built-in tabs/splits (the compositor or a multiplexer does that). Adds the three things foot refuses to do: GPU rendering, Kitty graphics protocol, ligatures.
Ships as the default terminal in Alloy. Standalone-usable on any Wayland compositor.
Status
Pre-v0.1. The binary opens a Wayland window, runs a shell on a PTY, and renders
its output through shop-vt, which is wired in, so escape sequences are
interpreted rather than printed. Working: the Kitty graphics protocol,
truecolor, cursor shapes via DECSCUSR, a palette resolved from a makeover
theme, mouse selection with clipboard and primary selection, key encoding
through shop-xkb, an I-beam pointer over the grid, and scrollback with the
wheel and Shift+Page Up / Shift+Page Down.
Absent: scrollback search, sixel (Kitty graphics covers the same ground), and hyperlinks.
-e PROGRAM [ARGS...] runs a program instead of the login shell, as every
terminal does. --exec 'CMD' is the shell-string form, for benchmarks.
Building
Three system libraries are linked at build time, so their development headers have to be installed. A Rust toolchain and these are the whole list.
Fedora:
dnf install rust cargo pkgconf wayland-devel libxkbcommon-devel fontconfig-devel
Debian, Ubuntu, Pop!_OS:
apt install pkg-config libwayland-dev libxkbcommon-dev libfontconfig-1-dev
Then cargo build --release, which leaves the binary at target/release/shop.
ldd on it is the check if this list ever looks wrong.
What each one is for, and how it fails if it is missing:
- libwayland-client.
wayland-clientis built with itssystemfeature, so it calls the C library instead of reimplementing the wire protocol. Itswayland-sysbuild script probeswayland-clientthrough pkg-config and panics if it is absent. - libxkbcommon. Keymap handling and key encoding, reached through
smithay-client-toolkit. The
xkbcommoncrate has no build script and no pkg-config probe; it declares#[link(name = "xkbcommon")]directly, so a missing dev package surfaces late, as a linker error about-lxkbcommonrather than as a build-script message. The runtime.so.0is not enough on its own: the link needs the.sosymlink the dev package carries. - libfontconfig. The system font query described under Fonts.
yeslogic-fontconfig-syspanics out of its build script with a pkg-config dump advising you to setPKG_CONFIG_PATH, which is the wrong instruction when the package is simply not installed.
Fontconfig is linked rather than dlopened, deliberately. The crate supports
RUST_FONTCONFIG_DLOPEN and shop does not set it: the font lookup runs on the
first character the bundled font lacks, which can be a long way into a session,
and a link error at build time is a better failure than a missing library found
when somebody prints CJK. Anyone repackaging shop who wants fontconfig soft can
set that variable and accept the runtime failure instead.
Layout
crates/shop/— the binary.crates/kitty-graphics/— the public library, package namekittygfx: a rendering-agnostic Kitty graphics protocol parser. Headed for crates.io, not published yet.crates/shop-{vt,grid,render,wayland,pty,xkb}/— present.shop-sixelis not written yet; each crate is added as its component is implemented. See design hub.
Config
~/.config/shop/config.toml, all keys optional:
# A theme id, without the .toml. Defaults to akari-night, which shop carries
# with it: makeover embeds its own theme set, so this works on a machine with
# no theme files installed anywhere.
theme = "akari-dawn"
# An extra directory to search first. An Alloy machine points this at the
# desktop's own set; shop does not otherwise know Alloy exists.
themes = "/usr/share/alloy/themes"
# Rows of scrollback to keep. Defaults to 10000. Costs lines x columns x 12
# bytes, so the default is about 24 MB per window at 200 columns. 0 turns
# scrollback off.
scrollback_lines = 10000
# Which chords shop keeps for itself. Shop sees a key before the program
# inside it does, so anything bound here never reaches the shell, or tmux.
# Every default can be moved, and `none` gives the key back entirely.
[keys]
scroll_page_up = "shift+page_up"
scroll_page_down = "shift+page_down"
copy = "ctrl+shift+c"
paste = "none" # shop stops taking it; tmux sees it again
emit_buffer = "ctrl+shift+e"
emit_screen = "ctrl+shift+p" # unbound by default
emit_selection = "none"
# Where an emitted region goes. Defaults to "new-window": a new shop window
# running $VISUAL, $EDITOR, or less on the capture.
[emit]
runner = ["hx", "{file}"]
# tmux: ["tmux", "display-popup", "-E", "hx {file}"]
# zellij: ["zellij", "action", "new-pane", "--", "hx", "{file}"]
Modifiers are ctrl, shift, alt and logo, in any order, with the key
last: ctrl+shift+y. A single character is that character; longer names cover
the keys worth taking from a program (page_up, home, end, insert,
delete, the arrows, tab, return, escape, space, backspace, f1
through f12). Modifiers match exactly, so shift+page_up does not fire while
alt is also down.
An entry shop cannot read leaves that action unbound rather than keeping the old chord: you meant to move it, and a key that stays consumed with nothing in the config explaining why is worse than one that does nothing.
Themes are makeover TOML files, which
declare colours by role rather than by hue. shop resolves the sixteen ANSI
slots from them through makeover::ansi_intent, the same mapping the bare
Linux console and Alloy’s generated configs use, so a program’s output does not
change colour depending on where it runs. 16-255 are fixed by the protocol and
are not the theme’s to move.
shop --theme ID overrides the file for one run.
Scrollback
The wheel scrolls three rows a notch; Shift+Page Up and Shift+Page Down move a
screen at a time, less a row of overlap. Typing snaps back to the bottom. Both
page chords are [keys] entries like any other, so they can be moved or given
up.
The alt screen has none, by definition: a program that takes the whole window repaints it, so a row leaving the top is overdraw rather than history. A wheel turn inside vim or htop does nothing.
A resize rewraps scrollback. Each history row records whether it ran off the right edge, so a run of those rows plus the one ending it is a logical line the rewrap can rebuild and re-split at the new width: narrowing a window and widening it again gives the original lines back. History stays a buffer of exactly-width rows, so nothing reading it needs to know this happened, and the viewport is carried across the rewrap by position within its line rather than by row number.
The live screen is clipped instead, and its wrap flags are dropped. It is whatever an application last painted and is about to be told the new size and repaint it, so there is nothing there worth rewrapping. One consequence: a history line that continued onto the live screen keeps its join only when it still fills the new width.
Character width
A character that occupies two columns is stored as two cells: one holding the character, one holding the column it covers. That is a protocol agreement rather than a drawing decision. A program printing a table of CJK text lays it out against the same width table and computes its cursor moves from it, so a grid that counted every character as one column would be a column out from the first wide character on the line and every absolute move after it would land somewhere else.
The second cell is not a character. Copy, emit and word boundaries skip it, so a word of CJK reaches the clipboard as itself and not as characters with spaces between them; a click on it means the character it belongs to. A wide character with one column left at the right edge moves to the next row whole rather than being split, and the column it could not use is left blank.
A combining mark takes no column at all. It is not placed anywhere: it amends the character already on screen and leaves the cursor where it was, which is what the program that printed it did too. Marks always follow their base, so nothing has to look ahead or segment a stream.
A cell keeps its base character inline and refers to its marks by a small id into a table the grid owns. Only the marks are interned, not whole clusters, and that is what keeps this cheap: real text draws on a handful of distinct mark sequences however much of it goes past, so the table stays small, a cell stays twelve bytes, and everything that only wants to know what a cell looks like never learns the table exists. Copying gets the whole cluster, so a path off a Mac-formatted volume pastes back as the path it came from.
Fonts
Shop bundles one font, Quasi Mono, so a fresh install has its box drawing and its marks without a font install first. That covers Latin, Greek, Cyrillic, combining marks, box drawing and block elements, and it has no CJK.
The face is not committed. It is cut at build time by
quasi-type, the house font pipeline:
a pinned base plus the house glyph set in, a Quasi <Slot> face out. shop-font
runs it from a build script and the base is cached in the target directory, so
the first build in a fresh checkout needs network and the rest do not. A font in
the repo would be a second source of truth that nothing rebuilds.
Braille and the legacy-computing sextants are not in it and come from fallback; nothing shop draws for itself emits either.
The face is variable, wght 200 to 800 in one file, and its own default is the
light end. Shop names the weight it draws at (shop_font::WEIGHT) rather than
taking the file’s, which is what a naive loader and fc-match both do.
For anything the bundled font does not have, shop asks the system. On a miss it puts the character to fontconfig, loads whatever comes back, and keeps it for every other character that font covers. Asking by character rather than by name is what makes it the system’s answer and not ours, and it means a machine configured to prefer a particular face gets that face here too. The lookup runs only on a miss, so ordinary output never reaches it, and a miss nothing can serve still draws the bundled font’s own empty box rather than nothing.
The baseline comes from the bundled font whatever a cell was drawn from. A fallback face’s own ascent would sit its glyphs on a different line and make a row of mixed scripts wander.
Ligatures are a different thing and shop does not do them. >= drawn as one
connected glyph is the font’s business, not the buffer’s: the two characters
still occupy two cells, and a ligature makes a cursor sitting between them land
visually inside a glyph. Column-accurate editing wins that trade.
Colour emoji are not drawn either, and that is a decision rather than a gap. Drawing them means a second atlas in colour, glyphs that carry their own colour instead of taking the cell’s, and bitmap strikes at a fixed size that is never the cell’s: a colour pipeline running the whole length of the renderer to put pictures in a terminal. An emoji still takes the columns it is owed, so text around one stays where it belongs and a line of somebody else’s output is read exactly as it was written. The cell is simply empty.
Emit
Shop has no scrollback search, no URL opener and no pager. It has one primitive that makes those somebody else’s program: address a region of the buffer and hand it over.
Ctrl+Shift+E writes the whole buffer, scrollback included, to a file and opens
it. emit_screen takes what is visible and emit_selection takes the
selection; both are actions like any other and ship unbound, because every
default binding is a key the program inside shop never sees.
What opens it is the [emit] runner. Shop is the bottom of the stack: it is
the thing that runs programs, so it cannot shell out without answering “and
where does the output appear?”, and that answer is yours. One key, and shop
carries no code for any particular multiplexer. Shop does not read $TMUX and
quietly behave differently; a window that opens somewhere surprising should be
explained by a line of config rather than a heuristic.
The region goes to a file and {file} is substituted into each argument, rather
than being piped: stdin does not survive tmux display-popup or a detached
window, so a pipe would work for exactly one runner. The file lands in
$XDG_RUNTIME_DIR and is not deleted, because an editor can outlive the
terminal that opened it.
It only goes one way. Kakoune’s | replaces the selection with the program’s
output; shop’s primitive is >. The scrollback is a transcript of something
that already happened, and the shell owns the PTY.
Non-goals
- Cross-platform. Wayland Linux only. No X11, no macOS, no Windows.
- Tabs, splits, panes. A window is a window. On Alloy that is sway’s job, and Alloy ships no multiplexer; elsewhere, bring your own if you want panes.
- Config DSL. TOML is enough.
- Scrollback search, URL opening, paging. See Emit: any program does these given the bytes, and shop’s job is to be the thing that has them.
License
MIT.
Design
Design hub and rationale live in the private wiki (~/Wiki/shop-overview.md).
Work backlog lives in GoingsOn (project shop), not in this repo.