Home
Counterweight is an experimental text user interface (TUI) framework for Python, inspired by React and Tailwind CSS.
A TUI application built with Counterweight is a tree of declarative components, each of which represents some piece of the UI by bundling together a visual element along with its state and how that state should change due to events like user input.
As an application author, you define the components and their relationships as a tree of Python functions. You use hooks to manage state and side effects, and styles to change how the elements look.
Counterweight takes this declarative representation of the UI and renders it to the terminal, updating the UI when state changes in response to user input or side effects (by calling your function tree).
Installation
Counterweight is available on PyPI, and needs Python 3.15 or newer:
Quick start
This to-do list is a complete app.
Save it as todo.py, or have counterweight examples copy todo write it there, and run it with python todo.py;
Ctrl+C quits.
"""
To-do list
A to-do list, the app the quick start builds.
Type a task and press Enter to add it, Tab to move between the tasks, and Space or a click to mark one done.
"""
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import replace
from functools import partial
from counterweight.app import app
from counterweight.components import component
from counterweight.elements import Div
from counterweight.elements import Text
from counterweight.events import InputEvent
from counterweight.events import InputResponse
from counterweight.events import KeyPressed
from counterweight.events import Receiver
from counterweight.handlers import first
from counterweight.handlers import on_click
from counterweight.handlers import tab_focus
from counterweight.hooks import use_focus
from counterweight.hooks import use_state
from counterweight.keys import Key
from counterweight.styles.utilities import *
from counterweight.widgets import text_input
@dataclass(frozen=True, slots=True)
class Task:
title: str
is_done: bool = False
FIRST_TASKS: tuple[Task, ...] = (Task("Install Counterweight", is_done=True), Task("Write a component"))
@component
def root() -> Div:
tasks, set_tasks = use_state(FIRST_TASKS)
draft, set_draft = use_state("")
def add_task(event: InputEvent, _: Receiver) -> InputResponse:
match event:
case KeyPressed(key=Key.Enter) if draft.strip():
set_tasks(lambda tasks: (*tasks, Task(draft.strip())))
set_draft("")
return ()
case _:
return None
def toggle(index: int) -> None:
set_tasks(
lambda tasks: tuple(replace(t, is_done=not t.is_done) if i == index else t for i, t in enumerate(tasks))
)
left = sum(not task.is_done for task in tasks)
return Div(
style=col | width(44),
on_input=first(add_task, tab_focus),
children=(
Div(
style=col | gap(1) | border | border_lightrounded | border_color("slate", 500) | pad_x(1),
children=(
Text(content=f"To do ({left} left)", style=text_color("amber", 400)),
Div(style=col, children=tuple(task_row(task, partial(toggle, i)) for i, task in enumerate(tasks))),
text_input(draft, set_draft, placeholder="What needs doing?", auto_focus=True),
),
),
Text(
content="enter: add · tab: move · space: done",
style=text_justify_center | text_color("slate", 500),
),
),
)
@component
def task_row(task: Task, toggle_done: Callable[[], None]) -> Text:
focus = use_focus()
def on_space(event: InputEvent, _: Receiver) -> InputResponse:
match event:
case KeyPressed(key=Key.Space):
toggle_done()
return ()
case _:
return None
return Text(
content=f"{'✓' if task.is_done else '○'} {task.title}",
style=(text_color("emerald", 400) if task.is_done else text_color("slate", 200))
| (text_bg("slate", 700) if focus.is_focused else default),
on_input=first(on_space, on_click(toggle_done)),
)
if __name__ == "__main__":
asyncio.run(app(root()))
After adding "Read the docs", with "Ship it" typed but not yet added:
╭──────────────────────────────────────────╮
│ To do (2 left) │
│ │
│ ✓ Install Counterweight │
│ ○ Write a component │
│ ○ Read the docs │
│ │
│ ╭──────────────────────────────────────╮ │
│ │ Ship it │ │
│ ╰──────────────────────────────────────╯ │
╰──────────────────────────────────────────╯
enter: add · tab: move · space: done
Adding tasks, then moving to them with Tab and marking them done with Space:
What's in it:
rootandtask_roware components: functions decorated with@componentthat return elements, aDivthat lays out its children and aTextthat shows a string. Counterweight calls them again whenever their state changes.use_stateholds the tasks and the draft. Each setter takes a new value, or a function from the latest value to the next.text_inputis a widget, a text box built on a headless hook, which editsdraftthroughset_draft.- Each
on_inputhandler matches the events it cares about and returns()to claim them, orNoneto let them bubble up to the element's parent. Enter bubbles out of the text input toadd_taskon the root. use_focusmakes each row focusable,tab_focusmoves focus with Tab, andon_clickmarks a task done when you click it.- The style utilities, such as
col,borderandtext_color("amber", 400), merge with|, as Tailwind's classes combine.
Showcase
Counterweight ships with complete apps,
each standing for a kind of app people build with it, beside the snippets these docs show.
Run one with counterweight examples run <name>, such as counterweight examples run wordle,
or browse them all, and run each in place, with counterweight examples gallery
(see Examples).
Chat
chat.py
is a chat with a canned bot whose Markdown replies stream in a word at a time,
in a transcript that stays scrolled to the newest message.
Markdown viewer
markdown_viewer.py
browses the working directory's Markdown files, in a tree beside the one it's showing.
╭─ markdown_samples/ ──────────╮╭─ blocks/code.md ─────────────────────────────────────────────────╮
│ ▾ blocks/ ││ Code │
│ alerts.md ││ │
│ block-quotes.md ││ def tallest(peaks): │
│ breaks-and-html.md ││ return max(peaks, key=lambda p: p.height) │
│ code.md ││ │
│ lists.md ││ Indented code isn't highlighted. │
│ tables.md ││ │
│ task-lists.md ││ │
│ ▸ extensions/ ││ │
│ ▸ text/ ││ │
│ README.md ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰──────────────────────────────╯╰──────────────────────────────────────────────────────────────────╯
Multiplexer
multiplexer.py
embeds terminals running shells in panes side by side, with hotkeys as zellij has.
╭─ Pane #1 ────────────────────────────╮╭─ Pane #2 ────────────────────────────╮
│$ echo hello from the first pane ││$ echo $((6 * 7)) │
│hello from the first pane ││42 │
│$ ││$ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰──────────────────────────────────────╯╰──────────────────────────────────────╯
<Alt n> NEW PANE <Alt ←/→> FOCUS <Alt x> CLOSE
Wordle
wordle.py
is the word game, with a daily word or a random one,
played on the keyboard or by clicking the keys on screen.
Wordle
┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓
┃ C ┃ ┃ R ┃ ┃ A ┃ ┃ N ┃ ┃ E ┃
┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛
┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓
┃ L ┃ ┃ A ┃ ┃ S ┃ ┃ E ┃ ┃ R ┃
┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛
┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓
┃ G ┃ ┃ A ┃ ┃ Z ┃ ┃ ┃ ┃ ┃
┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛
┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓
┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃
┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛
┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓
┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃
┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛
┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓
┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃
┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛
┌────────────────┐
│ Guess 3 of 6 │
└────────────────┘
╭────────────╮ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ╭────────────────╮
│ Exit [Esc] │ ┃ Q ┃ ┃ W ┃ ┃ E ┃ ┃ R ┃ ┃ T ┃ ┃ Y ┃ ┃ U ┃ ┃ I ┃ ┃ O ┃ ┃ P ┃ │ Submit [Enter] │
╰────────────╯ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ╰────────────────╯
┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ╭──────╮
┃ A ┃ ┃ S ┃ ┃ D ┃ ┃ F ┃ ┃ G ┃ ┃ H ┃ ┃ J ┃ ┃ K ┃ ┃ L ┃ │ Back │
┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ╰──────╯
┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ┏━━━┓ ╭───────╮
┃ Z ┃ ┃ X ┃ ┃ C ┃ ┃ V ┃ ┃ B ┃ ┃ N ┃ ┃ M ┃ │ Clear │
┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ┗━━━┛ ╰───────╯
Slides
slides.py
is a presentation whose slides each run a command and show what it printed
beneath their Markdown notes, which open in your editor to change.
Colors come through
The command runs on a terminal, so it colors its output as it would in
yours.
$ printf '\033[32mgreen\033[0m and \033[1;33mbold yellow\033[0m\n'
╭──────────────────────────────────────────────────────────────────────────╮
│ green and bold yellow │
│ │
│ │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
exited 0
3 / 5 · e: edit the notes