| 1 |
# shop |
| 2 |
|
| 3 |
Wayland-only, GPU-accelerated terminal emulator. Foot-inspired: small, |
| 4 |
focused, no built-in tabs/splits (the compositor or a multiplexer does that). |
| 5 |
Adds the three things foot refuses to do: GPU rendering, Kitty graphics |
| 6 |
protocol, ligatures. |
| 7 |
|
| 8 |
Ships as the default terminal in [Alloy](https://makenot.work/git/max/alloy). |
| 9 |
Standalone-usable on any Wayland compositor. |
| 10 |
|
| 11 |
## Status |
| 12 |
|
| 13 |
Pre-v0.1. The binary opens a Wayland window, runs a shell on a PTY, and renders |
| 14 |
its output through `shop-vt`, which is wired in, so escape sequences are |
| 15 |
interpreted rather than printed. Working: the Kitty graphics protocol, |
| 16 |
truecolor, cursor shapes via DECSCUSR, a palette resolved from a makeover |
| 17 |
theme, mouse selection with clipboard and primary selection, key encoding |
| 18 |
through `shop-xkb`, an I-beam pointer over the grid, and scrollback with the |
| 19 |
wheel and Shift+Page Up / Shift+Page Down. |
| 20 |
|
| 21 |
Absent: scrollback search, sixel (Kitty graphics covers the same ground), and |
| 22 |
hyperlinks. |
| 23 |
|
| 24 |
`-e PROGRAM [ARGS...]` runs a program instead of the login shell, as every |
| 25 |
terminal does. `--exec 'CMD'` is the shell-string form, for benchmarks. |
| 26 |
|
| 27 |
## Building |
| 28 |
|
| 29 |
Three system libraries are linked at build time, so their development headers |
| 30 |
have to be installed. A Rust toolchain and these are the whole list. |
| 31 |
|
| 32 |
Fedora: |
| 33 |
|
| 34 |
``` |
| 35 |
dnf install rust cargo pkgconf wayland-devel libxkbcommon-devel fontconfig-devel |
| 36 |
``` |
| 37 |
|
| 38 |
Debian, Ubuntu, Pop!_OS: |
| 39 |
|
| 40 |
``` |
| 41 |
apt install pkg-config libwayland-dev libxkbcommon-dev libfontconfig-1-dev |
| 42 |
``` |
| 43 |
|
| 44 |
Then `cargo build --release`, which leaves the binary at `target/release/shop`. |
| 45 |
`ldd` on it is the check if this list ever looks wrong. |
| 46 |
|
| 47 |
What each one is for, and how it fails if it is missing: |
| 48 |
|
| 49 |
- **libwayland-client.** `wayland-client` is built with its `system` feature, so |
| 50 |
it calls the C library instead of reimplementing the wire protocol. Its |
| 51 |
`wayland-sys` build script probes `wayland-client` through pkg-config and |
| 52 |
panics if it is absent. |
| 53 |
- **libxkbcommon.** Keymap handling and key encoding, reached through |
| 54 |
smithay-client-toolkit. The `xkbcommon` crate has no build script and no |
| 55 |
pkg-config probe; it declares `#[link(name = "xkbcommon")]` directly, so a |
| 56 |
missing dev package surfaces late, as a linker error about `-lxkbcommon` |
| 57 |
rather than as a build-script message. The runtime `.so.0` is not enough on |
| 58 |
its own: the link needs the `.so` symlink the dev package carries. |
| 59 |
- **libfontconfig.** The system font query described under Fonts. |
| 60 |
`yeslogic-fontconfig-sys` panics out of its build script with a pkg-config |
| 61 |
dump advising you to set `PKG_CONFIG_PATH`, which is the wrong instruction |
| 62 |
when the package is simply not installed. |
| 63 |
|
| 64 |
Fontconfig is linked rather than dlopened, deliberately. The crate supports |
| 65 |
`RUST_FONTCONFIG_DLOPEN` and shop does not set it: the font lookup runs on the |
| 66 |
first character the bundled font lacks, which can be a long way into a session, |
| 67 |
and a link error at build time is a better failure than a missing library found |
| 68 |
when somebody prints CJK. Anyone repackaging shop who wants fontconfig soft can |
| 69 |
set that variable and accept the runtime failure instead. |
| 70 |
|
| 71 |
## Layout |
| 72 |
|
| 73 |
- `crates/shop/` — the binary. |
| 74 |
- `crates/kitty-graphics/` — the public library, package name `kittygfx`: |
| 75 |
a rendering-agnostic Kitty graphics protocol parser. Headed for crates.io, |
| 76 |
not published yet. |
| 77 |
- `crates/shop-{vt,grid,render,wayland,pty,xkb}/` — present. `shop-sixel` is |
| 78 |
not written yet; each crate is added as its component is implemented. See |
| 79 |
design hub. |
| 80 |
|
| 81 |
## Config |
| 82 |
|
| 83 |
`~/.config/shop/config.toml`, all keys optional: |
| 84 |
|
| 85 |
```toml |
| 86 |
# A theme id, without the .toml. Defaults to akari-night, which shop carries |
| 87 |
# with it: makeover embeds its own theme set, so this works on a machine with |
| 88 |
# no theme files installed anywhere. |
| 89 |
theme = "akari-dawn" |
| 90 |
|
| 91 |
# An extra directory to search first. An Alloy machine points this at the |
| 92 |
# desktop's own set; shop does not otherwise know Alloy exists. |
| 93 |
themes = "/usr/share/alloy/themes" |
| 94 |
|
| 95 |
# Rows of scrollback to keep. Defaults to 10000. Costs lines x columns x 12 |
| 96 |
# bytes, so the default is about 24 MB per window at 200 columns. 0 turns |
| 97 |
# scrollback off. |
| 98 |
scrollback_lines = 10000 |
| 99 |
|
| 100 |
# Which chords shop keeps for itself. Shop sees a key before the program |
| 101 |
# inside it does, so anything bound here never reaches the shell, or tmux. |
| 102 |
# Every default can be moved, and `none` gives the key back entirely. |
| 103 |
[keys] |
| 104 |
scroll_page_up = "shift+page_up" |
| 105 |
scroll_page_down = "shift+page_down" |
| 106 |
copy = "ctrl+shift+c" |
| 107 |
paste = "none" # shop stops taking it; tmux sees it again |
| 108 |
emit_buffer = "ctrl+shift+e" |
| 109 |
emit_screen = "ctrl+shift+p" # unbound by default |
| 110 |
emit_selection = "none" |
| 111 |
|
| 112 |
# Where an emitted region goes. Defaults to "new-window": a new shop window |
| 113 |
# running $VISUAL, $EDITOR, or less on the capture. |
| 114 |
[emit] |
| 115 |
runner = ["hx", "{file}"] |
| 116 |
# tmux: ["tmux", "display-popup", "-E", "hx {file}"] |
| 117 |
# zellij: ["zellij", "action", "new-pane", "--", "hx", "{file}"] |
| 118 |
``` |
| 119 |
|
| 120 |
Modifiers are `ctrl`, `shift`, `alt` and `logo`, in any order, with the key |
| 121 |
last: `ctrl+shift+y`. A single character is that character; longer names cover |
| 122 |
the keys worth taking from a program (`page_up`, `home`, `end`, `insert`, |
| 123 |
`delete`, the arrows, `tab`, `return`, `escape`, `space`, `backspace`, `f1` |
| 124 |
through `f12`). Modifiers match exactly, so `shift+page_up` does not fire while |
| 125 |
alt is also down. |
| 126 |
|
| 127 |
An entry shop cannot read leaves that action unbound rather than keeping the |
| 128 |
old chord: you meant to move it, and a key that stays consumed with nothing in |
| 129 |
the config explaining why is worse than one that does nothing. |
| 130 |
|
| 131 |
Themes are [makeover](https://makenot.work/git/max/makeover) TOML files, which |
| 132 |
declare colours by role rather than by hue. shop resolves the sixteen ANSI |
| 133 |
slots from them through `makeover::ansi_intent`, the same mapping the bare |
| 134 |
Linux console and Alloy's generated configs use, so a program's output does not |
| 135 |
change colour depending on where it runs. 16-255 are fixed by the protocol and |
| 136 |
are not the theme's to move. |
| 137 |
|
| 138 |
`shop --theme ID` overrides the file for one run. |
| 139 |
|
| 140 |
## Scrollback |
| 141 |
|
| 142 |
The wheel scrolls three rows a notch; Shift+Page Up and Shift+Page Down move a |
| 143 |
screen at a time, less a row of overlap. Typing snaps back to the bottom. Both |
| 144 |
page chords are `[keys]` entries like any other, so they can be moved or given |
| 145 |
up. |
| 146 |
|
| 147 |
The alt screen has none, by definition: a program that takes the whole window |
| 148 |
repaints it, so a row leaving the top is overdraw rather than history. A wheel |
| 149 |
turn inside vim or htop does nothing. |
| 150 |
|
| 151 |
A resize rewraps scrollback. Each history row records whether it ran off the |
| 152 |
right edge, so a run of those rows plus the one ending it is a logical line the |
| 153 |
rewrap can rebuild and re-split at the new width: narrowing a window and |
| 154 |
widening it again gives the original lines back. History stays a buffer of |
| 155 |
exactly-width rows, so nothing reading it needs to know this happened, and the |
| 156 |
viewport is carried across the rewrap by position within its line rather than |
| 157 |
by row number. |
| 158 |
|
| 159 |
The live screen is clipped instead, and its wrap flags are dropped. It is |
| 160 |
whatever an application last painted and is about to be told the new size and |
| 161 |
repaint it, so there is nothing there worth rewrapping. One consequence: a |
| 162 |
history line that continued onto the live screen keeps its join only when it |
| 163 |
still fills the new width. |
| 164 |
|
| 165 |
## Character width |
| 166 |
|
| 167 |
A character that occupies two columns is stored as two cells: one holding the |
| 168 |
character, one holding the column it covers. That is a protocol agreement |
| 169 |
rather than a drawing decision. A program printing a table of CJK text lays it |
| 170 |
out against the same width table and computes its cursor moves from it, so a |
| 171 |
grid that counted every character as one column would be a column out from the |
| 172 |
first wide character on the line and every absolute move after it would land |
| 173 |
somewhere else. |
| 174 |
|
| 175 |
The second cell is not a character. Copy, emit and word boundaries skip it, so |
| 176 |
a word of CJK reaches the clipboard as itself and not as characters with spaces |
| 177 |
between them; a click on it means the character it belongs to. A wide character |
| 178 |
with one column left at the right edge moves to the next row whole rather than |
| 179 |
being split, and the column it could not use is left blank. |
| 180 |
|
| 181 |
A combining mark takes no column at all. It is not placed anywhere: it amends |
| 182 |
the character already on screen and leaves the cursor where it was, which is |
| 183 |
what the program that printed it did too. Marks always follow their base, so |
| 184 |
nothing has to look ahead or segment a stream. |
| 185 |
|
| 186 |
A cell keeps its base character inline and refers to its marks by a small id |
| 187 |
into a table the grid owns. Only the marks are interned, not whole clusters, and |
| 188 |
that is what keeps this cheap: real text draws on a handful of distinct mark |
| 189 |
sequences however much of it goes past, so the table stays small, a cell stays |
| 190 |
twelve bytes, and everything that only wants to know what a cell looks like |
| 191 |
never learns the table exists. Copying gets the whole cluster, so a path off a |
| 192 |
Mac-formatted volume pastes back as the path it came from. |
| 193 |
|
| 194 |
## Fonts |
| 195 |
|
| 196 |
Shop bundles one font, Quasi Mono, so a fresh install has its box drawing and |
| 197 |
its marks without a font install first. That covers Latin, Greek, Cyrillic, |
| 198 |
combining marks, box drawing and block elements, and it has no CJK. |
| 199 |
|
| 200 |
The face is not committed. It is cut at build time by |
| 201 |
[quasi-type](https://makenot.work/git/max/quasi-type), the house font pipeline: |
| 202 |
a pinned base plus the house glyph set in, a `Quasi <Slot>` face out. `shop-font` |
| 203 |
runs it from a build script and the base is cached in the target directory, so |
| 204 |
the first build in a fresh checkout needs network and the rest do not. A font in |
| 205 |
the repo would be a second source of truth that nothing rebuilds. |
| 206 |
|
| 207 |
Braille and the legacy-computing sextants are not in it and come from fallback; |
| 208 |
nothing shop draws for itself emits either. |
| 209 |
|
| 210 |
The face is variable, `wght` 200 to 800 in one file, and **its own default is the |
| 211 |
light end**. Shop names the weight it draws at (`shop_font::WEIGHT`) rather than |
| 212 |
taking the file's, which is what a naive loader and `fc-match` both do. |
| 213 |
|
| 214 |
For anything the bundled font does not have, shop asks the system. On a miss it |
| 215 |
puts the character to fontconfig, loads whatever comes back, and keeps it for |
| 216 |
every other character that font covers. Asking by character rather than by name |
| 217 |
is what makes it the system's answer and not ours, and it means a machine |
| 218 |
configured to prefer a particular face gets that face here too. The lookup runs |
| 219 |
only on a miss, so ordinary output never reaches it, and a miss nothing can |
| 220 |
serve still draws the bundled font's own empty box rather than nothing. |
| 221 |
|
| 222 |
The baseline comes from the bundled font whatever a cell was drawn from. A |
| 223 |
fallback face's own ascent would sit its glyphs on a different line and make a |
| 224 |
row of mixed scripts wander. |
| 225 |
|
| 226 |
Ligatures are a different thing and shop does not do them. `>=` drawn as one |
| 227 |
connected glyph is the font's business, not the buffer's: the two characters |
| 228 |
still occupy two cells, and a ligature makes a cursor sitting between them land |
| 229 |
visually inside a glyph. Column-accurate editing wins that trade. |
| 230 |
|
| 231 |
Colour emoji are not drawn either, and that is a decision rather than a gap. |
| 232 |
Drawing them means a second atlas in colour, glyphs that carry their own colour |
| 233 |
instead of taking the cell's, and bitmap strikes at a fixed size that is never |
| 234 |
the cell's: a colour pipeline running the whole length of the renderer to put |
| 235 |
pictures in a terminal. An emoji still takes the columns it is owed, so text |
| 236 |
around one stays where it belongs and a line of somebody else's output is read |
| 237 |
exactly as it was written. The cell is simply empty. |
| 238 |
|
| 239 |
## Emit |
| 240 |
|
| 241 |
Shop has no scrollback search, no URL opener and no pager. It has one primitive |
| 242 |
that makes those somebody else's program: address a region of the buffer and |
| 243 |
hand it over. |
| 244 |
|
| 245 |
`Ctrl+Shift+E` writes the whole buffer, scrollback included, to a file and opens |
| 246 |
it. `emit_screen` takes what is visible and `emit_selection` takes the |
| 247 |
selection; both are actions like any other and ship unbound, because every |
| 248 |
default binding is a key the program inside shop never sees. |
| 249 |
|
| 250 |
What opens it is the `[emit] runner`. Shop is the bottom of the stack: it *is* |
| 251 |
the thing that runs programs, so it cannot shell out without answering "and |
| 252 |
where does the output appear?", and that answer is yours. One key, and shop |
| 253 |
carries no code for any particular multiplexer. Shop does not read `$TMUX` and |
| 254 |
quietly behave differently; a window that opens somewhere surprising should be |
| 255 |
explained by a line of config rather than a heuristic. |
| 256 |
|
| 257 |
The region goes to a file and `{file}` is substituted into each argument, rather |
| 258 |
than being piped: stdin does not survive `tmux display-popup` or a detached |
| 259 |
window, so a pipe would work for exactly one runner. The file lands in |
| 260 |
`$XDG_RUNTIME_DIR` and is not deleted, because an editor can outlive the |
| 261 |
terminal that opened it. |
| 262 |
|
| 263 |
It only goes one way. Kakoune's `|` replaces the selection with the program's |
| 264 |
output; shop's primitive is `>`. The scrollback is a transcript of something |
| 265 |
that already happened, and the shell owns the PTY. |
| 266 |
|
| 267 |
## Non-goals |
| 268 |
|
| 269 |
- Cross-platform. Wayland Linux only. No X11, no macOS, no Windows. |
| 270 |
- Tabs, splits, panes. A window is a window. On Alloy that is sway's job, and |
| 271 |
Alloy ships no multiplexer; elsewhere, bring your own if you want panes. |
| 272 |
- Config DSL. TOML is enough. |
| 273 |
- Scrollback search, URL opening, paging. See Emit: any program does these |
| 274 |
given the bytes, and shop's job is to be the thing that has them. |
| 275 |
|
| 276 |
## License |
| 277 |
|
| 278 |
MIT. |
| 279 |
|
| 280 |
## Design |
| 281 |
|
| 282 |
Design hub and rationale live in the private wiki (`~/Wiki/shop-overview.md`). |
| 283 |
Work backlog lives in GoingsOn (project `shop`), not in this repo. |
| 284 |
|