Skip to main content

elka

A small, stdlib-only Python TUI package. A singleton terminal owns the screen; elements are attached to it and repainted on demand.

Data-driven elements take a provider function that is re-invoked every frame — so you update the UI just by mutating whatever the provider reads. No third-party dependencies; rendering is flicker-free (off-screen cell buffer + diff) and caller-driven (you call terminal.render()). Keyboard input is captured directly (raw, non-blocking) and dispatched to handler functions you register.

Elements

  • HorizontalSplit(top, bottom, ratio=0.5, divider=False) — two stacked panes; ratio may be a float or a () -> float provider. Nest for multi-pane layouts. Clicking a pane focuses it; see Mouse & focus.
  • TaskList(provider, title=None, bar_width=20)provider() -> list[Task], where Task(name, current=None, total=None, style="") draws a progress bar when both current and total are set; style colours the whole row.
  • Tree(provider)provider() -> TreeNode | list[TreeNode], where TreeNode(label, children=[], expanded=True, style=""); style colours the node's label (branch glyphs stay in the default colour).
  • Text(provider=None, text=None, style="") — one line of text per row, truncated to the region width. Either pull (pass provider) or push (drive it with method calls). Lines can be coloured. See Text.
  • Scroll(child, offset=0) — a movable vertical window into any element whose content is taller than its region. See Scrolling.

Usage

from elka import terminal, HorizontalSplit, TaskList, Task, Tree, TreeNode

state = {"done": 0, "running": True}
terminal.attach(HorizontalSplit(
    TaskList(lambda: [Task("build", state["done"], 100)], title="Tasks"),
    Tree(lambda: TreeNode("root", [TreeNode("src"), TreeNode("README")])),
    ratio=0.4, divider=True,
))

terminal.on("space", lambda k: state.update(done=min(100, state["done"] + 10)))
terminal.on("q", lambda k: state.update(running=False))

with terminal:                       # alt screen, hidden cursor, raw mode
    while state["running"]:
        terminal.render()            # one frame
        terminal.poll_input(0.1)     # dispatch keys; blocks up to 0.1s to pace

with terminal: enters the alternate screen and restores everything on exit (including on Ctrl-C or exceptions). When stdout is not a TTY the terminal no-ops so callers can run headless.

Text

Text renders lines of text, one per row. Content is either a single string (split on "\n") or a list of lines. It works in one of two mutually exclusive modes:

Pull — pass a provider (() -> str | list[str]), re-invoked every frame like the other data-driven elements. A constant string or list is also accepted:

from elka import Text, terminal

status = {"msg": "idle"}
terminal.attach(Text(lambda: f"status: {status['msg']}"))

Push — omit provider and drive the content directly. Handy for a log where you append as events happen rather than re-deriving the whole view:

log = Text()                 # push mode
log.set("first line")        # replace all content (str or list of lines)
log.append(["line 2", "line 3"])
log.delete_first(1)          # drop the oldest line
log.delete_last()            # drop the newest (n=1 by default)

delete_first/delete_last clamp to the available lines and no-op for n <= 0. Calling a push method while a provider is set raises RuntimeError. Text reports its content_height, so it scrolls inside a Scroll wrapper like Tree and TaskList.

Colour

Colour is applied through style strings — raw SGR escape prefixes attached to cells. The elka.ansi module provides ready-to-use ones so you don't hand-write escapes: foreground colours BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE; attributes BOLD, DIM, ITALIC, UNDERLINE; and fg(n) / bg(n) for 256-colour indices.

from elka import Task, TaskList, Text, Tree, TreeNode, ansi

# A task row, coloured by state.
TaskList(lambda: [Task("build", 100, 100, style=ansi.GREEN)])

# A tree node label (the ├─/└─ glyphs stay uncoloured).
Tree(lambda: TreeNode("src", [TreeNode("main.py", style=ansi.CYAN)]))

# Text: one colour for a whole call, or per line via (text, style) tuples.
log = Text()
log.append("connected", style=ansi.GREEN)
log.append([("warning", ansi.YELLOW), ("error", ansi.RED)])

Any style string works, so ansi.BOLD + ansi.RED combines attributes, and you can pass your own SGR sequence built with ansi.sgr(...).

Scrolling

Elements clip to their region and truncate anything past the bottom. Wrap one in Scroll to make the overflow reachable: it renders the child into an off-screen buffer as tall as its content, then shows just a slice of it.

from elka import Scroll, Tree, terminal

log = Scroll(Tree(big_tree_provider))    # wraps any element
terminal.attach(log)

terminal.on("down",     lambda k: log.scroll_by(1))
terminal.on("up",       lambda k: log.scroll_by(-1))
terminal.on("pagedown", lambda k: log.page_down())
terminal.on("pageup",   lambda k: log.page_up())
terminal.on("home",     lambda k: log.to_top())
terminal.on("end",      lambda k: log.to_bottom())

The wrapper owns the scroll position (log.offset) and clamps it to the child's content every frame, so you never track content height yourself: scroll_by/scroll_to stop at the first and last row, and page_up/ page_down move by the visible height. Clamping relies on the child reporting its content_height(width); the built-in Tree, TaskList, and Text do. Scroll nests inside a HorizontalSplit pane like any other element.

Keyboard input

Input is caller-driven like rendering. Register handlers, then call poll_input() each frame to read and dispatch pending keystrokes. Handlers on terminal are app-wide; the same on/on_any also work on any element, where they fire only while that element is focused (see Mouse & focus).

terminal.on("up", lambda key: ...)      # a specific key
terminal.on("enter", handle_enter)      # handler receives the key name
terminal.on_any(lambda key: log(key))   # catch-all, runs after specific ones

@terminal.on("q")                        # also usable as a decorator
def quit(key):
    ...
  • poll_input(timeout=0) — read + dispatch. timeout=0 is non-blocking; a positive timeout waits that long for a key (handy to pace a loop without a separate sleep). Returns the key names read.
  • read_keys(timeout=0) — read + parse without dispatching (timeout=None blocks until a key arrives).

Key names include printable characters ("a", "Q", "é"), "up"/"down"/ "left"/"right", "enter", "tab", "space", "backspace", "escape", "home"/"end"/"pageup"/"pagedown"/"insert"/"delete", "f1""f12", and "ctrl-a""ctrl-z". Constants are also available (elka.keys.UP, etc.). Ctrl-C keeps its default behavior (raises SIGINT, which cleanly restores the terminal and exits) rather than arriving as a key.

Mouse & focus

Mouse reporting is off by default (it takes over the terminal's own click-to-select). Turn it on with terminal.enable_mouse(); from then on poll_input() also dispatches MouseEvents, and the list it returns may contain them alongside key names.

terminal.enable_mouse()
terminal.on_mouse(lambda e: log(e.x, e.y, e.button, e.action))

A MouseEvent has x/y (0-indexed cell coordinates), button ("left"/"middle"/"right"/"wheel_up"/"wheel_down"), and action ("press"/"release"/"drag").

Click-to-focus. Independently of any on_mouse handler, a left-button press is routed into the attached element tree as root.focus_at(x, y). A HorizontalSplit uses this to focus the pane under the click: it tracks the focused pane in split.focused (0 top, 1 bottom, None neither) and marks it on the divider (/, drawn with focus_style), so divider=True makes the indicator visible. Focus follows a single path through nested splits — focusing one pane clears the others. You can also drive focus yourself: split.focus_pane(0) / split.focus_pane(1) (handy from a keyboard handler), or split.clear_focus().

Keys follow focus. Elements are key dispatchers too, so a handler registered on an element fires only while that element is the focused pane:

tasklist.on("up", lambda k: move(-1))     # only when the task list is focused
tree_scroll.on("up", lambda k: tree_scroll.scroll_by(-1))  # only when the tree is
terminal.on("tab", switch_focus)          # on the terminal -> always fires
terminal.on("q", quit)

On each keystroke the focused leaf pane (resolved by walking the split-focus path) is offered the key first, then the terminal's own app-wide handlers run — so up/down can mean different things per pane while q/tab work everywhere. When no pane is focused, only the app-wide handlers run.

Try it

python examples/demo.py     # in a real terminal
python tests/test_elements.py

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

elka-0.3.0.tar.gz (23.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

elka-0.3.0-py3-none-any.whl (23.5 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: elka-0.3.0.tar.gz
  • Upload date:
  • Size: 23.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Gentoo","version":"2.18","id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for elka-0.3.0.tar.gz
Algorithm Hash digest
SHA256 b36431af3f1ce7f290ba3adc35d6d58d68b9fedcb03c957655e8446f4795c85e
MD5 a2063d54e6eb5b08eea29f30d55e2908
BLAKE2b-256 b144ad8d6e137d34995ba3828c6f8522cb6d2026180dd749e0d6575d494edf0d

See more details on using hashes here.

File details

Details for the file elka-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: elka-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 23.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Gentoo","version":"2.18","id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for elka-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7f82923561af6e4c3da37be6f1898d92aa48c8e119c612d0bd131444ff5a5aec
MD5 95a1e5611e5269d1d8e3078af4fc15d8
BLAKE2b-256 d304edf202ae829e7fa9ecf7a8fab67c5033dfbd8e1143d961b2ad3b59b80e91

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 files

0.2.1

2 files

0.2.0

2 files

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