Minimalist framework for building terminal user interfaces(TUI) in Python, inspired by event-driven / Elm-style architectures (message → update → view) and bubbletea created by Charmbracelet
The library handles the event loop, asynchronous input, rendering, and command management, so you only have to worry about the state and view.
Important
This library is under early development and is not stable.
Breaking changes may occur at any time.
This is a personal project shared publicly and is not intended as a production-ready or fully supported library.
- Message-based architecture
- Async support
- Declarative view
- Win / Linux compatible
Every application inherits from App and must implement:
update(self, msg): processes messages and returns a commandview(self): describes the current UI- (optional)
init_model()run something and return a command
class MyApp(App):
def __init__(self):
super().__init__()
...
def update(self, msg: Msg):
...
def view(self):
...Defines the state of the application. Everything defined within the inherited class is part of the model and can be accessed via self.
class MyApp(App):
def __init__(self):
super().__init__()
# State
self.counter = 0The view represents how the application looks at a given moment. It is designed to be declarative: it describes the current state of the model, it does not change it.
Every time a message arrives and update(msg) updates the model, view() is called again to generate the UI.
The view must return a collection or sequence of ElementBuilder which will later be built to calculate the layout and render the UI.
from tui.elements import *
from tui.widgets.text_input import *
# We use yield for simplicity, but it has no additional effect, you can use lists
def view(self):
yield Text(f"Counter: {self.count}"),
# or
yield TextInput(self.text_input_m) \
.placeholder("Write something") \
.prompt(">")Note
The state of the app self should not be modified within view(). Its sole purpose is to describe the presentation.
update(msg) is the core of the App. Receives a message updating the state Model and returns a Cmd (or None).
Messages represent any event that occurs in the application. Everything that happens in the system —input, commands, or internal logic— which may contain data, always flows through the event loop in the form of messages.
The framework defines some internal messages that have special behavior within the event loop, for example:
KeyPressMsgGenerated byAppcan be used to obtain user inputQuitMsg: Not generated byAppbut captured to close the application after doing one extra renderStartMsg: initial message when starting the appExecBatchMsg,ExecSequenceMsg: Command exectution. Generated and captured only insideApp
These messages are intercepted by the loop before calling update, and can cause direct effects (exit, clean up, execute commands, etc.).
In addition to internal messages, you can freely declare your own messages.
They do not need to inherit from any special class or register anywhere. Any object can be a message.
class IncrementMsg:
pass
# dataclass recomended for messages containing data
from dataclasses import dataclass
@dataclass(frozen=True)
class LoadDataMsg:
data: list[str]This messages do go through the event loop. But they do anything internally and they always reach update(msg).
A command (Cmd) represents an action that is executed outside of the update.
Its purpose is to perform work (I/O, calculations, timers, etc.) and return a message that will re-enter the event loop. A command can be a synchronous or asynchronous function and should not modify the state directly
When a command requires parameters, it should not be executed directly in update. Instead, a function that encapsulates those parameters is returned.
Para facilitar esto, la librería provee un decorador simple:
@command
def my_command(self, foo: str):
...
def update(self, msg):
return self.my_command("Hi!")Internally, this returns a function ready to be executed by the event loop, without executing the command immediately.
For commands that do not require parameters, it would technically be possible to return the function without executing it (return self._my_other_command), but this is confusing and inconsistent.
For clarity and consistency, all commands are returned as calls (bar()), even if they do not receive arguments.
Often, an update needs to return more than one command. There are two main helpers for this purpose:
Executes several commands without guaranteeing the order of execution, ideal for independent tasks
tui.batch(cmd1, cmd2, cmd3)similar to batch but guarantees order
tui.sequence(cmd1, cmd2, cmd3)Cmds helper. To facilitate the composition of complex commands, there is an auxiliary class Cmds, which acts as an incremental container
def update(self, msg):
cmds = Cmds()
cmds.perform(self.input_model.update(msg))
cmds.sequence(self.foo(), self.bar())
return tui.batch(*cmds)The framework automatically manages keyboard input and transforms it into messages that enter the event loop. Each keypress generates a KeyPressMsg(key: str), where key is a text representation of the key pressed.
def update(self, msg):
match msg:
case KeyPressMsg(key="+"):
...Common combinations are also detected.
"ctrl+c"
"ctrl+shift+q"
"ctrl+a"This allows keyboard shortcuts to be managed easily and expressively.
match KeyPressMsg(key="ctrl+c"):
return tui.quit()import tui
from tui import App, KeyPressMsg
from tui.colors import *
from tui.elements import *
class Counter(App):
def __init__(self):
super().__init__()
self.count = 0
def update(self, msg):
match msg:
case KeyPressMsg(key="q"):
return tui.quit()
case KeyPressMsg(key="+"):
self.count += 1
case KeyPressMsg(key="-"):
self.count -= 1
def view(self):
if self.should_close():
yield Text("Bye...")
return
yield Text(f"Counter: {yellow(str(self.count))}")
yield Space()
yield Text("+/-: counter, q: quit")
if __name__ == '__main__':
Counter().run()