Skip to main content

A Python TUI library with differential rendering - port of @mariozechner/pi-tui

Project description

PyPiTUI

Terminal UIs that don't flicker. Native scrollback. 60fps.

PyPI Python License


from pypitui import TUI, Text, Input, ProcessTerminal

terminal = ProcessTerminal()
tui = TUI(terminal)

tui.add_child(Text("Hello, World!"))

inp = Input(placeholder="Type here...")
inp.on_submit = lambda v: print(f"You typed: {v}")
tui.add_child(inp)
tui.set_focus(inp)

tui.run()  # 60fps, no flicker, scrollback enabled

Why PyPiTUI?

Library Rendering Scrollback 60fps Size
curses Full redraw Built-in
Textual Full redraw ⚠️ ~50MB
Rich (Live) Full redraw ⚠️ ~10MB
PyPiTUI Differential ~100KB

Only changed lines redraw. No alternate screen buffer—your content flows into normal terminal scrollback.

Install

pip install pypitui
# pip install pypitui[rich]  # Optional: markdown, tables

Requires Python 3.12+.

Quick Start

from pypitui import (
    TUI, Container, Text, Input, SelectList, SelectItem,
    BorderedBox, ProcessTerminal, Key, matches_key
)

class App:
    def __init__(self):
        self.terminal = ProcessTerminal()
        self.tui = TUI(self.terminal)
        self.root = Container()
        self.tui.add_child(self.root)
        self.show_form()

    def show_form(self):
        """Compose a form from multiple components."""
        self.root.children.clear()

        # Container composes children vertically
        self.root.add_child(Text("User Registration"))
        self.root.add_child(Text("─" * 30))

        # Input with validation
        name_input = Input(placeholder="Enter username", max_length=20)
        self.root.add_child(name_input)

        # Another input
        email_input = Input(placeholder="Enter email")
        self.root.add_child(email_input)

        # Bordered box containing a list
        box = BorderedBox(title="Select Role")
        roles = SelectList([
            SelectItem("admin", "Administrator"),
            SelectItem("user", "Standard User"),
        ], max_visible=3)
        box.add_child(roles)
        self.root.add_child(box)

        self.tui.set_focus(name_input)

    def run(self):
        self.running = True
        self.tui.start()
        try:
            while self.running:
                data = self.terminal.read_sequence(timeout=0.05)
                if data and matches_key(data, Key.ctrl("c")):
                    break
                self.tui.handle_input(data)
                self.tui.request_render()
                self.tui.render_frame()
        finally:
            self.tui.stop()

App().run()

Components

  • Text — Multi-line text with wrapping
  • Input — Text input with cursor, validation
  • SelectList — Interactive selection with filtering
  • BorderedBox — Panel with borders and title
  • Container — Groups components vertically
  • OverlayOptions — Floating dialogs and modals

Critical Pattern: Reuse the TUI

Wrong: Creating new TUI instances breaks differential rendering.

# ❌ DON'T
def switch_screen():
    return TUI(terminal)  # Loses state!

Right: Clear containers, not the TUI.

# ✅ DO
class App:
    def __init__(self):
        self.tui = TUI(terminal)  # Create once
        self.root = Container()
        self.tui.add_child(self.root)

    def switch_screen(self):
        self.root.children.clear()  # Clear container
        self.root.add_child(Text("New Screen"))

Component Invalidation

When a component changes (e.g., a completion menu closes), you can invalidate just that component instead of redrawing everything.

Direct approach (with TUI reference):

# Clear only the input field's lines from the cache
tui.invalidate_component(input_field)

Bubble-up approach (no TUI reference needed):

# Component invalidates itself, bubbles up to TUI automatically
input_field.invalidate()

How it works:

  1. Component.invalidate() clears local cache and calls _child_invalidated(self)
  2. _child_invalidated() bubbles up through parent containers
  3. TUI._child_invalidated() receives the call and invokes invalidate_component()
  4. TUI.invalidate_component() clears only that component's lines from _previous_lines
  5. Next render redraws only the changed lines

Use case: Completion menu

class CompletionAddon:
    def __init__(self, input_field: Input):
        self.input_field = input_field

    def on_menu_close(self):
        # Only the input field (and its completion dropdown) gets cleared
        self.input_field.invalidate()

Rich Integration

Optional Rich support for markdown and tables:

from pypitui.rich_components import Markdown, RichTable, RichText

tui.add_child(Markdown("# Hello\n\n**Bold** text"))

Development

git clone https://github.com/jeremysball/pypitui.git
cd pypitui && uv sync --extra dev

# Install pre-commit hooks (runs ruff, mypy, pytest)
git config core.hooksPath .githooks

uv run python examples/demo.py

API Reference

Import Purpose
TUI Main controller
Container, Text, Input, SelectList, BorderedBox Components
OverlayOptions, OverlayMargin Overlay positioning
Key, matches_key, parse_key Keyboard handling
ProcessTerminal, MockTerminal Terminal I/O

Full docs: LLMS.md

License

MIT — see LICENSE.


Inspired by pi-tui

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

pypitui-1.0.0.tar.gz (35.0 MB view details)

Uploaded Source

Built Distribution

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

pypitui-1.0.0-py3-none-any.whl (66.0 kB view details)

Uploaded Python 3

File details

Details for the file pypitui-1.0.0.tar.gz.

File metadata

  • Download URL: pypitui-1.0.0.tar.gz
  • Upload date:
  • Size: 35.0 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for pypitui-1.0.0.tar.gz
Algorithm Hash digest
SHA256 a566e0637972e9bf1ff871efd6ddf5f9ee1ad95c717ad130d11f927728823264
MD5 4cb2e042c491741baf913e79d5757108
BLAKE2b-256 26d2bf92b09ebffaec218fc6fbe944fd9fb95b352f9e058e948e9011c8d039a3

See more details on using hashes here.

Provenance

The following attestation bundles were made for pypitui-1.0.0.tar.gz:

Publisher: release.yml on jeremysball/pypitui

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pypitui-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: pypitui-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 66.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for pypitui-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0627d73613005ad6082290302007626b5e4eb1a8638bbe1aa0a410360c8236ce
MD5 8079ee4760dc00f218a63c16f46b4274
BLAKE2b-256 046b50f5a3c197107644cd2a5df97e9958dd1c24cafa5d2c4a25bbad3835a22e

See more details on using hashes here.

Provenance

The following attestation bundles were made for pypitui-1.0.0-py3-none-any.whl:

Publisher: release.yml on jeremysball/pypitui

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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