Skip to content

Home

Counterweight Counterweight

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:

pip install counterweight

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:

The to-do list with three tasks and a fourth being typed

╭──────────────────────────────────────────╮
│ 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:

  • root and task_row are components: functions decorated with @component that return elements, a Div that lays out its children and a Text that shows a string. Counterweight calls them again whenever their state changes.
  • use_state holds the tasks and the draft. Each setter takes a new value, or a function from the latest value to the next.
  • text_input is a widget, a text box built on a headless hook, which edits draft through set_draft.
  • Each on_input handler matches the events it cares about and returns () to claim them, or None to let them bubble up to the element's parent. Enter bubbles out of the text input to add_task on the root.
  • use_focus makes each row focusable, tab_focus moves focus with Tab, and on_click marks a task done when you click it.
  • The style utilities, such as col, border and text_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.

The viewer's tree of sample documents, beside the one about code blocks

╭─ 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.

Two shells side by side, each having run a command, above a bar of the multiplexer's keys

╭─ 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, two guesses in, with a third being typed

                                               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.

A slide that ran a command whose output is in color

  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