Metadata-Version: 2.5
Name: elka
Version: 0.2.0
Summary: A small, stdlib-only Python TUI package.
Project-URL: Homepage, https://github.com/CNC5/elka-tui
Project-URL: Repository, https://github.com/CNC5/elka-tui
Author-email: CNC5 <github@cnc5.dev>
Keywords: ansi,cli,curses,terminal,tui
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: User Interfaces
Classifier: Topic :: Terminals
Requires-Python: >=3.8
Description-Content-Type: text/markdown

# 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](#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](#scrolling).

## Usage

```python
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.

```python
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](#mouse--focus)).

```python
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 `MouseEvent`s, and the list it returns may
contain them alongside key names.

```python
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:

```python
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
```
