Skip to main content

A lightweight, dependency-light Python TUI library with a cell-grid renderer and VT input.

Project description

cozy_tui

CI

A lightweight, cross-platform Python TUI (Terminal User Interface) library. Build keyboard-driven terminal apps with widgets, focus management, mouse support, and smooth cursor blinking — all rendered through raw VT sequences. Runs on Windows (Console API) and POSIX (Linux/macOS via termios).


Table of Contents


Features

  • Cross-platform — runs on Windows (Console API) and POSIX (Linux/macOS via termios); the backend is chosen automatically.
  • Very few dependencies — the clipboard is built in (no pyperclip); the only third-party dependency is rich, used to render Markdown/MarkdownInput. Everything else is the standard library.
  • Built-in clipboardcozy_tui.clipboard.copy/paste with native backends per platform (Win32 API, pbcopy/pbpaste, wl-clipboard/xclip/xsel, or OSC 52 fallback).
  • Unicode-aware rendering — a built-in wcwidth-style width layer keeps CJK/emoji (double-width) and combining marks (zero-width) aligned in the cell grid.
  • Widgets: Button, Checkbox, Input, Label, AnimatedLabel, Text, Box, MarkdownInput, ListView, CheckList, Dropdown, ProgressBar, Table, Collapsible, Tree
  • Layouts: VBox, HBox, Grid — auto-position children without manual x/y
  • Dock layout: app.dock(widget, "top"/"bottom"/"left"/"right"/"fill") — edge-anchored regions that re-flow on resize
  • Overlays & modals: app.open_overlay(widget) floats a widget above the UI, dims the background, and confines focus/input — the basis for dialogs, menus, and tooltips
  • Multi-line Input: Enter or Shift+Enter to insert newlines, UP/DOWN to navigate lines
  • Markdown preview: MarkdownInput renders live Rich Markdown when unfocused
  • Focus system: Tab / Shift+Tab to cycle focus, click to focus with mouse
  • Cursor blinking: Uses the real terminal cursor — smooth blink with no character replacement
  • Mouse support: Click to focus widgets, click to activate buttons, scroll wheel to scroll
  • Scrolling: Long content scrolls vertically; single-line inputs scroll horizontally
  • Global key handlers: Register app-wide shortcuts with app.on_key()
  • Flexible styling: Per-widget foreground, background, and text styles (bold, dim, underline)

Requirements

  • Python 3.10+
  • A VT-capable terminal on Windows (Windows Console API) or POSIX (Linux/macOS, via termios/tty). The console backend is selected automatically at import.

Installation

Install from a checkout with pip (editable is handy for development):

git clone https://github.com/youssefahmed2017/cozy_tui.git
cd cozy_tui
pip install -e .            # add [dev] for the test suite; pip install rich for MarkdownInput

Or just clone and import directly — each example adds the project root to sys.path, so no install is strictly required.

Then in your script:

from cozy_tui import App, Box, Label, Input, Button, Style

Quick Start

from cozy_tui import App, Box, Label, Input, Button, Checkbox, Style
from cozy_tui.events import Key

app = App(full=True, size=None, style=Style(fg="white", bg="black"))

# Box size = virtual pixels ÷ 30 → "1800x420" = 60 cols × 14 rows
box = Box(2, 1, "1800x420", border="rounded", style=Style(fg="white", bg="black"), title="Sign Up")

box.add(Label(2, 2, "Username:"))
box.add(Input(12, 2, 20, placeholder="Enter username"))

box.add(Label(2, 4, "Bio:"))
box.add(Input(12, 4, 20, placeholder="Tell us about you", multiline=True))

box.add(Checkbox(2, 7, "Subscribe to newsletter"))
box.add(Checkbox(2, 9, "I agree to the terms", checked=True))

btn = Button(2, 11, "Submit", width=20, style=Style(fg="white", bg="blue"))
btn.on_click(lambda b: print("Submitted!"))
box.add(btn)

app.add(box)
app.focus(btn)
app.on_key(Key.ESC, lambda: "quit")
app.run()

Core Concepts

How rendering works

cozy_tui maintains an in-memory grid of cells (one per terminal character). Every frame, each widget writes its content into this grid via canvas.write(x, y, text, style). After all widgets have drawn, the grid is serialized into ANSI escape sequences and written to stdout in one shot — this prevents flickering.

Coordinate system

All positions are in terminal characters (columns and rows), not pixels. (x=0, y=0) is the top-left corner of the terminal.

When a widget is inside a Box, its x and y are relative to the box's interior (inside the border). The widget's absolute position is computed automatically.

Widget lifecycle

  1. You create widgets and set their properties.
  2. You add them to a Box (or directly to App).
  3. App.run() starts the event loop, which repeatedly calls render() → each widget's draw() → input handling.

Widgets

App

The root of every cozy_tui application. Manages the render loop, focus, scrolling, and global key handlers.

App(full=True, size="800x600", style=Style(...))
Parameter Description
full True to use the full terminal size (recommended). False uses size.
size "WxH" string in virtual units when full=False. Each unit = 30 characters.
style Background style for the entire screen.

Methods:

app.add(widget)             # Add a top-level widget
app.dock(widget, side)      # Dock a widget to "left"/"right"/"top"/"bottom"/"fill"
                            # (see the Dock Layout section)
app.focus(widget)           # Set the initially focused widget
app.on_key(key, func)       # Register a global key handler
                            # Return "quit" from func to exit the app
app.quit()                  # Exit the app from anywhere (e.g. inside a callback)
app.run()                   # Start the event loop (blocking)

Example:

app = App(full=True, size=None, style=Style(fg="white", bg="black"))
app.on_key(Key.ESC, lambda: "quit")
app.on_key(Key.CTRL_C, lambda: "quit")
app.run()

Box

A bordered container that holds other widgets. Tab dives into the box's first focusable child rather than stopping on the box, so a box wrapping a form focuses the first field directly. Its border highlights whenever the box or any child has focus. A box is not itself a Tab stop by default — pass focusable=True to make an empty or decorative box selectable/clickable in its own right (diving into children still takes precedence when the box has focusable content).

Box(x, y, size, text="", border="single", style=None, title="", focusable=False)
Parameter Description
x, y Position in terminal characters
size "WxH" string in virtual pixels — divide by 30 to get character dimensions. "900x600" = 30 cols × 20 rows.
text Optional centered text in the box interior
border Border style: "single", "double", "rounded", "bold", "none"
style Style for the box background and border
title Optional title shown in the top border
focusable Make the box itself a Tab stop / clickable (False by default). Tab dives into focusable children regardless of this flag.

Methods:

box.add(widget)              # Add a child widget (positions relative to box interior)
box.dock(widget, side)       # Dock a child to an interior edge or "fill"
                             # (see the Dock Layout section)

Border styles:

Style Appearance
"single" +--+ / |
"double" ╔══╗ /
"rounded" ╭──╮ /
"bold" ┏━━┓ /
"none" No border

Example:

box = Box(1, 1, "60x20", border="rounded", style=Style(fg="white", bg="black"), title="My App")
box.add(Label(2, 2, "Hello!"))
app.add(box)

Label

A non-interactive static text widget.

Label(x, y, text, style=None)
Parameter Description
x, y Position
text The text to display
style Optional style override

You can update the label's text at any time by setting .text:

lbl = Label(2, 5, "Status: idle")
# later...
lbl.text = "Status: done"

AnimatedLabel / GlowAnimation

A label whose text is rendered with a per-character animated color wave. Useful for decorative headers or status lines.

AnimatedLabel(x, y, text, animation, *, style=None)
GlowAnimation(colors=None, interval=0.05)
Parameter Description
text The text to animate
animation A GlowAnimation instance (or any object with .colors and .interval)
colors List of named or hex colors cycling across the text characters
interval Seconds between animation frames (default 0.05)

Example:

from cozy_tui import AnimatedLabel, GlowAnimation

anim = GlowAnimation(
    colors=["red", "yellow", "green", "cyan", "blue", "magenta"],
    interval=0.08,
)
lbl = AnimatedLabel(2, 2, "cozy_tui", anim)
app.add(lbl)

The wave shifts one character position each frame, producing a smooth color sweep across the text.


Text

A read-only multi-line text display with automatic word wrapping and optional scrolling. Useful for help text, logs, or any longer content.

Text(x, y, *, width, height, text="", align="left", show_border=False, style=None)
Parameter Description
x, y Position
width Display width in characters (inner text area)
height Number of visible rows (inner text area)
text Initial text content. Use \n for explicit line breaks.
align "left" (default), "center", or "right"
show_border Draw a single-line border around the widget (False by default). The border turns bold-white when focused.

Updating the text:

txt.text = "New content goes here."
txt.set("Also works via set().")

Key bindings (when focused):

Key Action
Up / Scroll Up Scroll up one line
Down / Scroll Down Scroll down one line
Page Up Scroll up by height
Page Down Scroll down by height
Home Jump to the first line
End Jump to the last line

Example:

from cozy_tui import Text, Style

txt = Text(2, 2, width=50, height=10, align="left", show_border=True,
           text="Lorem ipsum dolor sit amet, consectetur adipiscing elit.")
app.add(txt)
app.focus(txt)

Input

A focusable text input field. Supports single-line and multi-line modes.

Input(x, y, width, placeholder="", style=None, cursor=True, cursor_style="vertical",
      flash=True, multiline=False, masked=False, masked_symbol="*")
Parameter Description
x, y Position
width Display width in characters
placeholder Ghost text shown when the field is empty
style Style when unfocused (defaults to terminal reset)
cursor Whether to show the cursor (True by default)
cursor_style "vertical" (default), "block", or "underline"
flash Whether the cursor blinks (True by default)
multiline Enables multi-line editing — Enter or Shift+Enter inserts a newline
masked Hide typed characters (e.g. for passwords). False by default.
masked_symbol Character used for masking. Defaults to "*".

Reading the value:

value = inp.value    # direct attribute
value = inp.get()    # method

Cursor styles:

Style Description
"vertical" The terminal's real blinking cursor bar (smooth, OS-rendered)
"block" Inverted color block drawn over the character at the cursor
"underline" Underlined character at the cursor

Multi-line mode:

When multiline=True, the input stores newlines in .value. Press Enter or Shift+Enter to insert a newline; use UP/DOWN arrows to move between lines.

Masked mode:

When masked=True, typed characters are replaced visually by masked_symbol (default "*"). The real text is still stored in .value and used for validation — only the display is affected.

Example:

name_input = Input(10, 2, 25, placeholder="Your name")
pass_input = Input(10, 4, 25, placeholder="Password", masked=True)
notes_input = Input(10, 6, 25, placeholder="Notes...", multiline=True)
box.add(name_input)
box.add(pass_input)
box.add(notes_input)

Button

A focusable button that executes a callback when activated. Activates on Enter, Space, or mouse click.

Button(x, y, text, width=None, style=None)
Parameter Description
x, y Position
text Label shown on the button
width Total width in characters. Defaults to len(text) + 4 (minimum 8). Set larger to add breathing room.
style fg = text color, bg = button background color

Methods:

btn.on_click(func)   # Register a callback (receives the button as argument); returns self for chaining

Visual states:

State Appearance
Normal Text centered on bg colored background
Focused Colors inverted (fg becomes background, bg becomes text), bold
Pressed Same as normal but dimmed, lasts ~0.3 seconds

Example:

btn = Button(2, 6, "Submit", size=20, style=Style(fg="white", bg="blue"))
btn.on_click(lambda: print("Submitted!"))
box.add(btn)
app.focus(btn)

Chaining:

box.add(
    Button(2, 6, "Delete", width=16, style=Style(fg="white", bg="red"))
    .on_click(handle_delete)
)

Checkbox

A focusable toggle widget. Clicking or pressing Enter/Space flips between checked and unchecked.

Checkbox(x, y, text, checked=False, style=None)
Parameter Description
x, y Position
text Label shown next to the checkbox
checked Initial checked state (False by default)
style fg = text color, bg = background color

Reading the value:

cb.checked    # bool — True if checked

Methods:

cb.on_change(func)   # Called with the new bool value whenever the checkbox is toggled
cb.on_click(func)    # Called with the widget itself on every toggle (same as Button)

Both methods return self for chaining.

Visual states:

State Appearance
Unchecked [ ] Label text — normal style
Checked [x] Label text — bold
Focused [x] Label text — black on white, bold

Example:

cb = Checkbox(2, 3, "Enable notifications", checked=True)
cb.on_change(lambda checked: print(f"Notifications: {checked}"))
box.add(cb)

MarkdownInput

A multi-line text editor that renders its content as live Markdown using Rich when not focused. All editing behaviour is inherited from Input — only the rendering differs.

Uses Rich. rich is a required dependency of cozy_tui (installed automatically), so Markdown/MarkdownInput always render real Markdown.

MarkdownInput(x, y, width, placeholder="", style=None, multiline=True, ...)

All Input parameters are accepted. Typical usage sets multiline=True so the user can write multi-line Markdown.

Behaviour:

State Display
Focused Raw Markdown source with blinking cursor — edit normally
Unfocused Rich-rendered Markdown preview (headings, bold, italic, code, etc.)

Tab cycles focus away; the preview appears instantly when another widget receives focus.

Example:

from cozy_tui import App, Box, Button, MarkdownInput, Style

editor = MarkdownInput(2, 2, 66, multiline=True,
                       placeholder="# Title\n\nStart writing **Markdown** here...")
box.add(editor)
app.focus(editor)

ListView / ListItem

A scrollable, keyboard-navigable list of items. Items can be plain strings or ListItem(text, value) objects to separate display text from the returned value.

ListView(x, y, items=None, *, width=None, height=None, style=None)
ListItem(text, value=None)
Parameter Description
x, y Position
items Initial list of strings or ListItem objects
width Fixed width in chars. None = auto-sized from the widest item.
height Number of visible rows. None = show all items.

Reading the value:

lv.selected        # value of the highlighted item
lv.selected_index  # integer index

Mutating the list:

lv.append(item)          # add to the end
lv.insert(index, item)   # insert at position
lv.remove(item)          # remove by reference
lv.clear()               # remove all items
lv.set(value)            # jump to the item whose value equals value

Callbacks:

lv.on_change(func)   # called with selected value when cursor moves (returns self)
lv.on_select(func)   # called with selected value when Enter is pressed or item clicked (returns self)

Key bindings: Up/Down — move, Home/End — first/last, Enter — confirm selection.

Example:

from cozy_tui import ListView, ListItem

lv = ListView(2, 2, [
    ListItem("Python",     "py"),
    ListItem("JavaScript", "js"),
    ListItem("Rust",       "rs"),
], height=5)
lv.on_select(lambda val: print(f"Chose: {val}"))
box.add(lv)

CheckList / CheckItem

A scrollable list where each item has an independent checked state. Combines ListView-style navigation with Checkbox-style toggling — every row shows [ ] or [✔] and can be toggled individually.

CheckList(x, y, items=None, *, width=None, height=None, style=None)
CheckItem(text, value=None, checked=False)
Parameter Description
x, y Position
items Initial list of strings or CheckItem objects
width Fixed width in chars. None = auto-sized from the widest item.
height Number of visible rows. None = show all items.

Reading values:

cl.selected        # value of the highlighted item
cl.selected_index  # integer index of the highlighted item
cl.checked_values  # list of .value for every checked item
cl.checked_items   # list of CheckItem objects for every checked item

Mutating the list:

cl.append(item)                # add to the end (string or CheckItem)
cl.insert(index, item)         # insert at position
cl.remove(item)                # remove by reference
cl.clear()                     # remove all items
cl.set_checked(value, checked) # flip one item's state by value

Bulk operations (do not fire on_toggle):

cl.check_all()    # check every item
cl.uncheck_all()  # uncheck every item
cl.toggle_all()   # flip every item's state

Callbacks:

cl.on_change(func)   # func(value) — called when the cursor moves to a different row (returns self)
cl.on_toggle(func)   # func(value, checked) — called when an item is toggled by the user (returns self)

Key bindings: Up/Down — move cursor, Home/End — first/last, Enter/Space — toggle highlighted item.

Mouse: clicking a row moves the cursor to it and toggles it immediately.

Example:

from cozy_tui import CheckList, CheckItem, Style

cl = CheckList(2, 2, [
    CheckItem("Buy groceries"),
    CheckItem("Walk the dog", checked=True),
    CheckItem("Finish report"),
], height=5, style=Style(fg="white"))

cl.on_toggle(lambda value, checked: print(f"{value}: {checked}"))
box.add(cl)

Dropdown

A collapsed header that opens a ListView popup when activated. Only one row tall when closed; expands downward when open.

Dropdown(x, y, items=None, *, width=None, height=6, style=None, placeholder=None)
Parameter Description
x, y Position
items Initial list of strings or ListItem objects
width Fixed header width. None = auto from items.
height Max visible rows in the popup (default 6)
placeholder Ghost text shown when no item is selected

Reading the value:

dd.selected        # value of the confirmed selection
dd.selected_index  # integer index

Mutating the list: same API as ListViewappend, insert, remove, clear, set.

Callbacks:

dd.on_change(func)   # called with value when confirmed (returns self)
dd.on_select(func)   # alias — called on same event (returns self)

Key bindings: Enter/Space/Down — open; Up/Down — navigate; Enter or click a row — confirm; Esc or click outside — close without confirming.

The open popup is rendered on the overlay layer, so it floats above every other widget (never clipped or overpainted) and the header stays a single row — opening it no longer pushes surrounding widgets down.

Example:

from cozy_tui import Dropdown, ListItem, Style

dd = Dropdown(2, 2, [
    ListItem("Option A", "a"),
    ListItem("Option B", "b"),
    ListItem("Option C", "c"),
], placeholder="Select one...", style=Style(fg="white"))
dd.on_change(lambda val: print(f"Selected: {val}"))
box.add(dd)

ProgressBar

A non-interactive horizontal progress bar. Displays [==== ] NNN%.

ProgressBar(x, y, fill="=", empty=" ", progress=0, *, width=20, min=0, max=100, style=None)
Parameter Description
x, y Position
fill Character for the filled portion (default "=")
empty Character for the empty portion (default " ")
progress Initial value (default 0)
width Total width in characters including the [ ] NNN% frame (default 20)
min Minimum value (default 0)
max Maximum value (default 100)

Methods:

bar.set(value)           # set the value directly (clamped to min–max)
bar.increment(amount=1)  # add amount to current value
bar.decrement(amount=1)  # subtract amount from current value
bar.get()                # return current value
bar.on_change(func)      # called with new value whenever it changes

Example:

from cozy_tui import ProgressBar, Style

bar = ProgressBar(2, 5, fill="█", empty="░", width=30,
                  style=Style(fg="bright_green"))
bar.set(42)
box.add(bar)

Table / TableRow

A scrollable, keyboard-navigable table with sortable columns and optional border.

Table(x, y, *, height=None, show_header=True, show_border=False, style=None)
Parameter Description
x, y Position
height Number of visible data rows. None = show all rows.
show_header Draw the column header row (default True)
show_border Draw a border around the table (default False)

Building the table:

tbl.add_column(title, *, width=None, align="left")
tbl.add_row(*values, style=None, disabled=False, metadata=None)
tbl.insert_row(index, *values, ...)
tbl.remove_row(index)
tbl.set_cell(row_index, col_index, value)
tbl.update_row(index, *values)
tbl.sort(column, *, reverse=False)   # column = title string or int index
tbl.clear()

TableRow — the object returned by iteration and selected_row:

row = tbl.selected_row     # TableRow | None
row[0]                     # access column value by index
list(row)                  # iterate all values
row.style                  # per-row style override
row.disabled               # skip focus/highlight if True
row.metadata               # arbitrary user data attached to this row

Callbacks:

tbl.on_change(func)   # func(row) — called when selection moves
tbl.on_select(func)   # func(row) — called on Enter or click

Key bindings:

Key Action
Up / Down Move row selection
Left / Right Move column highlight
Home / End First / last row
Enter Fire on_select

Example:

from cozy_tui import Table, Style

tbl = Table(2, 2, height=8, show_border=True)
tbl.add_column("Name",  width=16)
tbl.add_column("Lang",  width=12)
tbl.add_column("Stars", width=8, align="right")

tbl.add_row("cozy_tui",  "Python", "★★★★")
tbl.add_row("rich",      "Python", "★★★★★")
tbl.add_row("textual",   "Python", "★★★★★")

tbl.sort("Stars", reverse=True)
tbl.on_select(lambda row: print(row[0]))
app.add(tbl)

Collapsible

A focusable expand/collapse container. Children are navigated with Up/Down while the Collapsible holds focus — Tab does not descend into individual children.

Collapsible(x, y, *, title="", expanded=True, style=None)
Parameter Description
title Text shown in the header row
expanded Whether to start open (True by default)

Adding children:

coll.add(widget)              # any Widget — Button, Checkbox, Label, …
coll.add(ListItem("text", v)) # or a ListItem for text-only rows
coll.add("plain string")      # plain strings work too

All items (widgets and text) are laid out sequentially, one row each. Widget children draw in their active/focused style when the cursor is on them. Text items render with a > selection prefix.

Expand/collapse API:

coll.expand()
coll.collapse()
coll.toggle()
coll.on_toggle(func)   # func(expanded: bool)

Key bindings (when focused):

Key Action
Up / Down Move cursor through children
Left Collapse
Right Expand
Enter / Space Activate selected child (or expand if collapsed)
Home / End First / last child

Callbacks:

coll.on_select(func)   # func(value) — fires when Enter is pressed on a text item
coll.on_toggle(func)   # func(expanded) — fires when the section expands or collapses

Example:

from cozy_tui import Collapsible, Checkbox, ListItem

section = Collapsible(2, 2, title="Theme", expanded=True)
section.add(ListItem("Dark",      "dark"))
section.add(ListItem("Light",     "light"))
section.add(ListItem("Solarized", "solarized"))
section.on_select(lambda val: print(f"Theme: {val}"))
app.add(section)

Tree / TreeNode

A hierarchical tree view where each node can be expanded or collapsed. Navigation moves through the flat list of currently visible nodes.

Tree(x, y, *, height=None, connectors=False, style=None)
Parameter Description
height Number of visible rows. None = show all visible nodes.
connectors Draw ├──, └──, branch lines (default False)

Building the tree:

root  = tree.add("Project")        # returns a TreeNode
child = root.add("src")            # TreeNode — chains naturally
child.add("main.py")
child.add("app.py")

Every add() call returns a TreeNode, so nesting is expressed by method chaining.

TreeNode API:

node.add(text)       # append a child, return it
node.expand()        # set expanded = True
node.collapse()      # set expanded = False
node.toggle()        # flip expanded (no-op on leaf)
node.is_leaf         # True if no children
node.text            # display text
node.expanded        # current expand state
node.metadata        # arbitrary user data slot

Key bindings (when focused):

Key Action
Up / Down Move selection through visible nodes
Right Expand the selected node
Left Collapse (if expanded) or jump to parent (if collapsed)
Enter / Space Toggle expand/collapse; fires on_select
Home / End First / last visible node

Callbacks:

tree.on_select(func)   # func(node) — fires on Enter or click
tree.on_change(func)   # func(node) — fires when selection moves

Example (without connectors):

from cozy_tui import App, Tree

app = App()
tree = Tree(2, 2)

project = tree.add("Project")
project.expand()

src = project.add("src")
src.expand()
src.add("main.py")
src.add("app.py")

widgets = src.add("widgets")
widgets.add("button.py")
widgets.add("checkbox.py")

project.add("docs").add("README.md")

tree.on_select(lambda node: print(node.text))
app.add(tree)
app.focus(tree)
app.run()

Connector rendering (connectors=True):

v Project
├── v src
│   ├── main.py
│   ├── app.py
│   └── > widgets
└── > docs

After expanding widgets:

v Project
├── v src
│   ├── main.py
│   ├── app.py
│   └── v widgets
│       ├── button.py
│       └── checkbox.py
└── > docs

Layouts

Layouts are borderless containers that automatically position their children — you don't set x/y on children added to a layout. They inherit from Widget and can be placed anywhere a widget can (inside a Box, directly on App, or nested inside other layouts).

All layouts support .add(widget) which returns self for chaining.

Layout (base)

The base class for all layouts. Not used directly — subclass it and implement _arrange() to set each child's x, y, and update self._computed_width / self._computed_height.

class MyLayout(Layout):
    def _arrange(self):
        # position self.children, then set:
        self._computed_width = ...
        self._computed_height = ...

VBox

Stack children vertically, top to bottom. Width grows to the widest child; height is the sum of child heights plus gaps.

VBox(x, y, gap=0, style=None)
Parameter Description
x, y Position
gap Blank rows between children (default 0)

Example:

from cozy_tui import VBox, Label, Button, Style

vbox = VBox(2, 2, gap=1)
vbox.add(Label(0, 0, "Name:"))
vbox.add(Input(0, 0, 20, placeholder="Enter name"))
vbox.add(Button(0, 0, "Submit", width=20, style=Style(fg="white", bg="blue")))
box.add(vbox)

Children's x/y are ignored — the layout computes them. Pass 0, 0 or any placeholder.


HBox

Stack children horizontally, left to right. Height grows to the tallest child; width is the sum of child widths plus gaps.

HBox(x, y, gap=0, style=None)
Parameter Description
x, y Position
gap Blank columns between children (default 0)

Example:

from cozy_tui import HBox, Button, Style

hbox = HBox(2, 10, gap=2)
hbox.add(Button(0, 0, "OK", width=10, style=Style(fg="white", bg="green")))
hbox.add(Button(0, 0, "Cancel", width=10, style=Style(fg="white", bg="red")))
box.add(hbox)

Grid

Arrange children in a fixed number of columns, filling left to right, top to bottom. Column widths are sized to the widest child in each column; row heights to the tallest child in each row.

Grid(x, y, cols, gap_x=1, gap_y=0, style=None)
Parameter Description
x, y Position
cols Number of columns
gap_x Horizontal gap between columns (default 1)
gap_y Vertical gap between rows (default 0)

Example:

from cozy_tui import Grid, Checkbox

grid = Grid(2, 2, cols=2, gap_x=4, gap_y=1)
for option in ["Red", "Green", "Blue", "Yellow"]:
    grid.add(Checkbox(0, 0, option))
box.add(grid)

This renders as:

[✔] Red      [✔] Green
[✔] Blue     [✔] Yellow

Dock Layout

Instead of positioning widgets by hand, you can dock them to the edges of a container. Both App and Box have a dock() method:

app.dock(widget, side, margin=0)   # dock to a screen edge
box.dock(widget, side, margin=0)   # dock to a box interior edge

side is one of "left", "right", "top", "bottom", or "fill".

How space is divided

Docking works by consuming a shrinking rectangle. The container starts with its full area; each dock carves a band off one edge, and the next dock only sees what's left:

  • top / bottom → take a horizontal band; the widget stretches across the remaining width.
  • left / right → take a vertical band; the widget stretches across the remaining height.
  • fill → takes the entire leftover rectangle — this is who "gets the rest of the space."

Order matters. Docks are applied in the order you add them, so the widget docked last sees the smallest rectangle:

app.dock(header,  "top")     # full width, along the top
app.dock(status,  "bottom")  # full width, along the bottom
app.dock(sidebar, "left")    # spans only the band BETWEEN header and status
app.dock(main,    "fill")    # everything that's left
+----------------------------------+
| Header                           |
+------+---------------------------+
| Side | Main (fill)               |
| Bar  |                           |
+------+---------------------------+
| Status                           |
+----------------------------------+

Had you docked the sidebar before the header and status, it would span the full terminal height instead.

Stretching and margin

Whether a docked widget actually fills its band depends on the widget. A Box grows to fill the slice it's given (so a docked Box spans the full width/height of its band automatically); fixed-size widgets like Label simply anchor at the slice's top-left corner. margin insets the widget from the edge it docks against.

Reactive by design

Docks are recomputed every frame, so the layout re-flows automatically when the terminal is resized — no manual repositioning needed. Docking returns the widget, so calls can be chained or captured:

sidebar = app.dock(Box(0, 0, "180x10", title="Menu"), "left", margin=1)

On non-full (scrollable) apps, docked widgets scroll with the content rather than staying pinned to the viewport. For the typical full=True app there is no scroll, so they stay anchored.

See examples/dock_layout/dock_layout.py for a complete header / sidebar / status / fill layout.


Overlays & Modals

Overlays draw a widget above the rest of the UI on a separate z-layer — the basis for dialogs, menus, and tooltips. Push one with app.open_overlay(widget) and remove it with app.close_overlay().

def confirm(_btn):
    dialog = Box(0, 0, "520x180", title="Confirm", border="rounded")
    dialog.add(Label(2, 1, "Delete everything?"))
    dialog.add(Button(2, 4, "Cancel").on_click(lambda b: app.close_overlay(dialog)))
    dialog.add(Button(14, 4, "Delete").on_click(lambda b: app.close_overlay(dialog)))
    app.open_overlay(dialog, close_on_click_outside=True)
app.open_overlay(widget, *, modal=True, dim=True, center=True,
                 close_on_escape=True, close_on_click_outside=False, on_close=None)
app.close_overlay(widget=None)   # topmost, or the overlay wrapping `widget`
Option Meaning
modal Confine keyboard focus and mouse input to the overlay (Tab cycles only inside it). Non-modal overlays are purely visual, e.g. tooltips.
dim Grey the background behind the overlay as a scrim.
center Re-centre the widget on screen every frame (survives resize). Set False to position it yourself via x/y.
close_on_escape Esc dismisses the topmost modal (default True).
close_on_click_outside A click outside the overlay dismisses it (default False).
on_close func(widget) called when the overlay closes.

Behaviour:

  • Overlays are screen-fixed — unaffected by scrolling — and stack (last opened is topmost).
  • Opening a modal moves focus to its first focusable child; closing restores the focus that was active before.
  • A Box is the natural overlay container (it gives the dialog a border, title, and hit-testing). Its border highlights while it holds focus.

See examples/overlay/overlay.py for a dismissable confirm dialog.

Text-entry prompt

For the common case of "ask the user for a line of text", app.prompt() wraps the PromptDialog widget and the overlay plumbing into one call:

app.prompt("Rename card", initial=card.text,
           on_submit=lambda text: rename(card, text),   # Enter
           on_cancel=lambda: None)                       # Esc / click outside
app.prompt(title, initial="", *, on_submit=None, on_cancel=None,
           width=40, close_on_click_outside=True)   # returns the PromptDialog

Enter fires on_submit(text) and closes the dialog; Esc or a click outside fires on_cancel(). It's a centered, dimmed modal, so focus and input are confined to it. The underlying PromptDialog is also exported if you want to compose it yourself.


Styling

Styles are created with the Style class:

Style(fg="color", bg="color", styles=["bold", "dim", "underline"])

Available colors:

black, red, green, yellow, blue, magenta, cyan, white, bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white

Text styles: "bold", "dim", "underline"

Example:

Style(fg="white", bg="blue")                      # white text on blue background
Style(fg="bright_white", bg="black", styles=["bold"])   # bold bright white on black
Style(fg="cyan")                                  # cyan text, default background

Key Bindings

Global (handled by App)

Key Action
Tab Focus next widget
Shift+Tab Focus previous widget
Ctrl+C Exit the app
Scroll Up / Page Up / Ctrl+Up Scroll content up
Scroll Down / Page Down / Ctrl+Down Scroll content down

Input widget

Key Action
Arrow keys Move cursor
Home / End Jump to line start / end
Backspace Delete character behind cursor
Delete Delete character ahead of cursor
Enter / Shift+Enter Insert newline (multiline mode only)
UP / DOWN Move between lines (multiline mode only)

Button widget

Key Action
Enter Activate button
Space Activate button

Checkbox widget

Key Action
Enter Toggle checked state
Space Toggle checked state

Registering custom global shortcuts

app.on_key(Key.ESC, lambda: "quit")      # return "quit" to exit
app.on_key(Key.ENTER, submit_form)        # any function works too

Available key constants in cozy_tui.events.Key:

ESC, ENTER, BACKSPACE, TAB, SHIFT_TAB, SHIFT_ENTER, UP, DOWN, LEFT, RIGHT, HOME, END, DELETE, INSERT, PAGE_UP, PAGE_DOWN, CTRL_UP, CTRL_DOWN, CTRL_LEFT, CTRL_RIGHT, CTRL_C, F1F12

Modifier combos. Terminals only send Alt/Ctrl combined with another key, so use the helpers:

from cozy_tui.events import Key

app.on_key(Key.alt("s"), save)          # Alt+S   → "alt+s"
app.on_key(Key.ctrl("f"), find)         # Ctrl+F  → the raw control byte "\x06"
app.on_key(Key.F5, refresh)             # F5
app.on_key(Key.ctrl(Key.F5), hard_refresh)  # Ctrl+F5 → "ctrl+F5"

# Key.ALT == "alt" and Key.CTRL == "ctrl" are the underlying prefixes.
  • Key.alt(c)"alt+" + c (matches read_key() for Alt+<char>, e.g. Key.alt("backspace")).
  • Key.ctrl(c) → the actual control byte for a letter (Key.ctrl("a") == Key.CTRL_A), or a "ctrl+<key>" string otherwise (e.g. an F-key).
  • Key.shift(c)"shift+" + c (used for F-keys).
  • Modified F-keys parse to canonical "ctrl+F5", "shift+F5", "ctrl+shift+F12" … The modifier order is always ctrl, alt, shift, and the helpers compose in that order — Key.ctrl(Key.shift(Key.F5)) == "ctrl+shift+F5".

Mouse Support

Mouse clicks are handled automatically:

  • Click any focusable widget → gives it focus
  • Click a Button → gives it focus and activates it
  • Click a Checkbox → gives it focus and toggles it
  • Scroll wheel → scrolls the app content up/down

No extra setup needed — mouse support is enabled automatically when app.run() starts.


Focus System

Focus determines which widget receives keyboard input. Only focusable widgets (Input, Button, Checkbox, ListView, Dropdown, Table, Collapsible, Tree) can hold focus. A focusable container defers to its children: Tab dives into a Box's first focusable child instead of stopping on the box. A Box is not a Tab stop on its own unless you construct it with Box(..., focusable=True), which is useful for empty or decorative boxes you still want selectable.

app.focus(widget)      # set initial focus manually

While running:

  • Tab moves focus to the next focusable widget
  • Shift+Tab moves focus to the previous one
  • Mouse click focuses the clicked widget

Focused widgets receive a visual highlight — inputs show a white background and a blinking cursor; buttons invert their colors and go bold. The parent Box also highlights its border when any child has focus.


Scrolling

When content is taller than the terminal, the app scrolls vertically. Scroll controls:

Key / Action Effect
Scroll wheel up Scroll up 3 rows
Scroll wheel down Scroll down 3 rows
Page Up / Ctrl+Up Scroll up 3 rows
Page Down / Ctrl+Down Scroll down 3 rows

Examples

The examples/ directory contains runnable apps. Each example adds the project root to sys.path automatically, so they can be run from any directory.

examples/basic/basic.py — Hello World

Minimal app with a label and a quit button. Good starting point.

python examples/basic/basic.py

examples/timer_app/timer.py — Timer / Forms

Demonstrates Input, Button, Checkbox, ProgressBar, Dropdown, ListView, VBox, HBox, and Grid in a single app.

python examples/timer_app/timer.py

examples/dock_layout/dock_layout.py — Dock Layout

Demonstrates App.dock() with a header (top), status bar (bottom), sidebar (left), and a fill main area that claims the remaining space. Resize the terminal to watch the layout re-flow.

python examples/dock_layout/dock_layout.py

examples/overlay/overlay.py — Overlays / Modals

A base screen with a button that opens a centered, dimmed modal dialog. Tab is confined to the dialog; Esc or a click outside dismisses it.

python examples/overlay/overlay.py

examples/command_palette/command_palette.py — Command Palette

A Spotlight/VS Code-style fuzzy command launcher in a modal overlay: a custom widget with its own text buffer and filtered result list. Press p to open, type to fuzzy-search, Enter/click to run. Includes a background-worker command that keeps the UI responsive.

python examples/command_palette/command_palette.py

examples/kanban/kanban.py — Kanban Board

A keyboard-driven To Do / Doing / Done board built from Boxes + ListViews. Tab switches columns, Up/Down selects, ←/→ moves a card between columns, a/d add/delete, ? shows a help overlay, c opens a confirm-clear modal.

python examples/kanban/kanban.py

examples/snake/snake.py — Snake

A real-time Snake game: a fully custom drawing widget painting the field cell-by-cell, driven by app.tick_interval (game logic decoupled from render rate), with a "Game Over" modal offering Restart / Quit.

python examples/snake/snake.py

examples/calculator_app/calculator.py — Calculator

A fully keyboard-driven calculator supporting +, -, ×, ÷, ** (exponent), (square root), and ! (factorial).

python examples/calculator_app/calculator.py

Calculator keyboard shortcuts:

Key Action
09, . Enter digits
+ - * / Arithmetic operators (* inserts ×, / inserts ÷)
^ Exponent (**)
r Square root (√()
! Factorial
Enter / = Evaluate
Backspace Delete last character
c Clear
ESC Quit

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

cozy_tui-0.1.0.tar.gz (96.0 kB view details)

Uploaded Source

Built Distribution

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

cozy_tui-0.1.0-py3-none-any.whl (76.3 kB view details)

Uploaded Python 3

File details

Details for the file cozy_tui-0.1.0.tar.gz.

File metadata

  • Download URL: cozy_tui-0.1.0.tar.gz
  • Upload date:
  • Size: 96.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.9

File hashes

Hashes for cozy_tui-0.1.0.tar.gz
Algorithm Hash digest
SHA256 a620aa7b05875bbd88471b4714d82e187694ce93a52bd8f236fd655fdf61e148
MD5 473216bfbabf4c707636bc4be855055f
BLAKE2b-256 87bb2c86bd5f03522d5e84946c82dd0a7c63d8aa1035f3550d0671e83a0a62a5

See more details on using hashes here.

File details

Details for the file cozy_tui-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: cozy_tui-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 76.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.9

File hashes

Hashes for cozy_tui-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 176708239a01dee2502a40c940ddf927f958805e42b98ee5b5182cdbbe0feae9
MD5 594d28f1ef3d0258a8db0c8ff3e470d9
BLAKE2b-256 2fe7d0d3c1cda30542d5d9cc047fa7cc6f5b91f22a005ee2bff9dd95a2896b20

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