Skip to content

Commit 94262c9

Browse files
committed
tui: typed cell colors, handle-owned i/o, and spawn-time sizing
Core crate: - replace stringly StyledCell.fgcolor/bgcolor ("idx:5", "rgb:1,2,3") with a typed Color enum (Default/Indexed/Rgb) and rename to fg/bg. - move all read/write methods onto the self-sufficient TuiInstance handle and drop the unused_self-forced TuiManager static async fns; the manager now just spawns, tracks, and shares its runtime into each handle. - collapse the duplicated send/await boilerplate into one generic reader::request. - add SpawnConfig for spawn-time rows/cols/scrollback (size was hardcoded). - take Duration instead of u64 ms in the blocking API; give ArrayConversion a real ndarray::ShapeError source. - delete the dead Cache module (exported but never used). Python bindings: - expose color as the pythonic int | tuple[int,int,int] | None (None=default). - collapse the three redundant StyledCell reprs into the native class and drop the redundant native FullOutput pyclass (read_full returns a tuple). - add rows/cols to the constructor; defaults flow from core SpawnConfig. Fix stale READMEs (scrollback was claimed unimplemented; TuiInfo and runtime ioctl resize never existed).
1 parent 34006a5 commit 94262c9

18 files changed

Lines changed: 732 additions & 827 deletions

File tree

packages/tui-py/README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ with Tui("python", "-q") as tui:
4747
`tui.snapshot()` returns a frozen `Snapshot` (viewport, scrollback, size). It
4848
supports `str(snap)`, `"needle" in snap`, and `.text` / `.full_text`.
4949

50+
The terminal opens at 80x24 with 10,000 lines of scrollback. Override per
51+
instance with keyword args:
52+
53+
```python
54+
Tui("bash", "--norc", "-i", rows=40, cols=120, scrollback_lines=50_000)
55+
```
56+
5057
## Examples
5158

5259
### Drive a REPL and read its output
@@ -121,7 +128,10 @@ with Tui("bash", "--norc", "-i") as t:
121128

122129
`tui.chars()` returns a `numpy.uint32` array of Unicode codepoints, shape
123130
`(rows, cols)`. `tui.styled_cells()` returns a nested list of `StyledCell`
124-
dataclasses (`char`, `fg`, `bg`, `bold`, `italic`, `underline`, `inverse`).
131+
objects (`char`, `fg`, `bg`, `bold`, `italic`, `underline`, `inverse`).
132+
133+
`fg` and `bg` are `Color` values: `None` for the terminal default, an `int` in
134+
`0..=255` for a palette index, or an `(r, g, b)` tuple for truecolor.
125135

126136
```python
127137
import numpy as np
@@ -136,6 +146,7 @@ with Tui("bash", "--norc", "-i") as t:
136146
styled = t.styled_cells()
137147
bolds = [(r, c) for r, row in enumerate(styled)
138148
for c, cell in enumerate(row) if cell.bold]
149+
reds = [cell.char for row in styled for cell in row if cell.fg == 1]
139150
```
140151

141152
### Handle timeouts
@@ -170,7 +181,8 @@ print(len(Tui.list_all())) # 2
170181
| `Snapshot` | Frozen viewport + scrollback + size. |
171182
| `Size` | `(rows, cols)` dataclass. |
172183
| `Key` | ANSI keystroke constants + `Key.ctrl`/`Key.alt`. |
173-
| `StyledCell` | One cell with VT100 attributes. |
184+
| `StyledCell` | One cell: `char`, `fg`/`bg`, and VT100 attributes. |
185+
| `Color` | `None` (default), `int` (palette), or `(r, g, b)`. |
174186
| `WaitTimeout` | Raised by `wait_for` / `await_for` on deadline expiry. |
175187

176188
[maturin]: https://www.maturin.rs/

packages/tui-py/python/tui/__init__.py

Lines changed: 26 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
Size (rows, cols) terminal size
99
Key common keystrokes as ANSI byte strings, with .ctrl/.alt
1010
StyledCell one viewport cell: character + VT100 attributes
11+
Color a cell color: None (default), int (palette), or (r, g, b)
1112
WaitTimeout raised by `Tui.wait_for(...)` when nothing matched in time
1213
1314
Every blocking I/O method has an `a`-prefixed coroutine variant that returns a
@@ -31,11 +32,13 @@
3132
from numpy.typing import NDArray
3233

3334
from ._tui import (
35+
StyledCell as StyledCell,
3436
TuiInstance as _RawTuiInstance,
3537
__version__,
3638
)
3739

3840
__all__ = [
41+
"Color",
3942
"Key",
4043
"Pattern",
4144
"Size",
@@ -52,6 +55,12 @@
5255
# --------------------------------------------------------------------------- #
5356

5457

58+
#: A VT100 cell color: `None` is the terminal default, an `int` in `0..=255` is
59+
#: a palette index, and an `(r, g, b)` tuple is 24-bit truecolor. Read off a
60+
#: `StyledCell` via `cell.fg` / `cell.bg`.
61+
Color: TypeAlias = int | tuple[int, int, int] | None
62+
63+
5564
@dataclass(frozen=True, slots=True)
5665
class Size:
5766
"""Terminal dimensions, in cells."""
@@ -64,22 +73,6 @@ def __iter__(self) -> Iterator[int]:
6473
yield self.cols
6574

6675

67-
@dataclass(frozen=True, slots=True)
68-
class StyledCell:
69-
"""One viewport cell with its VT100 attributes.
70-
71-
`fg`/`bg` are vt100-formatted color strings or `None` for the default.
72-
"""
73-
74-
char: str
75-
fg: str | None
76-
bg: str | None
77-
bold: bool
78-
italic: bool
79-
underline: bool
80-
inverse: bool
81-
82-
8376
@dataclass(frozen=True, slots=True)
8477
class Snapshot:
8578
"""An immutable view of a Tui at a single point in time."""
@@ -191,21 +184,6 @@ def _build_predicate(pattern: Pattern) -> Callable[[Snapshot], bool]:
191184
return pattern
192185

193186

194-
def _wrap_styled(raw_row: list) -> list[StyledCell]:
195-
return [
196-
StyledCell(
197-
char=c.character,
198-
fg=c.fgcolor,
199-
bg=c.bgcolor,
200-
bold=c.bold,
201-
italic=c.italic,
202-
underline=c.underline,
203-
inverse=c.inverse,
204-
)
205-
for c in raw_row
206-
]
207-
208-
209187
# --------------------------------------------------------------------------- #
210188
# Tui
211189
# --------------------------------------------------------------------------- #
@@ -220,10 +198,11 @@ class Tui:
220198
tui.enter("1 + 2")
221199
snap = tui.wait_for("3", timeout=2.0)
222200
223-
A single process-wide tokio runtime drives every spawned PTY. Sync methods
224-
release the GIL on the underlying Rust call; async methods return native
225-
asyncio coroutines bridged through pyo3-async-runtimes — no thread-pool
226-
hop.
201+
The terminal opens at `rows` x `cols` (default 80x24) with `scrollback_lines`
202+
of history (default 10,000). A single process-wide tokio runtime drives
203+
every spawned PTY. Sync methods release the GIL on the underlying Rust call;
204+
async methods return native asyncio coroutines bridged through
205+
pyo3-async-runtimes, with no thread-pool hop.
227206
228207
There is no force-kill path today. `interrupt()` sends Ctrl+C, which most
229208
cooperative programs respect; `with` blocks will interrupt on exit.
@@ -235,9 +214,11 @@ def __init__(
235214
self,
236215
command: str,
237216
*args: str,
238-
scrollback_lines: int = 10_000,
217+
rows: int | None = None,
218+
cols: int | None = None,
219+
scrollback_lines: int | None = None,
239220
) -> None:
240-
self._raw = _RawTuiInstance(command, list(args), scrollback_lines)
221+
self._raw = _RawTuiInstance(command, list(args), rows, cols, scrollback_lines)
241222

242223
@classmethod
243224
def _from_raw(cls, raw: _RawTuiInstance) -> Self:
@@ -311,10 +292,10 @@ def text(self) -> str:
311292

312293
def snapshot(self) -> Snapshot:
313294
"""Immutable point-in-time view of viewport + scrollback."""
314-
full = self._raw.read_full()
295+
scrollback, viewport = self._raw.read_full()
315296
return Snapshot(
316-
viewport=tuple(full.viewport),
317-
scrollback=tuple(full.scrollback),
297+
viewport=tuple(viewport),
298+
scrollback=tuple(scrollback),
318299
size=self.size,
319300
)
320301

@@ -334,7 +315,7 @@ def chars(self) -> NDArray[np.uint32]:
334315

335316
def styled_cells(self) -> list[list[StyledCell]]:
336317
"""Per-cell styling for the viewport, indexed as `[row][col]`."""
337-
return [_wrap_styled(row) for row in self._raw.read_styled_cells()]
318+
return self._raw.read_styled_cells()
338319

339320
# -- waits --------------------------------------------------------------
340321

@@ -384,19 +365,18 @@ async def aread(self, *, timeout: float | None = None) -> list[str]:
384365

385366
async def asnapshot(self) -> Snapshot:
386367
"""Native asyncio-awaitable snapshot."""
387-
full = await self._raw.read_full_async()
368+
scrollback, viewport = await self._raw.read_full_async()
388369
return Snapshot(
389-
viewport=tuple(full.viewport),
390-
scrollback=tuple(full.scrollback),
370+
viewport=tuple(viewport),
371+
scrollback=tuple(scrollback),
391372
size=self.size,
392373
)
393374

394375
async def achars(self) -> NDArray[np.uint32]:
395376
return await self._raw.read_chars_array_async()
396377

397378
async def astyled_cells(self) -> list[list[StyledCell]]:
398-
rows = await self._raw.read_styled_cells_async()
399-
return [_wrap_styled(row) for row in rows]
379+
return await self._raw.read_styled_cells_async()
400380

401381
async def await_for(
402382
self,

packages/tui-py/python/tui/_tui.pyi

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,26 @@ pyo3-async-runtimes; awaiting them drives the underlying tokio future.
99
from __future__ import annotations
1010

1111
from collections.abc import Awaitable
12+
from typing import TypeAlias
1213

1314
import numpy as np
1415
from numpy.typing import NDArray
1516

1617
__version__: str
1718

19+
#: A cell color: `None` is the terminal default, an `int` is a palette index,
20+
#: an `(r, g, b)` tuple is truecolor.
21+
_Color: TypeAlias = int | tuple[int, int, int] | None
22+
1823
class StyledCell:
1924
"""A single terminal cell: character plus VT100 styling."""
2025

2126
@property
22-
def character(self) -> str: ...
27+
def char(self) -> str: ...
2328
@property
24-
def fgcolor(self) -> str | None: ...
29+
def fg(self) -> _Color: ...
2530
@property
26-
def bgcolor(self) -> str | None: ...
31+
def bg(self) -> _Color: ...
2732
@property
2833
def bold(self) -> bool: ...
2934
@property
@@ -34,28 +39,19 @@ class StyledCell:
3439
def inverse(self) -> bool: ...
3540
def __repr__(self) -> str: ...
3641

37-
class FullOutput:
38-
"""Snapshot of both scrollback history and the current viewport."""
39-
40-
@property
41-
def scrollback(self) -> list[str]: ...
42-
@property
43-
def viewport(self) -> list[str]: ...
44-
def __repr__(self) -> str: ...
45-
4642
class TuiInstance:
4743
"""A handle to a single spawned TUI process. Spawning is the constructor."""
4844

4945
def __init__(
5046
self,
5147
command: str,
5248
args: list[str] | None = ...,
53-
scrollback_lines: int = ...,
49+
rows: int | None = ...,
50+
cols: int | None = ...,
51+
scrollback_lines: int | None = ...,
5452
) -> None: ...
55-
5653
@staticmethod
5754
def list_all() -> list[TuiInstance]: ...
58-
5955
@property
6056
def id(self) -> str: ...
6157
@property
@@ -73,7 +69,7 @@ class TuiInstance:
7369
def write(self, data: str) -> None: ...
7470
def read_viewport(self) -> list[str]: ...
7571
def read_scrollback(self) -> list[str]: ...
76-
def read_full(self) -> FullOutput: ...
72+
def read_full(self) -> tuple[list[str], list[str]]: ...
7773
def read_blocking(self, timeout_ms: int) -> list[str]: ...
7874
def read_chars_array(self) -> NDArray[np.uint32]: ...
7975
def read_styled_cells(self) -> list[list[StyledCell]]: ...
@@ -82,9 +78,8 @@ class TuiInstance:
8278
def write_async(self, data: str) -> Awaitable[None]: ...
8379
def read_viewport_async(self) -> Awaitable[list[str]]: ...
8480
def read_scrollback_async(self) -> Awaitable[list[str]]: ...
85-
def read_full_async(self) -> Awaitable[FullOutput]: ...
81+
def read_full_async(self) -> Awaitable[tuple[list[str], list[str]]]: ...
8682
def read_blocking_async(self, timeout_ms: int) -> Awaitable[list[str]]: ...
8783
def read_chars_array_async(self) -> Awaitable[NDArray[np.uint32]]: ...
8884
def read_styled_cells_async(self) -> Awaitable[list[list[StyledCell]]]: ...
89-
9085
def __repr__(self) -> str: ...

packages/tui-py/src/lib.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
//! Python bindings for the `tui` PTY-backed terminal management library.
22
//!
33
//! All blocking calls release the GIL via `Python::detach`, so multiple
4-
//! Python threads can drive the same manager concurrently.
4+
//! Python threads can drive the same manager concurrently. Async methods
5+
//! return native asyncio-awaitable coroutines bridged through
6+
//! pyo3-async-runtimes.
57
68
#![allow(
79
clippy::missing_const_for_fn,
810
reason = "pyo3 getter methods cannot be const because they are dispatched through the pymethod vtable"
911
)]
10-
#![allow(
11-
clippy::struct_excessive_bools,
12-
reason = "VT100 cell attributes are intrinsically four parallel booleans"
13-
)]
1412

1513
mod manager;
1614
mod types;
@@ -20,7 +18,6 @@ use pyo3::prelude::*;
2018
#[pymodule]
2119
fn _tui(module: &Bound<'_, PyModule>) -> PyResult<()> {
2220
module.add_class::<manager::TuiInstance>()?;
23-
module.add_class::<types::FullOutput>()?;
2421
module.add_class::<types::StyledCell>()?;
2522
module.add("__version__", env!("CARGO_PKG_VERSION"))?;
2623
Ok(())

0 commit comments

Comments
 (0)