A tiny MS-Paint for your terminal. A full-screen canvas you draw on with the mouse — brush, flood-fill, eraser, a ten-swatch palette, undo/redo — every cell painted in 24-bit colour and kept until you clear it. No menus, no modes: pick a tool, pick a colour, drag.
tui-paint is a paint program that lives entirely in your terminal: a tool box pinned top-left, a colour palette bottom-left, a top-style hotkey legend across the bottom, and the rest of the screen is canvas. Drag to draw, scroll to resize the brush, and what you paint stays put — so you can build pixel art up one stroke at a time.
Where most yeet demos render data — process trees, thermal maps, packet feeds — tui-paint renders you. It's the pointer-and-paint demo: every pixel on screen is a reaction to a mouse event, routed through a painted widget tree.
Tip
The hard part isn't drawing — it's making the chrome and the canvas share one mouse. A click on a colour chip must pick a colour and not leak through to the canvas behind it; a scroll over the palette cycles colours, but the same scroll over the canvas resizes the brush. tui-paint solves this the way a browser does: each widget carries its own onMouseDown/onMouseMove/onWheel, every event is hit-tested against the painted tree and bubbled child→parent, and a handler calls stopPropagation to claim it. Same event, different target, no global mode flags.
curl -fsSL https://yeet.cx | sh
yeet run github:yeet-src/tui-paintManual install guide | Linux only
That's it — a full-screen canvas opens. tui-paint takes no flags; it's interactive. Everything is mouse + keyboard:
| input | action |
|---|---|
| drag | paint with the current tool |
| scroll on canvas | fan the brush wider or tighter, down to a single cell |
| scroll on palette | cycle through colours |
b f e |
brush · fill · eraser |
1–9 0 |
jump to a palette colour (ten swatches) |
u / r |
undo / redo |
c |
clear the canvas |
q / ^C |
quit |
The interesting machinery isn't the paint — it's how a single mouse event finds the right widget when a translucent palette floats over a full-screen canvas. There's no z-index bookkeeping and no "is the mouse over the toolbar?" checks scattered through the code. Three rules do all of it:
1. Hit-testing picks the target. Every event carries terminal (clientX, clientY). The runtime walks the painted tree and finds the deepest widget under that cell — so a click at the palette's location targets the chip there, while a click on bare canvas targets the canvas.
2. Events bubble child→parent. A widget with no handler for an event passes it up. A scroll over a colour chip has no onWheel of its own, so it bubbles to the palette behind it, which does — that's why "scroll a chip to cycle colours" works without every chip wiring up the wheel.
3. stopPropagation claims an event. A chip's onMouseDown selects its colour and then calls stopPropagation, so the click is claimed there — it never bubbles to the palette behind it or reaches the canvas underneath. The chrome shields the canvas the same way: the palette, footer, and panels each swallow their own clicks.
The cursor outline rides the same wiring. Free-hover tracking (?1003h) reports motion even with no button held, so a thin ring follows the cursor and previews the brush's footprint — inked light or dark to stay legible over whatever colour it sits on — before you commit a stroke.
tui-paint is a toy and a teaching piece, in that order:
- A reference for mouse-driven TUIs. It's the smallest complete example of per-node handlers, hit-testing, bubbling, and
stopPropagationworking together — copy the pattern into your own yeet tool. - Terminal doodling. Sketch pixel art over SSH, on a server, anywhere you have a terminal and no GUI.
- A reactive-rendering sample. A
Tensor-backed field projected through abgshader, withsignal/computeddriving every repaint — see How it works.
┌─────────────┐
│ ✎ b Brush │ tui-paint canvas
│ ❖ f Fill │ ← tool box (hover to expand)
│ ⌫ e Eraser │
└─────────────┘
· · · the rest is canvas · · ·
a thin ring tracks the cursor
┌─────────────────┐
│ ███ ███ ███ ███ │ ← palette (scroll to cycle)
│ ███ ███ ███ │ Brush size: 4.5 (Wheel adjusts)
└─────────────────┘
b/f/e tools 1–0 colours u/r undo·redo c clear q quit ← legend
The tool box shows just its icons until you hover it, then expands to name each tool and reveal a swatch of the current colour — the way MS-Paint keeps its active colour beside the tools. The palette is a band of seven vivid hues plus a black→grey→white ramp; each swatch is labelled with the digit that selects it (1–9, 0), inked to contrast its own colour, and the active one brackets its digit, e.g. [3]. The brush scales its footprint round even though terminal cells are about twice as tall as wide, and the fill bucket floods the contiguous region under the cursor four ways.
Painted cells hold full 24-bit colour; unpainted cells stay transparent, so your terminal's own background shows through. Nothing decays — the picture persists until you clear it.
The whole program is one file, index.jsx (~330 lines), built on yeet:tui.
The canvas is a Tensor([rows, cols, 2]) — an [on, swatch] pair per cell: whether it's painted, and with which palette index. A bg shader reads that field and returns a colour for every cell, so the canvas is a pure projection of the field; the terminal shows through wherever a cell is off.
The field itself isn't reactive (it's a raw typed array you mutate in place for speed), so a clock signal stands in for it: paint, fill, and clear bump the clock, the shader depends on it, and a repaint lands. Cursor motion and brush resize don't touch the clock — the shader also reads the hover, radius, and tool signals directly, so editing those re-mints it for free.
The tool box, palette, and legend are floating panels in a Layer (a z-stack), positioned with insets (top/bottom/left/right). Each is plain reactive markup: a computed per tool button and colour chip re-renders only itself when the active tool or colour changes. There's no mouse-leave event in a terminal, so the toolbar collapses on any move the Layer sees that isn't claimed by the toolbar's own handler — bubbling, again.
State is one flat typed array, so history is cheap: snapshot the whole field (slice) at the start of each gesture — a drag, a fill click, or a clear — and restore it (set) on undo. A drag is one snapshot, not one per stamp, so a single u rewinds an entire stroke. The stack is capped at 50 deep; a fresh edit forks history and drops any pending redo.
tui-paint projects a separate field through a bg shader rather than drawing into a CellBuffer (yeet's imperative raster surface). The shader is recomputed per cell on each repaint, which is trivially cheap at terminal sizes and keeps the transient cursor outline a free composite on top of the persistent paint — no need to erase the cursor's last position each frame. The trade is the small clock dance above. A CellBuffer would be the more idiomatic choice for a larger canvas; for this one, the shader wins on simplicity.
A new tool is one function plus one row. The field is the single source of truth, apply routes a canvas gesture to the active tool, and TOOLS drives both the keymap and the tool box:
// 1. a function that mutates the field (here: paint a full row, "ruler" style)
const ruler = (cx, cy) => {
const d = canvas.data, s = colour.get();
for (let x = 0; x < cols; x++) {
const b = idx(x, cy);
d[b + ON] = 1; d[b + SWATCH] = s;
}
tick();
};
// 2. a row in TOOLS — its `key` joins the keymap, its icon joins the tool box
const TOOLS = [
{ id: "brush", key: "b", icon: "✎", name: "Brush " },
{ id: "fill", key: "f", icon: "❖", name: "Fill " },
{ id: "eraser", key: "e", icon: "⌫", name: "Eraser" },
{ id: "ruler", key: "l", icon: "─", name: "Ruler " },
];
// 3. route it in `apply`
const apply = (x, y) =>
({ fill, ruler }[tool.get()] ?? paint)(x, y);The palette grows the same way — add a hue to HUES or a value to NEUTRALS and the chips, the keymap, and the contrast maths all follow.
Important
The yeet runtime, which drives the terminal render loop. curl -fsSL https://yeet.cx | sh installs it; it's Linux-only (on macOS, run it inside a Linux VM).
A terminal that supports 24-bit ("truecolor") output and SGR mouse reporting — any modern terminal (kitty, WezTerm, Alacritty, foot, recent xterm, the GNOME/KDE terminals) qualifies. Works over SSH if your terminal forwards mouse events.
Note
tui-paint is a demo, not a shippable editor. It draws beautifully; it does not save, load, or export.
- No save / load / export. The picture lives in memory and is gone when you quit. The field is one typed array, so dumping it to a file (or reading it back) is a small addition — but it isn't built in.
- The canvas is the terminal size at launch. It samples
tty.size()once on start and doesn't reflow if you resize the window mid-session; restart to get a canvas matching the new size. - Ten colours, one layer, no zoom. A fixed palette, no layers, no selection, no fill tolerance — it's MS-Paint's first afternoon, not Photoshop.
Can I save my drawing?
Not today — it's in-memory only. The canvas is a single Tensor (a flat typed array), so persisting it is straightforward: write canvas.data to a file on quit and set() it back on load. It just isn't wired up.
My colours look wrong / everything's one shade.
tui-paint emits 24-bit colour. A terminal that quantizes to 256 or 16 colours will approximate the palette; switch to a truecolor terminal for the intended look.
Does the mouse work over SSH / tmux?
Yes, as long as your terminal forwards mouse events and (for tmux) mouse mode is on. tui-paint asks for any-event tracking so it can follow the bare cursor, not just drags.
Why MS-Paint and not a "real" editor? Because the point is the pointer wiring, not the feature set. A bigger tool would bury the one idea worth showing — per-node mouse handlers, hit-tested and bubbled — under file dialogs and layer stacks.
Can I make the canvas resize with the window?
Not as written; it sizes once at launch. Re-running tty.size() on a resize event and reallocating the field would do it — a reasonable exercise if you want to extend it.
MIT.
Built with yeet, a JS runtime for writing terminal and eBPF programs on Linux. Join us on discord.
