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;ratiomay be a float or a() -> floatprovider. Nest for multi-pane layouts. Clicking a pane focuses it; see Mouse & focus.TaskList(provider, title=None, bar_width=20)—provider() -> list[Task], whereTask(name, current=None, total=None, style="")draws a progress bar when bothcurrentandtotalare set;stylecolours the whole row.Tree(provider)—provider() -> TreeNode | list[TreeNode], whereTreeNode(label, children=[], expanded=True, style="");stylecolours 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 (passprovider) 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=0is non-blocking; a positive timeout waits that long for a key (handy to pace a loop without a separatesleep). Returns the key names read.read_keys(timeout=0)— read + parse without dispatching (timeout=Noneblocks 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b36431af3f1ce7f290ba3adc35d6d58d68b9fedcb03c957655e8446f4795c85e
|
|
| MD5 |
a2063d54e6eb5b08eea29f30d55e2908
|
|
| BLAKE2b-256 |
b144ad8d6e137d34995ba3828c6f8522cb6d2026180dd749e0d6575d494edf0d
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7f82923561af6e4c3da37be6f1898d92aa48c8e119c612d0bd131444ff5a5aec
|
|
| MD5 |
95a1e5611e5269d1d8e3078af4fc15d8
|
|
| BLAKE2b-256 |
d304edf202ae829e7fa9ecf7a8fab67c5033dfbd8e1143d961b2ad3b59b80e91
|