Minimal Python Charm stack — raw terminal TUI framework
Project description
snaptui
A zero-dependency Python TUI framework that ports the Charm stack's approach to terminal UIs: the Elm Architecture, raw terminal I/O, and ANSI escape sequences.
Why this exists
Python's TUI options are either curses-based (complex, stateful, platform-dependent) or heavy frameworks like Textual (hundreds of dependencies, CSS layout engine, async runtime). The Go ecosystem solved this with the Charm stack: a set of composable libraries that render TUIs by writing ANSI escape sequences directly to stdout. This makes rendering fast and the architecture simple -- your entire UI is a function that returns a string.
snaptui brings that approach to Python:
- No curses. Reads raw bytes from stdin, writes ANSI sequences to stdout.
- No dependencies. Uses only the Python standard library (termios, select, signal, fcntl).
- Elm Architecture. Your app is
init(),update(msg),view()-- nothing else. - Fast rendering. A line-diff renderer only redraws what changed, just like Bubble Tea.
How the Elm Architecture works
from snaptui import Model, Cmd, KeyMsg, WindowSizeMsg, quit_cmd
class Counter:
def __init__(self):
self.count = 0
def init(self) -> Cmd:
return None
def update(self, msg) -> tuple['Counter', Cmd]:
if isinstance(msg, KeyMsg):
if msg.key == 'q':
return self, quit_cmd
elif msg.key == 'up':
self.count += 1
elif msg.key == 'down':
self.count -= 1
return self, None
def view(self) -> str:
return f"Count: {self.count}\n\nup/down to change, q to quit"
The program calls view() after every update(), diffs the output, and writes only the changed lines. There's no widget tree, no layout engine, no virtual DOM -- just a string.
Why escape sequences make this fast
Traditional TUI frameworks (curses, Textual) maintain an internal screen buffer and compute cell-by-cell diffs. snaptui skips all of that:
- Your
view()returns a string with embedded ANSI codes for colors, bold, etc. - The renderer splits it into lines and compares against the previous frame.
- Only changed lines get written using cursor-movement escape sequences.
- One
flush()per frame -- the terminal handles the rest.
Because ANSI rendering is done by the terminal emulator (which is GPU-accelerated in modern terminals like Ghostty, Kitty, WezTerm), the Python side only needs to produce and diff strings. There's no pixel math, no redraw-the-whole-screen, no intermediate buffer.
Why not cell-level diffing?
Bubble Tea v2 introduced a "Cursed Renderer" that parses view output through an ANSI state machine, builds a 2D grid of cells (each with character + style attributes), and diffs individual cells against the previous frame. snaptui deliberately does not adopt this approach.
The reason is the ratio of computation cost to I/O savings. In Go, the ANSI parser is compiled machine code -- iterating 80x40 = 3,200 cells, comparing structs, and building output takes microseconds. The I/O savings (writing 200 bytes instead of 3,000) are worth more than the computation cost.
In Python, the same state-machine parser would iterate character-by-character through interpreted code, create objects for each cell, and do attribute comparisons through Python's object protocol. For 3,200 cells this easily costs 1-5ms, while the I/O savings of ~2,800 fewer bytes is maybe 0.1ms at TTY write speeds. You'd spend 5ms of CPU to save 0.1ms of I/O.
The line-diff renderer sidesteps this: string equality (old == new) is a single C-level comparison in CPython. No parsing, no cell objects, no per-character work. The cost is near-zero, and unchanged lines (the majority of the screen on any given frame) are skipped entirely.
What's ported from where
snaptui combines features from four Charm libraries into one package, porting from these exact versions:
| Go module | Version |
|---|---|
| charmbracelet/bubbletea | v1.3.10 |
| charmbracelet/lipgloss | v1.1.0 |
| charmbracelet/bubbles | v1.0.0 |
| charmbracelet/huh | v0.8.0 |
| charmbracelet/x/ansi | v0.11.6 |
Here's what's implemented and what's not:
Core Framework
| Feature | Charm Source | snaptui | Status |
|---|---|---|---|
| Elm Architecture (init/update/view) | bubbletea | Model protocol, Program |
Done |
| Message types (Key, WindowSize, Quit) | bubbletea | KeyMsg, WindowSizeMsg, QuitMsg |
Done |
| Command system (async Cmd) | bubbletea | Cmd, batch(), quit_cmd |
Done |
| Raw terminal mode (termios) | bubbletea | terminal.make_raw() |
Done |
| Alternate screen buffer | bubbletea | Program(alt_screen=True) |
Done |
| Line-diff renderer | bubbletea | renderer.Renderer |
Done |
| Keyboard input parsing (70+ sequences) | bubbletea | keys.read_key() |
Done |
| SIGWINCH resize handling | bubbletea | terminal.listen_for_resize() |
Done |
| Mouse input events (SGR) | bubbletea | MouseMsg, Program(mouse=True) |
Done |
| Bracketed paste events | bubbletea | PasteMsg, Program(bracketed_paste=True) |
Done |
| Declarative View struct (cursor, title, alt screen) | bubbletea v2 | View dataclass |
Done |
| Key modifier bitfield | bubbletea v2 | Mod IntFlag on KeyMsg |
Done |
| Focus/blur events | bubbletea v2 | FocusMsg |
Done |
| Kitty keyboard protocol | bubbletea v2 | -- | Not yet |
| Clipboard (OSC 52) | bubbletea v2 | ClipboardMsg, set_clipboard() |
Done |
| Subscriptions (long-running listeners) | bubbletea | Sub, Model.subscriptions() |
Done |
| Suspend/resume (Ctrl+Z) | bubbletea | SIGTSTP/SIGCONT handling in Program |
Done |
Styling
| Feature | Charm Source | snaptui | Status |
|---|---|---|---|
| Chainable style builder | lipgloss | Style class |
Done |
| Text attributes (bold, dim, italic, underline, reverse, strikethrough) | lipgloss | Style.bold(), .dim(), etc. |
Done |
| Underline styles (curly, dotted, dashed, double) and color | lipgloss v2 | Style.underline_style(), .underline_color() |
Done |
| True color (24-bit RGB) | lipgloss | Style.fg(), .bg() |
Done |
| Padding (CSS shorthand) | lipgloss | Style.padding() |
Done |
| Margin (CSS shorthand) | lipgloss | Style.margin() |
Done |
| Width/height constraints | lipgloss | Style.width(), .height() |
Done |
| Max width/height | lipgloss | Style.max_width(), .max_height() |
Done |
Auto word-wrap on .width() |
lipgloss | Style._apply_wrap() |
Done |
| Borders (5 types, per-side, colored) | lipgloss | Style.border(), .border_fg() |
Done |
| Horizontal alignment | lipgloss | Style.align() |
Done |
| Immutable builder pattern | lipgloss | Style._copy() |
Done |
| ANSI 16/256 color fallback | lipgloss | -- | Not yet |
| Adaptive colors (light/dark) | lipgloss | -- | Not yet |
| Vertical alignment | lipgloss | -- | Not yet |
| Tab width | lipgloss | -- | Not yet |
| Table rendering | lipgloss/table | -- | Not yet |
| List rendering (enumerators) | lipgloss/list | -- | Not yet |
| Tree rendering | lipgloss/tree | -- | Not yet |
Layout
| Feature | Charm Source | snaptui | Status |
|---|---|---|---|
| Join blocks horizontally | lipgloss | join_horizontal() |
Done |
| Join blocks vertically | lipgloss | join_vertical() |
Done |
| Place content in canvas (2D) | lipgloss | place() |
Done |
| PlaceHorizontal / PlaceVertical | lipgloss | -- | Not yet |
String Utilities
| Feature | Charm Source | snaptui | Status |
|---|---|---|---|
| ANSI-aware visible width | x/ansi | strutil.visible_width() |
Done |
| ANSI stripping | x/ansi | strutil.strip_ansi() |
Done |
| ANSI-aware truncation | x/ansi | strutil.truncate() |
Done |
| ANSI-aware word wrap | x/ansi | strutil.word_wrap() |
Done |
| ANSI-aware padding | x/ansi | strutil.pad_right() |
Done |
| CJK wide character support | x/ansi | strutil._char_width() |
Done |
| Grapheme cluster segmentation | x/ansi | -- | Not yet |
| Hard wrap (non-word-boundary) | x/ansi | -- | Not yet (hard-break exists within word_wrap) |
| Truncate with tail ("...") | x/ansi | strutil.truncate(s, w, tail="...") |
Done |
Components
| Component | Charm Source | snaptui | Status |
|---|---|---|---|
| Single-line text input | bubbles/textinput | TextInput |
Done |
| Multi-line text area | bubbles/textarea | TextArea |
Done |
| Scrollable viewport | bubbles/viewport | Viewport |
Done |
| Paginated list (delegate pattern) | bubbles/list + bubbles/paginator | List |
Done |
| Option picker (select) | bubbles/list | Select |
Done |
| Yes/No confirmation | huh | Confirm |
Done |
| Multi-field form | huh | Form |
Done |
| Form theme (focused/blurred styles) | huh | Theme, ThemeCharm |
Done |
| App theme (titles, borders, list items) | snaptui-original | AppTheme, AppThemeCharm |
Done |
| Table | bubbles/table | Table, Column |
Done |
| Spinner | bubbles/spinner | Spinner, SpinnerTickMsg |
Done |
| Progress bar | bubbles/progress | Progress |
Done |
| File picker | bubbles/filepicker | -- | Not yet |
| Timer / Stopwatch | bubbles/timer, bubbles/stopwatch | -- | Not yet |
| Help (auto-generated keybinds) | bubbles/help | Help, KeyBinding |
Done |
Legend
- Done -- Implemented and tested
- Not yet -- Not implemented; listed here so future apps know what to add when they need it
Architecture
your_app.py
|
+-- snaptui (framework + styling + components in one package)
|
+-- model.py Elm Architecture: Model protocol, Msg, Cmd
+-- keys.py Keyboard input: escape sequence parser
+-- program.py Event loop: raw mode, resize, render cycle
+-- terminal.py Low-level: termios, ioctl, ANSI sequences
+-- renderer.py Line-diff screen updates
+-- style.py Lip Gloss equivalent: chainable styling
+-- strutil.py ANSI-aware string ops: width, wrap, truncate
+-- layout.py join_horizontal, join_vertical, place
+-- components/
+-- textinput.py Single-line input
+-- textarea.py Multi-line editor
+-- viewport.py Scrollable content
+-- list.py Paginated list with delegates
+-- select.py Option picker
+-- confirm.py Yes/No prompt
+-- form.py Multi-field form
+-- table.py Column-aligned data table
+-- spinner.py Animated spinner
+-- progress.py Progress bar
+-- help.py Auto-generated keybind help
In Go, these are spread across four repos (bubbletea, lipgloss, bubbles, x/ansi). snaptui combines them because Python apps can just import what they need from one package.
File mapping
Exact Go source → snaptui file mapping for reference when porting new features or chasing bugs back to the Go implementation.
Go source repos:
- bubbletea (v1.3.10) — event loop, renderer, key input
- lipgloss (v1.1.0) — styling, layout, borders
- bubbles (v1.0.0) — textinput, textarea, viewport, list
- huh (v0.8.0) — form, select, confirm, theme
- x/ansi (v0.11.6) — string width, wrap, truncate
| snaptui file | Go source(s) |
|---|---|
model.py |
bubbletea/tea.go |
program.py |
bubbletea/tea.go, bubbletea/signals_unix.go |
renderer.py |
bubbletea/standard_renderer.go |
keys.py |
bubbletea/key.go, bubbletea/key_sequences.go |
terminal.py |
bubbletea/tty_unix.go, x/ansi/mode.go |
style.py |
lipgloss/style.go, lipgloss/set.go, lipgloss/get.go, lipgloss/color.go, lipgloss/borders.go, lipgloss/whitespace.go, lipgloss/size.go |
layout.py |
lipgloss/join.go, lipgloss/align.go, lipgloss/position.go |
strutil.py |
x/ansi/width.go, x/ansi/wrap.go, x/ansi/truncate.go, x/ansi/style.go |
theme.py |
huh/theme.go (Form theme); AppTheme/AppThemeCharm are snaptui-original |
components/textinput.py |
bubbles/textinput/textinput.go, bubbles/cursor/cursor.go |
components/textarea.py |
bubbles/textarea/textarea.go |
components/viewport.py |
bubbles/viewport/viewport.go, bubbles/viewport/keymap.go |
components/list.py |
bubbles/list/list.go, bubbles/list/defaultitem.go, bubbles/paginator/paginator.go |
components/select.py |
huh/field_select.go |
components/confirm.py |
huh/field_confirm.go |
components/form.py |
huh/form.go, huh/group.go |
components/table.py |
bubbles/table/table.go |
components/spinner.py |
bubbles/spinner/spinner.go |
components/progress.py |
bubbles/progress/progress.go |
components/help.py |
bubbles/help/help.go, bubbles/key/key.go |
Running tests
python3 -m pytest
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file snaptui-0.2.2.tar.gz.
File metadata
- Download URL: snaptui-0.2.2.tar.gz
- Upload date:
- Size: 46.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c33567a46bdbd3a85f6cc4d4dfd55fed77b8dc14ff5ccec30b9ae8f3890cf68c
|
|
| MD5 |
5837a33c622be19ef0efcce5e4f89c83
|
|
| BLAKE2b-256 |
8f5b7afb94f0f13742483d7d2f1de125c01ecab37ab5b7551a3d97dc4bbaaf06
|
File details
Details for the file snaptui-0.2.2-py3-none-any.whl.
File metadata
- Download URL: snaptui-0.2.2-py3-none-any.whl
- Upload date:
- Size: 44.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
25677b0719609fa2a8c4b00dde313400a3db564f1c66e61fee73b28d0745f30c
|
|
| MD5 |
4f4e3a6ff935574747beccb1f0acf34c
|
|
| BLAKE2b-256 |
8e119ad461c27b696b54be7ae82bccaa58dee8f5616685b8779e2cc3b0e0580b
|