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-0.3.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-0.3.0-py3-none-any.whl (65.1 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: pypitui-0.3.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-0.3.0.tar.gz
Algorithm Hash digest
SHA256 fef1bdf16f04dcf550b329b92a043a62405476155a05e8356edba14f2da309b0
MD5 42a442d3ec72518df24d4c851d949002
BLAKE2b-256 c47d4b3173af98ace99b0e5516f0aa4467610496acd11766e9610dc9a1e6d576

See more details on using hashes here.

Provenance

The following attestation bundles were made for pypitui-0.3.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-0.3.0-py3-none-any.whl.

File metadata

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

File hashes

Hashes for pypitui-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 92658885041c688423f87d44ad3bba8906b4ce6fb858ccac7375d543dc6d9a9d
MD5 3edbed13de11445bb71678b315cb506a
BLAKE2b-256 76e3694b7542a65e0a188b6f97617d5ea66195d68c3ce9d627dafe0a1114d2f3

See more details on using hashes here.

Provenance

The following attestation bundles were made for pypitui-0.3.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