Skip to main content

motui

ci pypi python license

A layered, full-screen terminal UI toolkit with pure values at the bottom and a component tree on top. You describe a document of addressable sections; motui lays them out, scrolls them, and paints only the rows that changed on the terminal's alternate screen. It ships parts, not an application: a component library, a default interaction policy, and the runtime beneath them.

  • Values first. Geometry, rows, surfaces, styles, scroll values and selection are frozen values in motui.kernel, with no terminal I/O.
  • One render method. Every component answers render(Constraints) -> Surface; nothing else is promised, so a pure renderer never has to inherit a base class.
  • Explicit interaction. Widgets declare capabilities (focusable, clickable, hoverable, activatable) and override typed hooks (on_key, on_click, on_action, ...). There is no untyped handle fallback.
  • Explicit tree. Widget.parent, Widget.children, walk() and touch() replace attribute sniffing and manual invalidation.

Runtime dependency: wcwidth only. Python 3.12+.

Install

pip install motui      # or: uv add motui

The wheel carries its own demo, so python -m motui works right away.

Quickstart

The smallest app: a Text row and an Input, painted once headless. The runnable copy is examples/quickstart.py.

from motui import App, Input, Text
from motui.terminal import HeadlessScreen, TerminalOptions

app = App(terminal=TerminalOptions(screen=HeadlessScreen(60, 6)), auto=False)
app.add(Text("Hello, motui."))
app.add(Input("type here"))
app.paint_now()
for row in app.workspace.rows[:3]:
    print(row.text)

What each piece did:

Piece Job
App composes the runtime, the workspace and the terminal; run() owns the loop
TerminalOptions the one terminal configuration: stream, input, mouse mode, paste, focus events, color mode, sync and the headless screen
app.add(component) append a section and answer the Section the caller keeps
app.workspace.rows the painted rows; app.workspace owns the frame and its content
app.focus(target) focus a section id or a mounted component; tab cycles the tree-derived ring
app.bind(key, callback) run a callback after the focused control and the default tiers decline the key
render(Constraints) -> Surface a component's only rendering obligation
take() read a one-shot widget result; the read clears it

Capability matrix

Goal What ships Where it runs
Playable game Canvas cell grid, Sizing.FILL, app.every, app.bind python -m motui --scene snake
Live dashboard Sparkline, ProgressBar, Table, App.every/invalidate python -m motui --scene dashboard
Wide table Table(cell_wrap=WrapMode.OVERFLOW) inside ScrollPanel(axis="x") examples/wide_table.py
Two-axis code view WrapMode.OVERFLOW inside ScrollPanel(axis="both") examples/code.py
Streaming document Markdown.text += chunk, section generation cache, ScrollPanel below
Split IDE split(Axis.X), dock(placement=Placement.BOTTOM), Panel, StatusLine ARCHITECTURE
Forms and dialogs Form/Field, Input/Editor, Dialog through App.open_modal WIDGETS

The three shipped examples are quickstart, code and wide_table. The interactive demo is one page with five scenes: a splash that cycles Neon, Plasma, Retro CRT and Starfield until a key, a menu, Snake, Dashboard and a component Gallery.

Wide content is the one horizontal-scroll case: ScrollPanel(axis="x" or "both") windows a component that may render wider than its constraints (WrapMode.OVERFLOW). Document-flow horizontal scrolling is a 2.x goal.

The five concepts

1. Kernel values. Rect, Size, Pos and Axis are geometry. Cell, Segment, Line and Surface are the row model. Constraints, Scroll, ScrollAxis and ScreenLayout are layout. Section, Document, Placement and Sizing are the document model. Style, Theme, Styles, Glyphs and Border are styling. The data values are frozen; Section and Document manage identity and order. All live in motui.kernel; the package imports no os, sys, io, ctypes, termios, tty, select or asyncio.

from motui.kernel import Constraints, Line, Surface, WrapMode

row = Line.from_text("hello").clip(WrapMode.ELLIPSIS, 4)  # "hel..."
surface = Surface.of([row], width=4)                       # clipped, then padded

2. Constraints and Surface. A component is asked for a width and an optional height budget, and answers rows at one content width.

from motui.kernel import Constraints, Line, Surface


class Banner:
    def render(self, c: Constraints) -> Surface:
        rows = [Line.from_text("motui").clip("ellipsis", c.width)]
        if c.max_height is not None:
            rows = rows[: c.max_height]
        return Surface.clipped(rows, c.width)

Surface.of guarantees every row is at most width columns and pads short rows; Surface.clipped keeps short rows short (the byte-stable builder). OVERFLOW is the one wrap mode that may return a row wider than the limit; a container clips it at its edge.

3. Widgets and hooks. Widget adds the explicit tree, automatic invalidation and no-op interaction hooks. A subclass overrides only what it takes and returns True when it consumed the input.

from motui.kernel import Constraints, Line, Surface
from motui.widgets import Widget


class Counter(Widget):
    focusable = True
    activatable = True
    keymap = {"enter": "activate", "space": "activate"}

    def __init__(self) -> None:
        super().__init__()
        self.count = 0

    def on_action(self, name: str) -> bool:
        if name != "activate":
            return False
        self.count += 1          # the setter calls touch()
        return True

    def render(self, c: Constraints) -> Surface:
        return Surface.of([Line.from_text(f"count: {self.count}")], c.width)

Assigning any public attribute calls touch(), which bumps this widget's generation and every ancestor's. Mutating a list in place cannot be seen; call touch() yourself in that case.

4. Sections. Document holds Section values: a stable id, a widget, a Placement (FLOW, TOP, BOTTOM) and a Sizing (NATURAL, FILL). app.add(...) and workspace.add(...) answer the section.

from motui import App, Text
from motui.kernel.document import Placement, Sizing

app = App()
header = app.add(Text("header"), placement=Placement.TOP)
canvas = app.add(game, sizing=Sizing.FILL)   # or: app.fill(game)

At most one FILL section is allowed, it must be in the flow, and no flow section may follow it. In this release Fill is the document form (sizing=Sizing.FILL), not a separate pane-content union.

5. Terminal options. App(terminal=TerminalOptions(...)) is the one terminal configuration. MouseMode is OFF, BUTTON, DRAG or MOVE; color_mode="auto" probes the environment once; screen= is the testing/headless escape hatch.

from motui import App
from motui.terminal import MouseMode, TerminalOptions

app = App(terminal=TerminalOptions(mouse=MouseMode.DRAG, paste=True))

There are no separate mouse=, hover=, screen= or keys= arguments on App; every switch lives in TerminalOptions.

The 60-second pipeline

terminal bytes --KeyDecoder--> Event --Router (DEFAULT_HANDLERS)--> palette /
                                                      modal / find / toasts /
                                                      controllers / wheel /
                                                      drag / mouse / focus /
                                                      page keys / bindings /
                                                      host hooks
                                                            |
                         Component.render(Constraints) -> Surface
                                                            |
                              Section.rows (generation cache) -> Viewport.layout
                                                            |
                                               ScreenLayout (rows + ownership)
                                                            |
                                  Frame.compose (panes, docks, chrome, overlays)
                                                            |
                                  Painter.paint (per-row diff, one flush)
                                                            v
                                                          Screen

An unchanged frame writes zero bytes. A non-live Screen keeps the logical rows and stays silent, which is how piped runs and tests work.

Components

The root is a curated 40-name import line. Everything else is public at its own layer:

from motui import App, Input, Markdown, Table          # the curated root
from motui.widgets import Canvas, Panel, ScrollPanel   # the complete library
from motui.kernel import Style, Line, Selection        # the kernel seam
from motui.kernel import DEFAULT_INPUT_KEYMAP          # decoder and keymaps
from motui.app import Runtime, Workspace, Scrollbar    # runtime and controllers
from motui.terminal import TerminalOptions, Screen     # terminal seam
from motui.testing import Emulator, headless_tui       # test helpers

The component families are text (Text, Markdown, Diff, Notice), forms (Input, Editor, Checkbox, RadioGroup, CheckboxGroup, Select, MultiSelect, ComboBox, Autocomplete, Slider, Form, Field, Option), data (List, Table, Tree, Tabs, NavList, ProgressBar, Spinner), layout (Row, Column, Padding, Panel, Divider, Spacer, Collapsible, ScrollPanel), actions (Button, Link, MenuBar, ContextMenu, ShortcutBar, StatusLine), overlays (Dialog, ButtonSpec, confirm_dialog, prompt_dialog) and drawing (Canvas, Sparkline).

Every themed widget takes theme=; components that resolve role groups take one styles: Styles override, while Text takes one explicit style=. Widgets resolve their role groups when they render, so assigning app.theme repaints a mounted UI without rebuilding it. See WIDGETS for the per-component catalogue.

Runtime and controllers

App composes Runtime, Workspace and Terminal and owns the default interaction policy. Runtime owns the session: terminal, pump, timers, painter and run lifecycle. Workspace owns what is on screen: document, frame, panes, docks, hit testing and selection. With auto=False, App mounts no controllers and the caller drives the runtime directly.

The dispatch order is public and first-claim-wins; App.handlers is DEFAULT_HANDLERS (in motui/app/router.py) unless the host passes its own tuple:

palette -> modal -> find -> toasts -> mounted controllers
        -> wheel -> drag -> mouse (gesture, pointer, pane focus)
        -> focused widget -> page keys -> bindings -> host hooks

Controllers are ordinary objects: FocusManager, Pointer, SelectionGesture, Scrollbar, Toasts, Tooltip, Stats, Find, Modal and CommandPalette. A controller is hosted on the workspace (it owns the frame and the hit test); a host controller joins through app.mount(controller).

from motui import App, Dialog, Text
from motui.app import Scrollbar


class Shell(App):
    def on_start(self) -> None:
        self.mount(Scrollbar(self.workspace))
        self.bind("c-k", self.ask)

    def ask(self) -> None:
        self.open_modal(Dialog("Confirm", Text("Continue?"), ["Yes", "No"]))


Shell().run()

Commands are values plus a registry that binds their keys:

from motui.app import Command, CommandRegistry

registry = CommandRegistry()
registry.add(Command(
    name="theme.next",
    group="Appearance",
    description="Switch theme",
    keys=("c-t",),
    handler=self.next_theme,
))
registry.bind(app)              # keys -> invoke(name)
app.open_palette(registry)      # the palette renders the registry

Layout patterns

Split and dock use enums, not direction strings:

from motui import App, Editor, Text
from motui.kernel import Axis
from motui.kernel.document import Placement
from motui.widgets import StatusLine


class EditorApp(App):
    def on_start(self) -> None:
        left, right = self.split(Axis.X, weights=(1, 2))
        self.add(Text("# notes"), pane=left)
        self.dock(StatusLine(left=("ready",)), placement=Placement.BOTTOM)
        self.add(Editor("hello"), pane=right)

Streaming Markdown is a plain attribute assignment; the assignment calls touch(), the section cache sees the new generation, and only the changed rows repaint:

from motui import Markdown

md = Markdown("# Live\n\nA paragraph arrives ")
md.text += "in pieces, with **bold** and a [link](https://example.com)."
app.add(md)

A wide table is a non-wrapping table in a horizontal panel:

from motui import ScrollPanel, Table
from motui.kernel.text import WrapMode

table = Table(headers, rows, cell_wrap=WrapMode.OVERFLOW)
view = ScrollPanel(table, axis="x", height=8, bar=True)
app.add(view)

Styling

Style is frozen and hashable; fg/bg accept a named 16-color, a #rrggbb string, an (r, g, b) tuple, or a Color that pairs truecolor with an ANSI anchor. Theme is ten frozen role groups (text, surfaces, borders, controls, rows, status, content, chrome, overlays, syntax). DEFAULT_THEME is the Ink & Signal dark theme and equals Theme() and Theme.dark().

from dataclasses import replace

from motui import DEFAULT_THEME, List, Style

theme = replace(
    DEFAULT_THEME,
    content=replace(DEFAULT_THEME.content, accent=Style(fg="#ff8800")),
    rows=replace(DEFAULT_THEME.rows, active=Style(bg="#264f78")),
)

app.add(List(["one", "two"]))
app.theme = theme       # repaints mounted widgets whose theme is still None

A brand theme derives every role from one ColorPalette; Theme.light(), Theme.flat() and Theme.auto(env) are the shipped alternatives. terminal.auto_theme() is the convenience that reads os.environ and returns a theme.

Structural marks are separate values: Glyphs (GLYPHS) holds cursors, prompts, folds, checks, bars and notice marks; Border holds one box style. Every carrying component takes glyphs=...; Panel, Dialog and Table also take a Border. Glyphs are content, not style: a theme change never moves geometry.

Keys

KeyDecoder is a pure bytes -> events machine. Editing keys route through a plain dict keymap:

from motui import Input
from motui.kernel import DEFAULT_INPUT_KEYMAP

field = Input(keymap={**DEFAULT_INPUT_KEYMAP, "c-j": "clear"})
del field.keymap["up"]      # an unbound key goes back to the host

DEFAULT_INPUT_KEYMAP leaves enter unbound, so submission stays the host's. DEFAULT_EDITOR_KEYMAP adds enter as newline; EDITOR_SUBMIT_KEYMAP hands return back and binds c-j to newline.

Results and the tree

State attributes (selected, checked, text, open) can be read at any time. take() is the one-shot door: it returns the pending result and clears it, so None means "nothing pending" and False is a real result. Collect results with walk():

from motui.widgets.base import HasResult

for widget in app.walk():
    if isinstance(widget, HasResult):
        value = widget.take()
        if value is not None:
            handle(value)

There is no per-widget result callback; see ADR-023.

Testing

headless_tui returns a Harness: one flat test driver over a real Runtime (recording screen, scripted event source) and its Workspace. Content members resolve to the workspace, session members to the runtime; tui.runtime and tui.workspace reach the two objects directly. snapshot renders the document as structured plain text. Emulator rebuilds the cell grid the painter wrote, so assertions can be on what a terminal would show rather than on escape spelling.

from motui import Text, headless_tui
from motui.testing import Emulator

tui = headless_tui(40, 10)          # a Harness
tui.add(Text("hello"))              # workspace.add
tui.paint()                         # runtime.paint

assert "hello" in "\n".join(row.text for row in tui.rows)

screen = Emulator(40, 10)
screen.feed(tui.screen.output)
assert screen.cell(0, 0).char == "h"

The library never sleeps. Time enters through Runtime.clock() and Runtime.advance(dt); App.every and App.animation schedule against deadlines rather than accumulating drift.

The demo

python -m motui starts the five-scene showcase that ships in the wheel: a persistent MOTUI splash (Neon / Plasma / Retro CRT / Starfield, until any key), menu, Snake, Dashboard and Component Gallery. No network, no model, no assets beyond the package.

python -m motui                          # a real terminal: the splash and menu
python -m motui --scene gallery          # start on one scene
python -m motui --headless --frames 60   # deterministic text frame, no TTY
python -m motui < /dev/null              # piped: print the menu frame, exit 0
python -m motui --pages                  # all five scenes as plain text

In the live menu, arrows and enter (or a click) pick Snake, Dashboard, Gallery or Quit; esc returns to the splash. Snake plays with the arrow keys or WASD, space pauses, and q or esc returns to the menu. The Gallery keeps a real Tree, Table, ProgressBar, Slider and Input side by side; tab cycles their focus.

Docs

  • ARCHITECTURE - layers, dependency rules, object model, lifecycle, dispatch order and extension points.
  • CONTRACT - the normative values, protocols and invariants the implementation and tests are written against.
  • WIDGETS - every component: construction, capabilities, hooks and take().
  • MIGRATION - the 1.x to 2.0 name table, recipes and the removal list.
  • ADRs - the 23 accepted architecture decisions.
  • CONTRIBUTING - the gates, the commit style and the release process.
  • CHANGELOG - what changed between releases.

Source: Shingwha/motui. Issues and pull requests go to the tracker.

License

MIT - see LICENSE.

Metadata

Release files for motui 2.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for motui 2.0.0
File Size Uploaded Provenance
motui-2.0.0.tar.gz 686.7 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for motui 2.0.0
File Interpreter ABI Platform
motui-2.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.2 MB

Release files / motui-2.0.0.tar.gz

Download URL motui-2.0.0.tar.gz
Size 686.7 kB
Tags Source
SHA-256 checksum
How to use checksums
f8b2b3879fd729f8ad29c770b73ad643c23ed16134723edb25529e0c737875ac
BLAKE2b-256 checksum
How to use checksums
ee321332055524bd7022d4682796fbaf69f6f6f8310891f69aefc1e89991ec8c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.

Transparency log

Release files / motui-2.0.0-py3-none-any.whl

Download URL motui-2.0.0-py3-none-any.whl
Size 493.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c812f477a48cddd8ca911a30d7bec2be3d66968be44ba543c2f982362468db28
BLAKE2b-256 checksum
How to use checksums
ad85204d0854d6e3816f074f4b20160a91cbf30456108a3f79da315c2dafa560
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.

Transparency log

Security for motui 2.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for motui-2.0.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page