Skip to main content

Zero-dependency Python terminal-UI library with the classic Newt look — truecolor windows, drop shadows, and a no-boilerplate app API

Project description

CookieUI — A Zero-Dependency Python TUI Library

A lightweight, feature-rich terminal-UI library inspired by Newt: the original Newt color palette, double-buffered 24-bit truecolor rendering, a widget/window architecture, and automatic layout helpers. Build beautiful TUIs in pure Python with zero runtime dependencies.

Prologue — the Newt lineage

CookieUI's look and feel descend directly from Newt — the library behind whiptail and the classic Red Hat/Debian text-mode installers. If you've ever installed Linux from a console, you know the aesthetic: bordered windows floating on a bright blue background, drop shadows, chunky buttons you Tab between, modal message boxes. That windowed dialog-box tradition — rather than the web-style docked layouts of modern TUI frameworks — is the thought CookieUI started from.

CookieUI is what Newt would look like if it were designed today: the same classic look, rebuilt as a pure-Python library with a modern interactive core.

Newt / whiptail / snack CookieUI
Look Windows + drop shadows on a blue background The same — it's the signature
Colors 16-color slang palette 24-bit truecolor, themeable (DEFAULT Newt palette, MOCHA)
Rendering slang, cell-based Double-buffered differential ANSI — flicker-free, idle frames cost zero bytes
API C, or the snack Python binding (form-centric, blocking) TuiApp + win.layout() — callbacks, live attribute mutation
Interactivity Static forms: run a form, collect results at the end Continuous run loop: labels, spinners, and progress bars update live
App structure One form at a time View stack (push_view/pop_view), focus management, key bindings
Dependencies libnewt + slang system packages Zero — Python standard library only

What CookieUI adds that Newt never had:

  • Live widgetsSpinner and ProgressBar animate while your code works; set label.text or progress.value from anywhere and the next frame shows it. Newt forms are frozen while displayed.
  • Layout helpers instead of grid mathwin.layout(), rows(), footer_buttons(), shadow-aware stacking; no coordinate arithmetic, no Grid assembly.
  • An app framework, not just dialogs — view navigation, auto-wired quit keys, a status bar, value-carrying listboxes, one-call background tasks (run_task), and automatic re-layout when the terminal is resized.
  • The whiptail quartet as one-linersshow_message(), confirm(), prompt(), choose(): everything whiptail does, as auto-sizing, self-closing method calls.
  • Scrollable textTextView shows logs, file contents, and long command output with word-wrap and the classic Newt scrollbar.
  • Composable widgetscookieui.contrib.FileBrowser navigates directories in place; build your own by subclassing the primitives.
  • Truly zero install — no C library, no system packages; copy the directory and run (or pip install it — the wheel has no dependencies either).

Features

  • Zero dependencies — pure Python, standard library only (termios, tty, select, signal)
  • Double-buffered differential rendering — smooth, flicker-free ANSI 24-bit truecolor; only changed cells hit the wire, and an unchanged frame writes zero bytes (SSH- and battery-friendly)
  • Widget/window architecture — composable containers and controls with real focus management
  • No eyeballed numbers — sizes are fractions of the terminal or content-fit (the window wraps what you put in it); text clips at draw time; fill widgets compute their own height
  • Automatic layoutpage(), columns(), rows(), win.layout() remove positioning math
  • Automatic resize handling — push views as builders and they rebuild + re-center when the terminal resizes
  • Batteries-included TuiApp — auto-linked windows, auto Esc/q quit, an auto status bar, and one-line dialogs
  • One-line modal dialogsshow_message / confirm / prompt / choose: auto-sized, self-closing
  • One-line background workrun_task(fn, status=…, running=…, done=…): worker thread, double-start guard, auto-detected progress bar/spinner, errors never silently lost
  • Scrollable textTextView for logs, file contents, and long output (word-wrap + Newt scrollbar)
  • A real design system, not just colors — a theme carries the palette, every glyph (border sets, [x] brackets, █░ progress pair), and a pluggable chrome that constructs every box. Four shipped looks: NEWT (the signature), MOCHA (dark), OUTLINE (wireframe — pure rounded line work, no filled panels), FLAT (borderless). Switch at runtime; design your own in minutes — see TEMPLATING.md and examples/themedemo.py

Quick Start

from cookieui import TuiApp

class HelloApp(TuiApp):
    def build_view(self):
        page = self.page(0.4, title='Hello')   # 40% of the terminal wide;
        page.label('Welcome to cookieui!')     # no height → wraps its content
        page.gap()
        page.buttons(['OK'])
        return page                            # Esc/q quit + status bar wired automatically

if __name__ == '__main__':
    HelloApp().run()

No __init__: TuiApp pushes build_view automatically (as a builder, so the view rebuilds on terminal resize). A complete, styled, resizable app is the class above — eight lines.

That 0.4 is a fraction of the terminal, and the missing height means the window wraps its content — never write eyeballed cell counts like page(40, 18). The four ways to express a size (fraction / content-fit / rows= / exact cells) are covered in The sizing model below — read that section before writing your first real view.

Installation

The library has zero runtime dependencies, so every install path is trivial:

  • Python: 3.8+
  • Dependencies: none (built-in termios, tty, select, signal)
  • Platform: Linux, macOS, BSD — any POSIX terminal

Options:

  1. pippip install cookieui (from PyPI; pip install . from the repo root, or pip install -e . for development), or
  2. conda / micromambamicromamba install -c conda-forge cookieui (available once the conda-forge feedstock is live; the recipe ships in recipe/meta.yaml), or
  3. Copy the cookieui/ package into your project (it's pure stdlib — copy-and-run), or
  4. Add the repo root to PYTHONPATH (e.g. export PYTHONPATH=/path/to/cookieui).

License: The Unlicense (public domain). Use it for anything; the LICENSE file is what makes that promise legible to package indexes and corporate license scanners.

Running the examples

The example apps live in examples/ and import the installed library — run pip install -e . once from the repo root, then:

python examples/demo.py          # login → settings → dialogs showcase
python examples/quickdialogs.py  # the whiptail quartet: message/confirm/prompt/choose
python examples/todo.py          # task manager with JSON persistence
python examples/filebrowsing.py  # directory navigator + TextView file viewer
python examples/gitlog.py        # git commit viewer with scrollable commit details
python examples/sysinfo.py       # system dashboard (live in-place refresh)
python examples/envbrowser.py    # live-filtered environment variables
python examples/spinnerdemo.py   # animated spinner during a background task
python examples/progressdemo.py  # progress bar driven by a worker thread
python examples/file-copy-progress.py  # real chunked file copy with byte-accurate progress
python examples/processes.py     # live-filtered process table (Table widget), sorted by CPU
python examples/themedemo.py     # the design system: four looks, switched live
python examples/commander.py     # Cookie Commander: two-pane file manager (the capstone)
python examples/html2pdf.py      # HTML→PDF tool — needs extra deps: pip install playwright pillow

(html2pdf.py is the only example with extra dependencies; the rest are stdlib-only.)

The standard app pattern

Subclass TuiApp and define build_view() — it's pushed automatically (as a builder, so the view rebuilds on terminal resize). Initialize app state in setup(), which runs first:

from cookieui import TuiApp

class TasksApp(TuiApp):
    def setup(self):
        self.tasks = []                       # keep state on the app, not in widgets —
                                              # it survives the rebuild on resize

    def build_view(self):
        page = self.page(0.6, 0.8, title='Tasks')  # fractions of the terminal —
        inp = page.input('New task')               # fixed height because the list fills it
        lb  = page.listbox()                       # no height: fills down to the footer
        lb.items = self.tasks

        def add():
            if inp.value.strip():
                self.tasks.append(inp.value.strip())
                lb.items = self.tasks              # mutate → redraws next tick
                inp.clear()

        page.footer([('Add', add), ('Quit', self.quit)])
        inp.on_enter = add
        return page                                # Esc/q quit wired automatically

if __name__ == '__main__':
    TasksApp().run()

The sizing model — no eyeballed numbers

CookieUI's design rule: every number in your app should be intent, never a guessed cell count. There are exactly four ways to express a size, and each says what you mean:

You write It means Use when
0.5 (float ≤ 1.0) A fraction of the terminal — half the width/height, recomputed on every resize "The window should take about this much of the screen"
nothing (no height) Content-fit — the window wraps whatever the layout places in it Forms, dialogs, button panels — anything with a known set of rows
rows=5 A count of things — show 5 visible rows in a listbox/textview "I want the user to see 5 results at a time"
54 (int) Exact cells — you really mean 54 columns Targeting a fixed terminal (e.g. an 80×24 console tool)

And one absence of a number: a page.listbox() / page.textview() with no size at all fills the space between the content above it and the footer buttons — computed from the same geometry the footer row uses, so they can never drift apart.

The same principle — anything the window already knows shouldn't be passed by the caller — gives single-widget windows a one-call body:

win.fill_with(TextView, output, wrap=False)              # created, sized, added
browser = win.fill_with(FileBrowser, start_dir, on_select=open_file)

fill_with(WidgetClass, *content_args, **kwargs) supplies the position and size itself (interior + fill height) and turns off the widget's drop shadow (it's already inside a window); you pass only the content arguments.

Content-fit windows

Omit the height and the window sizes itself to its content when the view is pushed:

page = self.page(0.5, title='Sign In')    # width: half the terminal; height: wraps content
page.input('Username')
page.input('Password')
page.buttons([('Sign In', go), ('Quit', self.quit)])
return page                               # height finalized here — inputs + buttons + border

Add a field later and the window grows by itself; this is how Newt's own wrapped grids worked.

Two rules follow logically (the library enforces both with clear errors):

  1. A content-fit window can't contain a fill widget — "fill the window" is circular when the window is "fit the content." Use rows=N instead: page.listbox(rows=5) works anywhere.
  2. In a content-fit window, buttons flow with the content (page.buttons(...)) — page.footer pins to a bottom edge that doesn't exist yet. (buttons_below is allowed: it re-anchors itself once the height is known.)

Which to choose: content scrolls (listbox/textview fills the window) → fixed height, usually a fraction: page(0.7, 0.8). Content is a known set of rows (form, menu, dashboard) → content-fit: page(0.5).

Side-by-side windows: columns()

left, right = self.columns(view, 2, titles=['Preferences', 'Protocol'])   # equal split
main, side  = self.columns(view, [2, 1])                                  # 2:1 by weight

Widths are derived from the terminal (shadow-aware, centered as a block); heights default to content-fit per window, so each column wraps its own content independently. No (title, w, h) tuples, no coordinates.

Because fractions and content-fit are recomputed every time a builder view is rebuilt, resizing the terminal re-derives the entire layout — proportions hold at any size.

Widgets

All widgets take screen-absolute x, y. Constructor signatures:

  • Window(x, y, width, height, title='', icon='') — bordered container with title, rounded corners, and a 1×1 drop shadow. view.add(win) auto-links it. Provides interior() -> (x, y), interior_size() -> (w, h), interior_rect() -> (x, y, w, h), fill_height(y, footer=True) (height to fill the interior from row y, stopping above the footer-button row — no hand-counted rows), fill_with(WidgetClass, *args, **kwargs) (create a widget that fills the interior and add it — the caller passes only content arguments), and layout(pad_x=1, pad_y=0, spacing=1).
  • Label(x, y, text, bold=False, dim=False, color=None, max_width=None) — static text; set lbl.text = '…' to update live. max_width clips at draw time, so live updates can never overflow a border — no text[:iw - 4] slicing at call sites (labels from page.label() get it automatically).
  • Button(x, y, label, icon='', on_click=None, shadow=True) — 3-line Newt button; btn.on_click = fn after construction too.
  • TextInput(x, y, width, label='', password=False, placeholder='', on_enter=None, on_change=None) — entry field with blinking cursor. inp.value reads/sets text; inp.set_value(s) sets value + cursor-at-end; inp.clear() resets. on_change(value) fires after every user edit (typing, backspace, delete — never cursor moves or programmatic sets): the live-filter hook.
  • Checkbox(x, y, width, label, checked=False, on_change=fn(bool))[*]/[ ], toggle with Space/Enter.
  • Listbox(x, y, width, height, items=None, title='', on_select=fn(idx, item), shadow=True) — scrollable list with a Newt scrollbar. See value-carrying items below.
  • RadioGroup(x, y, width, options=None, selected=0, on_change=fn(idx)) — mutually-exclusive options.
  • Spinner(x, y, label='', frames=None, fps=12, color=None, style='dots') — animated activity spinner; animates from the wall clock with no threads. Seven named styles — dots, line (pure ASCII), arc, circle, square, arrow, pulse — or any custom glyph cycle via frames= (single-cell-width characters only). .visible = False hides it, .spinning = False freezes it, .frames = Spinner.STYLES['arc'] restyles it live.
  • ProgressBar(x, y, width, value=0.0, show_percent=True, color=None, text='') — a self-contained status area: the center shows the live percent while progressing, and .text replaces it when set (the track spans the full width — no side suffix eating columns): [██████ 42% ░░░░░░][█████Complete ✓████]. Set .value/.text from anywhere; both are clipped/clamped at draw time. run_task targets the bar's text by default.
  • TextView(x, y, width, height, text='', title='', wrap=True, shadow=True) — scrollable read-only multi-line text with the Newt scrollbar; for logs, file contents, help screens, command output. Set .text to replace content (tabs expand, long lines word-wrap; wrap=False clips instead); scroll_to_end() follows appended output. Keys: Up/Down, PgUp/PgDn, Home/End.
  • Table(x, y, width, height, columns=None, rows=None, title='', on_select=fn(idx, value), shadow=True) — scrollable table: bold header, separator, selectable rows; keys identical to Listbox. Columns are specs — 'Name', ('Name', weight), ('CPU%', 1, '>') for right-aligned numbers — and the widths are derived (interior split by weight, like columns(); ints in column specs are weights). Rows are value-carrying: t.rows = [((pid, cpu, cmd), proc), …], read t.selected_value; cells are clipped per column at draw time, so pass them untrimmed. A row with the wrong cell count raises a teaching error. Sorting is data logic — sort, then assign .rows. InputDialog / ChoiceDialog add an entry field / a choice list. Prefer the TuiApp one-liners: show_message(...), confirm(...), prompt(...), choose(...).

Composite widgets (cookieui.contrib)

Higher-level widgets built on the primitives — still zero external dependencies — re-exported from the top-level cookieui package for convenience:

  • FileBrowser(x, y, width, height, start_dir, on_select=fn(Path), on_dir_change=fn(Path), extensions=None, show_hidden=False, dirs_only=False) — a Listbox that navigates directories in place: entering a directory refreshes the listing, selecting a file calls on_select(path). Shows the current directory as its title and carries each row's Path as the value. Also .current_dir and .open_dir(path). (extensions is a set of lowercase suffixes like {'.html', '.htm'}.)

Reading & writing widget state

The run loop redraws every tick — mutate an attribute and it reflects next frame; never call draw yourself.

Widget Read Write / mutate
TextInput inp.valuestr inp.set_value(s), inp.clear(), on_enter=fn
Label lbl.text lbl.text = '…' (live, no rebuild; clipped to max_width at draw)
Checkbox cb.checkedbool cb.checked = True
RadioGroup rg.selected → label; rg.selected_index; rg.selected_value options=[…] or [(label, value), …]
Listbox lb.selected → label; lb.selected_index; lb.selected_value lb.items = […] or [(label, value), …]; lb.selected_index = 0
TextView tv.textstr tv.text = '…' (re-wraps); tv.scroll_to_end() to follow output
Table t.selected → cells; t.selected_index; t.selected_value → carried object t.rows = [((cells…), value), …] — auto-clamps selection; t.selected_index = 0
ProgressBar pb.valuefloat pb.value = 0.42 (clamped at draw); center shows the percent, pb.text = '…' replaces it

Value-carrying items (no parallel lists)

Listbox and RadioGroup accept (label, value) pairs — the label is shown, the value is recovered with selected_value. This removes the error-prone "second array indexed by selected_index" pattern:

lb.items = [(f.name, f) for f in paths]    # label shown, Path carried
picked = lb.selected_value                  # → the Path directly

radio.options = [('A4', a4_dict), ('A3', a3_dict)]
dims = radio.selected_value                  # → the dict

Layout helpers

self.page() — one object to talk to

Every standard view starts with page(), and the returned Page delegates to its parts, so the whole view/window/layout vocabulary collapses into one name — layout factories (page.input/.label/.listbox/.buttons/…), window helpers (page.fill_with/.add/…), page.footer([...]) for the footer row, and return page (push_view unwraps it):

def build_view(self):
    page = self.page(0.5, title='Sign In')      # fraction wide, content-fit tall
    page = self.page(0.7, 0.8, title='Files')   # fixed height (for fill widgets)
    ...
    return page

Sizing follows The sizing model above (fractions / content-fit / cells); pad_x/pad_y/ spacing pass through to the layout. The parts stay reachable as page.view / page.win / page.lay — key bindings and status_bar take page.view — and old-style unpacking (view, win, lay = self.page(...)) still works.

Stacking widgets — what page.input()/.listbox()/… is

Those page methods delegate to a VerticalLayout anchored at the window's interior, which positions and adds each widget it builds:

inp = page.input('Name')         # created, positioned, added
cb  = page.checkbox('Enable')
rg  = page.radio_group(OPTIONS)
prefs = page.checkboxes(['Notifications', ('Dark mode', True)])     # batch, returns a tuple
page.buttons([('Sign In', do_login), ('Quit', self.quit)])          # labels + callbacks together
ok, cancel = page.buttons(['OK', 'Cancel'])                         # plain labels still fine

Methods: .label() (clips to the layout width at draw time), .checkbox(), .checkboxes(specs), .input(on_enter=), .radio_group(), .listbox() / .textview() (no size = fill to the footer buttons; rows=5 = show 5 rows — see The sizing model), .spinner(), .progressbar(), .buttons(specs) (labels or (label, fn) pairs; returns a tuple), .gap(lines=1), .fill_height(footer=True). Use page.lay.x / .y / .width as an anchor when a row needs custom placement. Working with a raw Window (no page)? win.layout() returns the same layout; a bare VerticalLayout(x, y, width, spacing=1, target=None) only positions unless you pass target=.

Button rows — page.footer / buttons_below

page.footer([('Save', do_save), ('Quit', self.quit)])            # inside the window, at its footer
buttons_below(view, win, [('Save', do_save), ('Back', back)])    # floating below a window (shadow-aware)

Each spec is a (label, on_click) tuple; both return the buttons as a tuple. (page.footer wraps footer_buttons(win, …) — use the latter with raw Windows.)

self.columns() — side-by-side windows

Covered in detail under The sizing model above; the short version:

left, right = self.columns(view, 2, titles=['Preferences', 'Protocol'])
left.layout().checkbox('Dark mode', checked=True)
right.layout().radio_group(PROTOCOLS)

Options: spec (count or weight list), height (default content-fit; fraction/cells/list), width (the whole block, default 0.9 of the terminal), gap.

self.rows() — stacked windows (the vertical mirror of columns)

log, status = self.rows(view, [12, 3], titles=['Build log', 'Status'], width=0.8)
log.layout()      # furnish each returned window through its own cursor
status.layout()

spec is a window count (equal heights) or a list of per-window heights (ints = rows, floats = fraction of the terminal); all share width, the stack is centered as a block, each window's drop shadow + gap reserved between neighbours.

Positioning primitives

ix, iy, iw, ih = win.interior_rect()             # interior() + interior_size() in one call
h = win.fill_height(iy)                          # fill from row iy, stop above the footer row
lb = page.listbox()                              # or: the page's layout fills to the footer itself

# Shadow-aware stacking (a window at (x,y,w,h) occupies through row y+h / col x+w):
next_y = stack_below(y, height, gap=1)           # shadow-aware vertical stacking
next_x = stack_beside(x, width, gap=2)

Also available (low-level, used internally by the helpers above): calculate_centered_window, layout_buttons, calculate_footer_position, create_status_bar, resolve_size, SHADOW.

TuiApp base class

class MyApp(TuiApp):
    AUTO_QUIT   = True                  # Esc/q quit every view (set False to manage keys yourself)
    AUTO_STATUS = True                  # add a default status bar if a view builds none
    AUTO_RESIZE = True                  # rebuild builder-pushed views when the terminal resizes
    AUTO_VIEW   = True                  # push build_view automatically (no __init__ needed)
    STATUS_HINT = 'Tab cycle focus   Esc / q quit'

    def setup(self): ...                # optional: app state, runs before build_view
    def build_view(self): ...           # pushed automatically as a builder

When the constructor takes arguments (a path, a repo), set state before super().__init__()build_view runs inside it:

class GitLog(TuiApp):
    def __init__(self, repo=None):
        self.repo = repo or Path.cwd()  # state first — build_view needs it
        super().__init__()              # auto-pushes build_view (theme: super().__init__(MOCHA))

Methods:

  • def setup(self) — override it: runs before build_view is auto-pushed; use instead of __init__ when you need to initialize state (self.tasks = load_tasks())
  • self.page(width=0.6, height=None, title='') → a delegating Page object — the one-line view scaffold; fractions or cells, no height = content-fit (see The sizing model and self.page())
  • self.columns(view, spec=2, titles=None, height=None, …) → side-by-side Windows, widths derived
  • self.centered_window(w, h)(x, y, w, h), clamped to the terminal
  • self.status_bar(view, text) — a custom footer bar (suppresses the auto one)
  • self.show_message(title, message, buttons=['OK'], on_close=fn(label)) — auto-closing dialog
  • self.confirm(title, message, on_yes, yes='Yes', no='No', on_no=None) — yes/no (Esc picks no)
  • self.prompt(title, message='', on_submit=fn(str), default='', placeholder='', password=False) — text-entry dialog
  • self.choose(title, items, on_pick=fn(value), message='') — pick-from-list dialog ((label, value) pairs work)
  • self.run_task(fn, *args, status=label, running='…', done='…', error='…', on_done=fn, on_error=fn) — run a domain function on a worker thread: double-start guard, auto-detected progress bar/spinner, status text for running/done/error, error dialog fallback (see Background work)
  • self.push_view / pop_view / replace_view, self.quit()
  • self.ts.width(), self.ts.height() — terminal size; self.KeyType — for key handlers

Auto-wiring: on every push_view/replace_view, TuiApp binds Esc/q → self.quit (TextInput-aware, so q types normally while editing) and adds a default status bar — both opt-out via the class attributes. To make Esc mean something else — "go back" on one pushed view, or app-wide — see bind_quit under Key & event helpers below: calling it on a view marks it quit-bound, so the auto-wiring skips it.

Resize handling

push_view/replace_view accept either a built View or a zero-arg builder. Pass the builder and the view re-runs it whenever the terminal is resized — fractions, content-fit, columns(), and centered_window() read the new size, so everything re-centers with no extra code:

# build_view is pushed this way automatically (AUTO_VIEW); for additional views:
self.push_view(self.build_detail)                     # rebuilt + re-centered on resize
self.replace_view(lambda: self.build_page(name))      # builders with args: capture, then lambda
self.push_view(self.build_detail())                   # plain view — fixed at build-time size

Two rules make rebuilds painless: keep state on the app (not in widgets), and remember that a rebuild re-runs your build_view exactly like the first time. Set AUTO_RESIZE = False if a view holds widget state you can't reconstruct.

Dialogs — the whiptail quartet

CookieUI's Newt ancestry shows here: everything whiptail does is one method call. Dialogs close themselves on any button or Escape; the callback just says what to do after:

self.show_message('File Info', text)                       # msgbox    — plain OK
self.confirm('Quit?', 'Are you sure?', self.quit)          # yesno     — Esc = No
self.prompt('Name', 'Who is asking?', on_submit=greet)     # inputbox  — Enter submits
self.choose('Branch', branches, on_pick=checkout)          # menu      — Enter picks

prompt supports default=, placeholder=, and password=True; choose takes plain labels or (label, value) pairs and hands on_pick the paired value. Cancelling (Escape or the Cancel button) runs the optional on_cancel instead.

Dialogs auto-size to their content but never balloon: long message lines are word-wrapped at max_width (default 0.6 — a fraction of the screen, sizing-model semantics; pass cells or another fraction to tune) — so a 300-character exception string becomes a tidy multi-line dialog, not a screen-wide one.

Background work: run_task

Long work runs on a worker thread; run_task does the entire ceremony — single-flight guard, progress/spinner wiring, status text, error surfacing, thread spawn — usually in one line:

def start(self):
    self.run_task(copy_file, src, dst, status=self.status,
                  running=f'Copying {src.name} …',
                  on_done=lambda n: f'✓ {n:,} bytes',
                  on_error=lambda e: f'✗ {e}')
  • Progress and spinner are found automatically: a view with exactly one ProgressBar gets it reset, wired to the engine's on_progress, and filled to 1.0 on success; exactly one Spinner is shown for the duration. Pass progress=/spinner= to target explicitly, or None to disable. (The bar can stay a local in build_view — nothing else references it.)
  • The bar is its own status area: while progressing its center shows the live percent; with no status= widget the running/done/error texts take that center, installer-style — run_task(download, done='Complete ✓') is a complete progress UI with no label at all: [██████ 42% ░░░░░░] while running, [█████Complete ✓████] at the end. Pass status=label to route text elsewhere.
  • Outcome parameters follow the library-wide naming rule (the same as confirm's yes='Yes' / on_yes=fn): a bare noun is a value, an on_-noun is a callable. done='Saved ✓' for static success text — never write a lambda for a constant; on_done=lambda n: f'✓ {n:,} bytes' when the text needs the result (its returned string is shown when done= is absent; return None to handle display yourself; both together work — on_done for behavior, done= for the text). With neither, str(result) is shown — an engine that returns its message needs zero wiring. error='✗ failed' / on_error=fn(exc) mirror it; with neither, status shows Error: … (or an Error dialog when there's no status) — failures are never silently lost.
  • running='…' appears only if the task actually starts (the guard returns False otherwise).

Write the engine as a pure domain functioncopy_file(src, dst, on_progress) returns its result, raises on failure, reports through the callback, and never knows widgets or threads exist. See examples/file-copy-progress.py and PATTERNS.md's "domain/UI boundary rule".

Key & event helpers

from cookieui import bind_key, bind_enter_action, chain_key_handler

bind_key(view, KeyType.ESCAPE, go_up)              # run fn() on a key (chaining handled internally)
bind_key(view, KeyType.CHAR, refresh, char='r')    # specific char key
bind_enter_action(listbox, on_pick)                # Enter on a widget
TextInput(..., on_enter=submit)                    # Enter on an input — no binding needed
inp.on_change = refresh                            # fires with the new text per edit — live filter
chain_key_handler(widget, fn(key) -> bool)         # low-level escape hatch (return True = consume)

Form flow is automatic: Enter in a TextInput with no on_enter moves focus to the next widget (same order as Tab) — chain fields with zero wiring and set on_enter only on the last one (submit).

For app-wide quit you usually write nothing — TuiApp.AUTO_QUIT handles it. Use bind_quit(view, on_quit) for custom behaviour: it marks the view as quit-bound, so even with AUTO_QUIT = True a pushed view can make Esc/q mean "go back" (bind_quit(view, self.pop_view)) while every other view keeps the automatic quit.

Examples

Twelve runnable apps in examples/. Each documents its patterns in a DESIGN PATTERNS DEMONSTRATED docstring section, and PATTERNS.md is the cheat sheet mapping every pattern to the file that demonstrates it best — start there.

App Demonstrates
demo.py Multi-screen login → settings → dialogs; content-fit page(0.5), columns(), confirm, builder views
quickdialogs.py The whiptail quartet — show_message / confirm / prompt / choose, one line each
todo.py Task manager; setup() state, fill page.listbox(), page.footer, JSON persistence
filebrowsing.py FileBrowser contrib widget + TextView file viewer; push/pop drill-down, Esc=up
gitlog.py Git commit viewer; full git show output in a scrollable TextView, value-carrying listbox
sysinfo.py System dashboard; in-place label.text refresh (no view rebuild), bind_key 'r'
envbrowser.py Live-filtered env vars; inp.on_change, value-carrying listbox
spinnerdemo.py One-line run_task(long_task, status=…, running=…) — auto-detected Spinner, result becomes the status
themedemo.py The design system live: one app in all four looks (NEWT/MOCHA/OUTLINE/FLAT), switched from a radio group
commander.py Cookie Commander — two-pane file manager: FileBrowser panes, copy/move/delete with byte-accurate progress, viewer, confirmations (the book's capstone)
progressdemo.py One-line run_task(download, done='Complete ✓') — auto-detected ProgressBar, no lambdas
file-copy-progress.py run_task on real I/O: pure copy_file engine, byte-accurate progress, atomic publish, error text
processes.py Table with weighted/right-aligned columns; live filter (on_change), value-carrying rows, r refresh
html2pdf.py HTML→PDF tool built on the lower-level App/View API (needs playwright, pillow)

Screenshots

Rendered from real composed frames by screenshots/generate.py — re-run it after any visual change and the screenshots cannot go stale.

File copy, mid-transfer — Mocha (file-copy-progress.py) File browser — Flat (filebrowsing.py)
filecopy filebrowser
Process table (processes.py) Progress bar (progressdemo.py)
processes progress
Spinner styles (spinnerdemo.py) Todo (todo.py)
spinner todo
Login (demo.py) Dialogs — Outline (quickdialogs.py)
login dialogs
Theme gallery, switched live (themedemo.py) Cookie Commander (commander.py)
themes commander

Architecture

cookieui/
├── __init__.py     public API (everything importable from `cookieui`)
├── app.py          App + View (run loop, view stack, focus, dialog overlay)
├── theme.py        Theme, MOCHA, DEFAULT
├── helpers.py      TuiApp + layout/key helpers
├── core/           terminal.py, screen.py, event.py (Key/KeyType)
├── widgets/        window, dialog (+ input/choice dialogs), button, label, textinput,
│                   checkbox, listbox, radiogroup, spinner, progressbar, textview, base
└── contrib/        composite widgets built on the primitives (FileBrowser)
  • Rendering: a double-buffered cell grid (char, fg, bg, bold, italic, dim) flushed as batched 24-bit ANSI — differentially: the previous frame is kept and only changed cells are emitted (as per-row spans with cursor jumps), so an unchanged frame writes zero bytes and a ticking label costs ~50 bytes, not the ~14 KB a full 200×50 repaint costs. First frame and every resize repaint fully. Shadows tint the background ~70% toward dark blue-gray and text to ~15% brightness.
  • Coordinates: screen-absolute from (0, 0). A window's borders take 1 cell on each side and its shadow extends 1 column right / 1 row down — interior()/interior_rect()/win.layout() account for all of it.
  • Focus: View keeps a flat focusable list (global Tab/Shift-Tab); a Window handles arrow-key navigation between its children; View syncs focus to the actually-focused widget each key event.
  • Run loop: single-threaded, polls read_key(timeout=0.05) (~20 FPS) and redraws every tick; quit() / Ctrl-C tear down the terminal cleanly.
  • Resize: SIGWINCH only records the new size; the run loop applies it between frames (never mid-render), then TuiApp re-runs the builder of every builder-pushed view (AUTO_RESIZE).

Keyboard controls

Key Action
Tab / Shift-Tab Focus next / previous widget (global cycle)
Arrows Navigate within the focused window, move the TextInput cursor, scroll a TextView
PgUp / PgDn Page through a Listbox or TextView
Home / End Start / end of a TextInput, RadioGroup, Listbox, or TextView
Space Toggle Checkbox, cycle RadioGroup
Enter Activate Button, submit TextInput (on_enter) or move to the next field (no on_enter), select Listbox item, submit a prompt
Backspace / Delete Edit in TextInput
Esc / q Quit (automatic via TuiApp.AUTO_QUIT; q still types inside a focused TextInput)

Themes

Two themes ship: NEWT (default, original colors) and MOCHA (Catppuccin palette).

from cookieui import TuiApp, MOCHA

class MyApp(TuiApp):
    def __init__(self):
        super().__init__(theme=MOCHA)

Newt palette:

Background        RGB(28, 144, 219)    bright saturated blue
Window surface    RGB(241, 234, 218)   warm beige
Button background RGB(236, 76, 47)     coral-red
Button text       RGB(255, 215, 188)   warm peachy-white
Text / borders    RGB(0, 0, 0)         black
Shadows           RGB(1, 57, 75)       dark blue-gray

Terminal requirements

24-bit truecolor (most modern terminals), POSIX termios (Linux/macOS/BSD), 80×24 minimum recommended.

License

Public domain — use freely in any project.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cookieui-1.0.1.tar.gz (117.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cookieui-1.0.1-py3-none-any.whl (79.0 kB view details)

Uploaded Python 3

File details

Details for the file cookieui-1.0.1.tar.gz.

File metadata

  • Download URL: cookieui-1.0.1.tar.gz
  • Upload date:
  • Size: 117.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.15

File hashes

Hashes for cookieui-1.0.1.tar.gz
Algorithm Hash digest
SHA256 4d56db7d17e1a65519be2f35a2555b549092dc3ffabef70657df4c5dcd81ea37
MD5 bd1db724a6aea31332911d3e0bf5bfb7
BLAKE2b-256 aa1e1b1029201235d5249319f085910fbe03893ab611770b1b01aceefe8ed3be

See more details on using hashes here.

File details

Details for the file cookieui-1.0.1-py3-none-any.whl.

File metadata

  • Download URL: cookieui-1.0.1-py3-none-any.whl
  • Upload date:
  • Size: 79.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.15

File hashes

Hashes for cookieui-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 448f165601ef4025a6e127781a05694f753afd24ea5c31e650262347f54d4843
MD5 db3f4805da5db02e921e66bc140a6807
BLAKE2b-256 3ed5e2f812f1646a910195e9f037aa1c230019b4d39f294af337e690d76f2e17

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page