Skip to main content

max / shop

13.3 KB · 284 lines History Blame Raw
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