# 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](https://makenot.work/git/max/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-client` is built with its `system` feature, so it calls the C library instead of reimplementing the wire protocol. Its `wayland-sys` build script probes `wayland-client` through pkg-config and panics if it is absent. - **libxkbcommon.** Keymap handling and key encoding, reached through smithay-client-toolkit. The `xkbcommon` crate 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 `-lxkbcommon` rather than as a build-script message. The runtime `.so.0` is not enough on its own: the link needs the `.so` symlink the dev package carries. - **libfontconfig.** The system font query described under Fonts. `yeslogic-fontconfig-sys` panics out of its build script with a pkg-config dump advising you to set `PKG_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 name `kittygfx`: a rendering-agnostic Kitty graphics protocol parser. Headed for crates.io, not published yet. - `crates/shop-{vt,grid,render,wayland,pty,xkb}/` — present. `shop-sixel` is not written yet; each crate is added as its component is implemented. See design hub. ## Config `~/.config/shop/config.toml`, all keys optional: ```toml # 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](https://makenot.work/git/max/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](https://makenot.work/git/max/quasi-type), the house font pipeline: a pinned base plus the house glyph set in, a `Quasi ` 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.