Skip to content

XcrDevv/Python-TUI-lib

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

TUI Framework

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.

Features

  • Message-based architecture
  • Async support
  • Declarative view
  • Win / Linux compatible

Concetps

App

Every application inherits from App and must implement:

  • update(self, msg): processes messages and returns a command
  • view(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):
        ...

Model

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 = 0

View

The 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

update(msg) is the core of the App. Receives a message updating the state Model and returns a Cmd (or None).

Messages

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.

Internal Messages

The framework defines some internal messages that have special behavior within the event loop, for example:

  • KeyPressMsg Generated by App can be used to obtain user input
  • QuitMsg: Not generated by App but captured to close the application after doing one extra render
  • StartMsg: initial message when starting the app
  • ExecBatchMsg, ExecSequenceMsg: Command exectution. Generated and captured only inside App

These messages are intercepted by the loop before calling update, and can cause direct effects (exit, clean up, execute commands, etc.).

Custom Messages

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).

Commands

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

Commnad declaration

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.

Multiple commands

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)

User input

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="+"):
            ...

Key combinations

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()

Minimum example

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()

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages