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) draws a progress bar when both current and total are set.
  • Tree(provider)provider() -> TreeNode | list[TreeNode], where TreeNode(label, children=[], expanded=True).
  • 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.

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 and TaskList 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.2.0.tar.gz (21.2 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.2.0-py3-none-any.whl (21.5 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: elka-0.2.0.tar.gz
  • Upload date:
  • Size: 21.2 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.2.0.tar.gz
Algorithm Hash digest
SHA256 79808df1babe97253412ed4acd6e5ee880c93782995b835a29b754d4943df1cb
MD5 c4fa91e423797528cbbf6be912fbe52a
BLAKE2b-256 c33ba4493def5dd6fca7c0f51e02244f1ad0736188dc3e1a78e0280699f4800e

See more details on using hashes here.

File details

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

File metadata

  • Download URL: elka-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 21.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.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 289cb4309c1cf7cb851c0901ddcddf9e86a0ae9cff00cf7077b75ee60622132a
MD5 04b812976fe9f2ff69243d4281c8596c
BLAKE2b-256 6e354b28da49435718cff2224fcbccf1a869fe2def2a32c93252ecd6320d9c71

See more details on using hashes here.

Release history Release notifications | RSS feed

0.3.0

2 files

0.2.1

2 files

This release

0.2.0 This release

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