motui
A layered, full-screen TUI toolkit. It takes over the terminal on the
alternate screen, renders a document of addressable blocks one diffed row at
a time, owns its own scrollback, and keeps pinned bands (fixed top/bottom
regions, the frame's footer band) that never scroll away. Any Python program
can build its own components and screens on it: the library ships parts, not
an application. Runtime dependency: wcwidth only. Python 3.12+.
- Install
- Quickstart
- The import contract
- Features
- The demo
- Building your own shell
- Chrome, panes, overlays
- Styling: Theme
- Keys and keymaps
- The driver at a glance
- Package layout
- Components
- Testing without a terminal
- Docs
- License
Install
pip install motui # or: uv add motui
The wheel carries its own demo, so python -m motui is one command away.
Quickstart
A minimal interactive app: a Button and a Checkbox (the minimal action
primitives), a FocusRing for the keyboard and a Pointer for the mouse.
Run it on a real terminal, or drive it headless: the same body replays
scripted events.
import asyncio
from motui import Button, Checkbox, FocusRing, Key, MouseEvent, Pointer, Text, TUI
async def session(tui):
ship = Button("Ship")
tui.add(ship, id="ship") # a one-row action
trace = Checkbox("Trace")
tui.add(trace, id="trace") # a one-row toggle
ring = FocusRing([ship, trace]) # tab cycles, enter activates
pointer = Pointer(tui, ring=ring) # hover + stationary clicks
while True:
event = await tui.read_key()
if event is None or (isinstance(event, Key) and event.name == "c-c"):
break # EOF, or the quit gesture
if isinstance(event, MouseEvent):
if pointer.handle(event): # the hover moved, or a click landed
tui.invalidate()
elif isinstance(event, Key) and event.name == "tab":
ring.focus_next() # the host owns the cycle key
elif not ring.handle(event):
... # the host's own dispatch
for widget in (ship, trace):
if widget.chosen is not None: # read each chosen once
tui.add(Text(f"{widget.label}: {widget.chosen}"))
widget.chosen = None
tui.stop()
asyncio.run(TUI(hover=True).run(session)) # 1003: motion reaches the pointer
tests/test_readme_examples.py runs exactly this snippet headless — the
scripted source activates, clicks and hovers — so the docs cannot rot.
Selection is one dispatch line, and it composes the way a bracketed Paste
does: reports and pastes both arrive through read_key(), both belong to the
host's dispatch, and neither is interpreted by the driver — the terminal
brackets the paste, the gesture owns the drag. While a drag is in flight the
left button is the gesture's; a drag to the viewport's edge row auto-scrolls
across pages (the pinned footer never scrolls), release copies the extracted
range through OSC 52, and a plain click clears without touching the
clipboard. The knobs, the state machine and the four replacement points (its
own set_selection loop, its own on_end, the sub-gesture knobs, the
highlight style) are in docs/COMPONENTS.md; the
rationale is in docs/DESIGN.md.
The import contract
The root is the curated face: 31 names in one import line. Every other name stays public — one name, one home — at its own layer namespace:
from motui import TUI, Input, Markdown # the root: 31 names, one line
from motui.widgets import Row, Panel, Diff # the widget library, complete
from motui.kernel import Style, Line, Selection # the kernel seam
from motui.keys import DEFAULT_INPUT_KEYMAP
from motui.interaction import FocusRing, Pointer, SelectionGesture, Find
from motui.chrome import Scrollbar, Toasts
The layers:
kernel— the kernel and the root of the dependency graph: theLine/Segmentrow primitive,Style/Theme, the event model, the document model, the render core (screen / viewport / painter), the frame model (Pane,Rect, the split algebra, the compositor), the interaction protocols (Clickable,Activable,Focusable,Hoverable,Container,descend) and the selection kernel (Selection,highlight,extract).keys— the byte decoder, the key sources, and the three keymap tables (DEFAULT_INPUT_KEYMAP,DEFAULT_EDITOR_KEYMAP,EDITOR_SUBMIT_KEYMAP)Input/Editorconsume.widgets— the generic component library (Text,Input,Markdown,Table,Tree, …), the minimal action primitives (Button,Checkbox,Link), the two-columnStatusLineand the self-containedPalette, plus thewidgets.markdownrule registries and thewidgets.syntaxhighlighting seam.interaction— the four strategy tiers a host mounts on the driver: the mouse-selection policy tier (SelectionGesture,copy_on_select), the pointer tier (Pointer— hover tracking and stationary clicks beside the gesture), the focus tier (FocusRing— registered targets, the cycle, the routing table and the click policy) and the search tier (Find— the query row it owns, the matches and the reveal).app— theTUIdriver, the composition root: blocks, panes, the frame, the pump and the repaint throttle.chrome— the frame-chrome and overlay assembly tier (Scrollbar,Toasts,Tooltip,Stats), one level above the kernel.testing—headless_tuiandsnapshot.
The root's 31 names are the driver (TUI), the styling trio (Theme,
DEFAULT_THEME, Style), the row primitive (Line, Segment) plus Text,
the event trio (Key, MouseEvent, Paste), the content, editing, data,
selection and action components an application reaches for first, the wiring
entry points of the interaction and chrome tiers, and the two headless
helpers. Everything else is one import deeper:
| Import from | What lives there |
|---|---|
motui |
the curated surface, 31 names: TUI, Scrollbar / Toasts, the components a host starts with (Button / Checkbox / Link, Input / Editor, Palette / StatusLine, Table / Tabs / Tree / List / Markdown / Option / Select), the styling trio (Theme / DEFAULT_THEME / Style), the row primitive (Line / Segment / Text), the event trio (Key / MouseEvent / Paste), the interaction entry points (SelectionGesture / Pointer / FocusRing) and the headless pair (headless_tui / snapshot) |
motui.kernel |
the kernel facade: Painter, Viewport, ScreenLayout, Document, BlockState, the interaction protocols (Clickable, Activable, Focusable, Hoverable, Container, descend), the inline-action lookup (action_at), MORE_BELOW, Text, ellipsize_tail, wrap_line, the selection kernel (Selection, highlight, extract), the capability switches (the screens' focus_events / color_mode, Painter(sync=...)), the kernel.ansi escape helpers |
motui.widgets |
the component library's own facade: the full catalogue including the names the root leaves off (Diff, Panel, Row, ProgressBar, Radio, Spinner, …), each defined in its own module (widgets.table, widgets.list, widgets.editor, …) |
motui.kernel.frame |
the frame model below the driver's face: Rect, Split, Chrome, Frame, FrameTable, Overlay, splice_row |
motui.kernel.lines |
wrap_line (also on the kernel facade) and styled_with, the uncached row rendering for a downgraded color mode |
motui.interaction |
the four strategy tiers: the selection policy (SelectionGesture — press/drag/release/edge auto-scroll/click count: word, then line — and copy_on_select, the shipped on_end default), the pointer tier (Pointer — hover tracking, a button-named motion is a drag and stays the gesture's, the button-free one moves the hover, row-level hover_at dispatch, stationary clicks through the ring or to the deepest clickable leaf), the search tier (Find — the query row it owns, the matches a query addresses as (bid, row, start, end), the wrapping cursor, the reveal of the current match and the overlay that marks them) and the focus tier (FocusRing — registered targets, focus/focus_next cycling, the handle routing table, the click policy) |
motui.chrome |
the frame-chrome assembly tier: Scrollbar (chrome + gesture), Toasts (overlay + controller), Tooltip (the hovered leaf's hint, painted beside the pointer) and Stats (the dev readout: frames and rate, counted events, the hover) — Scrollbar and Toasts also sit on the root, Tooltip and Stats do not |
motui.app |
the driver's own module face: TUI, Hit (the block-internal cell a click resolves to) and EveryHandle (a cancellable tui.every callback) |
motui.kernel.style |
Style, the named-16 FG_CODES, the palette constants not on the root (BORDER, CODE, CURSOR, SELECTION, HEADING, LINK, INLINE_CODE, LIST_MARKER, NONE), and the color-capability functions (ColorMode, detect_color_mode, sgr_prefix, quantize) |
motui.keys |
KeyDecoder, RawTerminalKeys, Keymap and the three default tables (DEFAULT_INPUT_KEYMAP, DEFAULT_EDITOR_KEYMAP, EDITOR_SUBMIT_KEYMAP) — the event model itself (Key, Paste, MouseEvent, KeySource, ScriptedKeys) lives in kernel.events and its value types are on the root |
motui.widgets.markdown |
BlockRule / InlineRule / RenderContext, BLOCK_RULES / INLINE_RULES, default_block_rules, default_inline_rules, every built-in rule class |
motui.widgets.syntax |
Highlighter, RegexHighlighter, default_highlighter, highlight |
Features
- alt screen — standard vim/htop-style full screen; the shell is restored on exit.
- viewport scrollback — the whole document stays resident in memory; the screen is a window, the page keys (
page_up/page_down) scroll back. - mouse — the wheel scrolls (SGR 1006); presses, releases and motion reports decode into 0-based
MouseEvents (each carryingkind) andtui.hit_test(x, y)resolves one to the block under it — the host dispatches clicks, never the driver. - mouse selection & copy — one
SelectionGesture(tui)line: drag selects (document-space addresses, so the highlight follows its text through scrolls and the copied range reaches rows scrolled out of view), a hold on the viewport's edge row auto-scrolls across pages, release copies through OSC 52 (best-effort; theon_endhook is the real contract). Repeated presses at one cell count: the second selects the word under the pointer, the third its whole visible line, the fourth starts over. Enabling mouse reporting takes the terminal's native selection away — the same bargain bracketed paste made: the terminal hands over the bytes, the application owns the gesture. - hover —
Pointer(tui)watches the same reports: the leaf under the pointer takes itshoverflag (and row-levelhover_atwhere a component has rows), a press and release on one cell is a stationary click, and a drag's motion stays the gesture's — a motion that names a button is a drag, only the button-free one moves the hover. Hover needs DECSET 1003, so it is opt-in —TUI(hover=True)— and the default mode stays 1000+1002+1006. - frame chrome — one
Scrollbar(tui)line budgets a column at the pane's edge and maps the scroll onto it (press to page, drag the thumb); the bar is the reference chrome, and a custom rail is oneChromeprotocol away. - split panes —
tui.split("vertical")tiles the frame (transcript | preview): the first child keeps the document, a 1-cell tmux-convention divider between them, focus by press orc-o, and the wheel routed by pointer position. - toasts —
Toasts(tui)floats transient bands over the composed frame (a corner stack, TTL-bounded, click-to-dismiss), the reference overlay. - tooltip —
Tooltip(tui, pointer)paints the hovered leaf's ownhintas a one-line theme-toastband beside the pointer, clamped into the frame;Button/Checkbox/Linkdeclare the attribute, and a leaf without one shows nothing (zero bytes). - pinned tail — a scrolling region on top, fixed blocks (input / status) at the bottom. A pinned footer lives in the frame's bottom band: its own viewport, below the pane area, so a split never cuts it in half.
- pluggable keyboard — one decoder feeding scripted or raw terminal sources; editing keys route through configurable keymaps and enter
handle(event). - themeable — one frozen
Themecarries every semantic style; components resolve it once at construction anddataclasses.replacerestyles the whole toolkit at once. - markdown — a streaming-safe rule-registry engine in
widgets/markdown: GFM tables, fenced code, an inline/bold/strike/italic/link registry you extend with your own rules. - syntax highlighting — a zero-dependency
motui.widgets.syntax.Highlighterseam, wired into Markdown fences and injectable. - testable without a terminal —
headless_tui+snapshot.
The demo
python -m motui runs the library's own front page: a demo that ships in the
wheel — 11 pages under a left rail, the page content on the right, the
shell built from the same library it shows off. It answers locally: no
network, no model.
python -m motui # a real terminal: the full screen
python -m motui < /dev/null # piped: replays headless, dumps the final screen
python -m motui --pages # every page as plain text, no terminal needed
The frame is a split: the left pane's document carries a vertical Tabs
— one row per page — and the right pane carries the page's own document, so
the rail never scrolls with the content and both halves are ordinary panes
(own viewport, own scroll state, own hit domain). A click on a label opens
that page, c-n / c-p step to the next and the previous one (the ends
wrap), and q / c-c quit. The header band holds the window title per page
and the cursor shape; the pinned footer reads the shell's own key hints
(c-n/c-p pages · tab focus · c-o pane · c-f find · c-g stats · q, a
ShortcutBar) above a StatusLine that reads <page> · focus=<target> on
the left and the frame rate on the right.
There is no input box anywhere in the demo, and the shell has no /
command palette. Navigation is the rail's — one screen, visible and
clickable — and everything the palette used to carry has a direct key
(c-o pane focus, c-f find, c-g stats, q quit); a modal panel beside
the rail would be a second navigator that owns keys without owning a job.
The Palette component still ships and is still on a page: the chrome page
holds it as an exhibit — open it there and its own query row, its
filtering and its pick answer for themselves. c-f opens the search tier
(Find), the other self-contained query row: type and every match on the
page is marked on its own band, enter/↓ walk to the next match and ↑ to
the previous one (the current one centered into view), escape closes —
while it is open it owns the keyboard, so typing q searches for a q.
c-g mounts the dev stats overlay (Stats) in the frame's top-right
corner — frames and the window frame rate, the events this round has counted,
the hovered leaf — and the next c-g retires it.
Two roads lead to a page, plus the overview page's own index: a click
on a rail label (the host follows Tabs.active once per round, the same road
a click on a widget takes) and c-n/c-p. Every page answers the same three
questions with the same helpers: a header (title, blurb, rule), a TRY ▸
line saying what to do here, an API · note naming the real API behind the
exhibit, and labelled rules between the parts. The eleven pages:
overview— what the demo is, how it is organised, and a clickable index of the restcontent—Text,Markdown(GFM tables, fenced code, inline rules) andDiffdata—Table,ListandTree: a row cursor, a pick, the row under the pointerinput—Input,EditorandAutocomplete: click any cell and the block cursor lands therechoice—Select,Confirm,RadioandCheckbox: activate one and the page echoes itaction—Button,LinkandSliderlayout—Panel,Row,Column,Padding,Spacer,Divider, aCollapsiblethat folds onenter, andNoticefeedback—ProgressBar,Spinner,StatusLine,ToastsandTooltipchrome— split panes,Scrollbar,Stats, the selection copy, a live hit-test probe, and thePaletteexhibitstyles— a theme picker plus the themes color by color, style attributes and color forms: activate a row and the whole shell re-skinsstreaming— a fixture streamed block by block; a control bar (play / pause / step / replay / speed) drives the document
A page that can be played with mounts a FocusRing: tab cycles its
widgets, ↑↓ drive the focused widget's cursor and enter activates it. A
click focuses: the first click on a ring target focuses it, the second
activates it, and every widget that answers click(row, col) is dispatched
its cell either way. Hover is the pointer tier's — the demo opens
TUI(hover=True) (DECSET 1003), and the leaf under the pointer takes the
hover band; the action page is the plainest place to see it, three
primitives with three hovers. Nothing an activation produces goes unseen: the
host reads each page component's chosen once per event round and reports it
— a toast, plus the page's own echo line (data, choice, action,
feedback and styles each keep their last pick in one) — and a pick whose
value names a page opens that page (overview's index is the registry) while
one that names a theme variant re-skins the shell (styles). The pages stay
live: the focus, hover, click and pick paths are the real component APIs, not
screenshots.
The chrome page mounts a preview pane of its own: its enter hook opens
one more tui.split, its leave hook closes it on the way out, and c-o
moves the pane focus between the columns — panes, the pane focus and the
un-split, on the same frame the rail already divides.
Both no-terminal paths are dump-safe, and the streaming page is where
that shows: no beat and no pump clock run there, so the page is built
complete — --pages prints the whole fixture, and the replay builds it whole
on its way through, never a partial document. The piped run replays a
scripted session — every page through the rail, the rings driven by key, a
hover and a click, a drag and its copy, the wheel — and dumps the last frame
as plain text.
The demo is a demo, not a shell to copy — the library ships no app shell.
The recipe for building your own (a footer Document with an Editor and a
StatusLine, parked in the frame's bottom band) is the runnable snippet in
Building your own shell and the longer walkthrough
in docs/COMPONENTS.md.
Chrome, panes, overlays
Everything that draws outside the document flow is exactly one of three
claims on screen space — a pane (an exclusive rectangle owning its own
viewport), chrome (a budgeted band inside a pane's rectangle, re-wrapping
its content) or an overlay (a pure paint over the composed frame) — and
picking which one a feature is is the design step. Scrollbar is the
reference chrome, Toasts the reference overlay and Tooltip the minimal
one (the hovered leaf's hint, one band beside the pointer), Stats the
developer's readout (frames and rate, the counted events, the hover), Find
the search tier (its own query row, the matches, the reveal), and tui.split
the pane tier:
scrollbar = Scrollbar(tui) # chrome: budgets a column, maps the scroll
toasts = Toasts(tui) # overlay: corner-anchored transient bands
pointer = Pointer(tui) # hover + stationary clicks (TUI(hover=True))
tooltip = Tooltip(tui, pointer) # overlay: the hovered leaf's hint, no input
stats = Stats(tui, pointer=pointer) # overlay: frames, frame rate, events, hover
left, right = tui.split("vertical", ratios=(2, 1)) # panes: the first keeps the document
right.document.add(Markdown(text)) # ...the second opens over a fresh one
The dispatch order is one rule everywhere — chrome, then overlays, then the panes' content gestures, then the host's own dispatch — and it is geometric at every step: the bar's strip and a toast's band are derived from the frame's rectangles, so both route before a hit test runs (a press that reached a hit test first would anchor a selection on the text beneath them):
if not scrollbar.handle(event): # 1. chrome, first
if not toasts.handle(event): # 2. overlays, next
if not gesture.handle(event): # 3. content gestures
... # 4. the host's own dispatch
The wheel is nobody's chrome — read_key intercepts scroll_keys and routes
a wheel report to the pane under the pointer. The sketches, the two
"build your own" walkthroughs (a custom 2-column rail reading
offset/total_rows; a custom overlay on the registry protocol) and the full
dispatch contract are in
docs/COMPONENTS.md; the three-space-claims
constitution, the compositor, budget negotiation and the retile are in
docs/DESIGN.md.
Package layout
src/motui/
__init__.py # the curated public API: 31 names, one import line
app.py # the TUI driver (the composition root)
chrome.py testing.py # the frame-chrome assembly + the headless helpers
interaction/ # the four strategy tiers, one module each
focus.py pointer.py # the focus ring + the pointer tier (hover, stationary clicks)
gesture.py search.py # the selection policy tier + the search tier (Find)
kernel/ # the kernel
__init__.py # kernel facade
frame.py # Rect + Split: the split algebra + the compositor
style.py theme.py # Style + Theme (+ DEFAULT_THEME) and the Themed base
text.py lines.py ansi.py # measuring + Text, rows, escape bytes
events.py # Key/Paste/MouseEvent + the source protocol
block.py # Component / Block / Document (+ the interaction protocols)
screen.py viewport.py painter.py # the render core
selection.py # the document-space selection kernel
keys/ # input
__init__.py # keys facade
decoder.py # the Key/Paste/MouseEvent byte decoder
keymap.py # Keymap + the three default tables
sources.py console.py # the raw source, Windows plumbing
widgets/ # the generic component library
__init__.py # widgets facade
markdown/ # the Markdown rule-registry engine
inline.py blocks.py # the inline rules + the block rules & loop
nested.py tables.py # quote/list rules, the GFM table
context.py component.py # RenderContext + the Markdown component
table.py spinner.py decor.py textbuf.py
field.py input.py editor.py # the shared text-field shell + the two public fields
button.py checkbox.py link.py # the action primitives
choice.py syntax.py # Select/Confirm/Option, the highlighter seam
statusline.py palette.py # the two-column status row + the command palette
autocomplete.py progress.py tabs.py tree.py layout.py
demo/ # the shipped demo behind `python -m motui`
__main__.py # the module entry point
shell.py session.py # the shell (rail, header, footer) + the host loop
registry.py script.py # the page table + the headless replay
pages/ fixture.md # one page builder per file + the streaming source
from motui import … is the one face an application needs. app.py,
chrome.py, testing.py and the interaction/ tiers sit above the kernel
because they compose it with the keys layer; each facade (motui.kernel,
.keys, .widgets, .interaction, .app, .chrome, .testing) is
complete for its own layer, and tests/test_api_root.py pins every one — the
root and every layer facade.
Components
Every component renders through the frozen render(width) -> list[Line]
protocol, and the interactive ones carry controller methods beside it:
handle(event) -> bool (Input / Editor / Palette),
click(row, col) -> bool (the text fields, the lists and the tab strip),
move(delta) /
activate() / select(index) — the menus wrap their ends, the tab strip
and tree clamp, and a menu's activation lands the picked option in
chosen. The focusable ones carry one more flag: focused: bool (the
text fields draw their block cursor by it; the lists, the tree and the tab
strip render an unfocused cursor row in their cursor style merged with the
theme's dim) — FocusRing sets it as the focus moves, and the component
never learns a manager exists. The pointer-driven ones carry hover: bool
and, where a component has rows, hover_at(row, col) -> bool (row < 0
means the pointer left) — Pointer writes both, the component only renders
by them — while the wrapping containers translate a cell for their children
through child_at(row, col) -> (component, row, col) | None: a click
through a Row / Padding / Panel reaches the leaf
(tui.hit_component(x, y) resolves it), and a container never proxies the
leaf's own methods.
Styles resolve once, at construction, as explicit style= > theme=
field > DEFAULT_THEME. The 31 most used import straight from motui; the
rest are one import away from motui.widgets — the catalogue, the
capability tables and the build-your-own walkthroughs are in
docs/COMPONENTS.md.
Styling: Theme
Style is frozen and hashable (fg/bg accept named-16, #rrggbb or
(r, g, b) truecolor); Theme is the frozen dataclass that carries every
semantic default. Build a variant with dataclasses.replace and hand it to
the components you construct — nothing global is mutated:
from dataclasses import replace
from motui import DEFAULT_THEME, Markdown, StatusLine, Style, Text
from motui.kernel import ACCENT
theme = replace(DEFAULT_THEME, accent=Style(fg="#ff8800"), heading=Style(fg="#ff8800"))
Text("hello", theme=theme) # theme field wins over DEFAULT_THEME
Markdown("# H", theme=theme).render(80)
StatusLine(left=("ready",), theme=theme) # the two-column row follows the theme
Text("still the default", style=ACCENT) # an explicit style always wins
| Theme field | Default | Used for |
|---|---|---|
dim muted accent success error warning border code cursor selection |
the matching motui.kernel.style constant |
the shared semantic styles |
heading link inline_code list_marker |
the matching motui.kernel.style constant (#e3b341 gold, #58a6ff underlined, #f778ba pink, #bc8cff purple) |
the Markdown heading ladder, link labels, inline `code`, list markers |
hover |
reverse video | the pointer tier's hover band |
table_border |
none (uncolored) | Table grid lines |
first_column |
none (plain) | Table first data column |
headings notice_levels |
derived from the colors above | read-only structural tables (Theme() rebuilds them) |
Bold is reserved for the **…** syntax itself and strike for ~~…~~: no
default style above is bold or struck (syntax highlighting's keyword=accent
aside) — Markdown signals structure with color (gold headings, underlined
blue links, pink inline code, purple list markers, italic quotes) instead of
weight.
Keys and keymaps
KeyDecoder is a pure bytes -> events machine (UTF-8, control names, CSI
navigation, bracketed paste — the screen opens DECSET 2004 on entry, so a
paste arrives wrapped in \x1b[200~/\x1b[201~ and decodes to one event —
and SGR mouse). Editing keys route through a
keymap — key event name to the editing method it triggers — which
Input/Editor resolve at construction:
from motui import Input
from motui.keys import DEFAULT_INPUT_KEYMAP
field = Input(keymap={**DEFAULT_INPUT_KEYMAP, "c-j": "clear"})
del field.keymap["up"] # unbound: the host sees the event again
DEFAULT_INPUT_KEYMAP binds arrows/home/end/backspace/delete/c-a/c-e/
c-u (and up/down for wrapped rows), c-z/c-y for the buffer's bounded
undo/redo, and the s-left/s-right/s-up/s-down/s-home/s-end shift
chords for the text-internal selection (c-a selects the whole field; the
line start is the plain home), but leaves enter unbound — the return
key is the host's submit gesture. DEFAULT_EDITOR_KEYMAP adds enter as
newline, and EDITOR_SUBMIT_KEYMAP (the editor table minus enter, c-j
as newline) is what an editor whose host owns the submit gesture resolves.
All three tables live together in keys/keymap.py with the derivation chain
visible, and each is re-exported from motui.keys. A component consumes
one event at a time through
handle(event) -> bool, so the wrap geometry move_up/move_down follow
still comes from the component's last render — the event never carries it.
The driver at a glance
In call order (not a runnable script — every call is on the driver):
tui.add(comp, id="x", pin=None) # append a block (auto id "b0"…); pin: "top"/"bottom"/None
tui.get("x"); tui.remove("x") # address by id; invalidate
tui.system("system line") # muted system block (no raw stdout writes)
tui.invalidate(); tui.paint() # mark dirty / paint one frame now
tui.rows # the current frame: exactly screen.height rows
tui.every(0.1, fn) # periodic callback, returns a cancellable handle
tui.clock() # the pump's monotonic clock (the one time seam)
tui.tick(now) # advance the pump: due callbacks, then one frame
event = await tui.read_key() # next key; page keys and the wheel scroll and are absorbed
tui.scroll_up(2); tui.scroll_to_bottom()
tui.reveal("b3", 12) # bring a document address on screen (centered, clamped)
tui.viewport.scroll_to(offset) # the pane's window: an absolute jump, clamped
tui.viewport_rows() # the focused pane's window as (first, last), or None
tui.split("vertical", ratios=(2, 1)) # tile the focused pane: first child keeps the document
tui.add_band(viewport) # park a footer viewport in the frame's bottom band
tui.set_title("my app") # OSC 2 window title (control bytes scrubbed)
tui.set_cursor_shape("beam") # DECSCUSR: block/beam/underline/default
tui.hit_test(x, y) # a 0-based cell -> the block under it (or None)
tui.hit_component(x, y) # ...or the deepest leaf: child_at descends every container
tui.selection; tui.set_selection(sel) # the document-space selection overlay; None clears
tui.stop() # ask run() to finish
TUI().run(body) brackets enter/leave, runs a throttle/repaint pump, and —
on a non-live screen (pipe/file) — stays silent during the run and dumps the
final screen as plain text on exit. TUI(theme=...) sets the theme the
driver's own chrome reads (the split's separator; widgets take their own
theme=), and TUI(hover=True) opens DECSET 1003 — any-event motion — for a
Pointer; the default mouse mode stays 1000+1002+1006. read_key intercepts
the scroll_keys table (tui.scroll_keys, rebind or del entries to hand
an event back) and returns None on a non-live screen even with a source
attached.
Every non-empty frame is bracketed by DECSET 2026 (synchronized output) by
default — TUI(sync=False) drops the pair — and the rows are rendered for
the screen's color capability: TUI(color_mode="truecolor"|"256"|"16"|"none")
overrides the environment probe (NO_COLOR, TERM, COLORTERM) the default
screen's "auto" runs. TUI(focus_events=True) opens DECSET 1004, so the
decoder hands the host focus_in / focus_out events. A mouse click
resolves through the driver — the host dispatches (inside the async body,
where an await is legal):
if isinstance(event, MouseEvent):
hit = tui.hit_test(event.x, event.y) # 0-based coords; None when no block owns the cell
if hit is not None:
hit.block.component.click(hit.row, hit.col) # e.g. a Select: it ignores the column
Building your own shell
The library ships no app shell — no ready-made application, no pinned
input box: the components plus the tiers are the whole toolkit, and a host
assembles its own. The shape is three pieces: the body document the
panes render, a footer Document holding the input and status rows, and
tui.add_band(viewport), which parks that footer's viewport in the frame's
bottom band — full width, beneath every pane, outside every split, so a
pane division never cuts it in half.
import asyncio
from motui import Editor, Key, StatusLine, Text, TUI
from motui.kernel import Document, Viewport
from motui.keys import EDITOR_SUBMIT_KEYMAP
async def session(tui):
footer = Document() # the shell's own footer document
editor = Editor(keymap=EDITOR_SUBMIT_KEYMAP) # enter stays the host's submit gesture
footer.add(editor, id="input") # the pinned input row
footer.add(StatusLine(left=("ready",)), id="status")
tui.add_band(Viewport(footer)) # park the footer under the pane area
while True:
key = await tui.read_key()
if key is None or (isinstance(key, Key) and key.name == "c-c"):
break # EOF, or the quit gesture
if isinstance(key, Key) and key.name == "enter":
tui.add(Text(f"you: {editor.text}")) # the submit gesture is the host's
editor.clear()
continue
editor.handle(key) # every editing key, and a Paste
tui.stop()
asyncio.run(TUI().run(session))
(Document and Viewport are the two deeper seams this recipe reaches for;
both stay public at motui.kernel — the root's curated face is listed in
The import contract.)
tests/test_readme_examples.py runs this recipe headless: a scripted
session types, submits and quits, and the footer's own blocks are asserted
in place below the pane area.
Every other tier mounts with one line beside the loop: a Palette in the
footer document is the self-contained command panel (a host opens it on its
own key and routes it first while it is open),
SelectionGesture adds drag selection (a drag's motion keeps its clicks),
Pointer adds hover and stationary clicks, FocusRing routes the keys a
focused widget owns, and Scrollbar / Toasts bring chrome and overlays —
Chrome, panes, overlays spells out the dispatch
order they route in. The complete shell recipe lands in
docs/COMPONENTS.md.
Testing without a terminal
headless_tui(...) builds a driven TUI on a HeadlessScreen with a
ScriptedKeys source; snapshot(tui) renders the document to rows for
assertions. The demo doubles as a smoke test in piped mode:
python -m motui < /dev/null visits every page, dumps the
final screen as plain text and exits 0 — the piped dump is the whole
assertion, and --pages dumps the pages one by one. The no-color path is a
run of its own: NO_COLOR=1 python -m motui --pages exits 0
too (HeadlessScreen pins its color_mode to "truecolor", so a test's
bytes never depend on the machine, and the probe is what a live
TerminalScreen reads).
Golden frames. Every demo page is also pinned byte for byte:
tests/golden/pages/<page>.txt holds one --pages dump per page, and
uv run python tools/golden.py --check — the same assertion as
tests/test_golden_frames.py — diffs them by name. --update re-records
the whole set, for a wave that means to change what a user sees.
The same gates run on every push and pull request:
.github/workflows/ci.yml syncs the lock with
uv, then runs pytest -q, framebench (the exit code and the presence of
the hash line, not the hash's value), the golden check, the piped replay and
the page dump — on Ubuntu and on Windows alike.
Docs
docs/DESIGN.md— the layers and the focus tier, the 0-based coordinate charter, screen model, viewport, painter, driver, keys, keymaps, highlighting, the selection rationale (D1–D8) and mode table, the pointer tier and the hover rationale (1002 vs 1003, the wire's button-free motion), the non-TTY contract, and the frame model (the three space claims, the compositor, budget negotiation, focus and wheel routing, the pinned footer band, and the un-split).docs/COMPONENTS.md— therender(width)protocol, the component catalogue, the Theme fields, the frame tier (Pane/split/close/Scrollbar/Toasts) with the dispatch-order contract, theSelectionGesturestate machine and selection cookbook, the focus tier (FocusRing's routing table and click policy), the footer-band recipe for assembling an app, the "build your own" walkthroughs (a custom 2-column chrome rail; a custom overlay; a clickable-through container), a customMarkdownrule walkthrough onRenderContext, and a custom-component walkthrough.docs/API.md— generated, not hand-written: every facade's curated names, each with its signature, the first paragraph of its docstring and the module that defines it (tools/apidoc.py;--checkis the drift gatetests/test_api_doc.pyruns).
Releases are tag-driven: pushing a v* tag runs
.github/workflows/release.yml — uv build
on both platforms, then a trusted-publishing upload to PyPI; the file's
comments spell out the one-time PyPI-side configuration (the trusted
publisher's environment name — no secret lives in the repository).
Source: Shingwha/motui — issues and
pull requests go to the tracker.
Not on purpose (see docs/DESIGN.md): document-size caps / history trimming
(the document stays resident in full), runtime color negotiation (the palette
is 24-bit truecolor; a VT probe only gates streams that cannot render escapes
at all), drag-to-resize dividers and cross-pane selection (drag selection and
scrollbar dragging both ship — SelectionGesture consumes the press, motion
and release reports mode 1002 delivers, and the bar consumes its own strip —
but the divider's ratio is fixed at split time, selection stays
single-document, and multi-range selection stays at one range, while the
decoder still drops the horizontal wheel and unknown button codes), math
rendering in Markdown (the rule registries are the documented extension
point), and any sleep in the library or its tests.
License
MIT — see LICENSE.
Metadata
Release files for motui 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| motui-1.0.0.tar.gz | 544.1 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| motui-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 875.4 kB
Release files / motui-1.0.0.tar.gz
| Download URL | motui-1.0.0.tar.gz |
|---|---|
| Size | 544.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1987ceb11b4cf08c24b13a5857935fa453323842bbf64ad412f83cd88e073681
|
|
BLAKE2b-256 checksum How to use checksums |
f1b3ab7737fc48d7d55d6bcbbd087116c04d9fdf5ed3d91cc10cd03a59341234
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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 17, 2026.
Transparency logRelease files / motui-1.0.0-py3-none-any.whl
| Download URL | motui-1.0.0-py3-none-any.whl |
|---|---|
| Size | 331.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
760bcea96492597f122f5393dc330b1ce3f5d8bfabc740b4740d50bd68aedd6e
|
|
BLAKE2b-256 checksum How to use checksums |
52b93d7b8ed8271ad7152c448c26a4292a0690e5044de001c38ee92888f110b7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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 17, 2026.
Transparency logSecurity for motui 1.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.
Attestation for motui-1.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 17, 2026.
Transparency logA 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.