feat: TUI widget primitives (colors, InputField, draw helpers)

Implement COLOR_* constants, initialization, box/centered drawing,
clamp utility, and InputField with handle_key/value logic for pure
text editing without live curses. All tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vlad Doloman
2026-06-24 00:00:52 +03:00
parent a2593b644e
commit 3a6d6aa125
2 changed files with 153 additions and 0 deletions

View File

@@ -353,6 +353,106 @@ def send_email(
)
# ============================================================
# === TUI WIDGETS ===
# ============================================================
COLOR_NORMAL = 1
COLOR_SELECTED = 2
COLOR_DISABLED = 3
COLOR_TITLE = 4
COLOR_ERROR = 5
COLOR_SUCCESS = 6
def init_colors() -> None:
curses.start_color()
curses.use_default_colors()
curses.init_pair(COLOR_NORMAL, curses.COLOR_WHITE, -1)
curses.init_pair(COLOR_SELECTED, curses.COLOR_BLACK, curses.COLOR_CYAN)
curses.init_pair(COLOR_DISABLED, 8, -1)
curses.init_pair(COLOR_TITLE, curses.COLOR_WHITE, -1)
curses.init_pair(COLOR_ERROR, curses.COLOR_RED, -1)
curses.init_pair(COLOR_SUCCESS, curses.COLOR_GREEN, -1)
def draw_box(win, title: str = "") -> None:
win.box()
if title:
_, w = win.getmaxyx()
label = f" {title} "
x = max(1, (w - len(label)) // 2)
try:
win.addstr(0, x, label, curses.color_pair(COLOR_TITLE) | curses.A_BOLD)
except curses.error:
pass
def draw_centered(win, y: int, text: str, attr: int = 0) -> None:
_, w = win.getmaxyx()
x = max(0, (w - len(text)) // 2)
try:
win.addstr(y, x, text, attr)
except curses.error:
pass
def clamp(value: int, lo: int, hi: int) -> int:
return max(lo, min(hi, value))
class InputField:
"""Single-line editable text field. draw() needs a live curses window;
handle_key() + value are pure logic."""
def __init__(
self, win, y: int, x: int, width: int,
initial: str = "", mask: bool = False,
) -> None:
self._win = win
self._y = y
self._x = x
self._width = width
self._mask = mask
self._buf = list(initial)
self._cur = len(self._buf)
@property
def value(self) -> str:
return "".join(self._buf)
def draw(self) -> None:
display = ("*" * len(self._buf)) if self._mask else "".join(self._buf)
start = max(0, self._cur - self._width + 1)
visible = display[start: start + self._width].ljust(self._width)
try:
self._win.addstr(self._y, self._x, visible,
curses.color_pair(COLOR_NORMAL) | curses.A_UNDERLINE)
self._win.move(self._y, self._x + min(self._cur - start, self._width - 1))
except curses.error:
pass
def handle_key(self, key: int) -> None:
if key in (curses.KEY_BACKSPACE, 127, 8):
if self._cur > 0:
del self._buf[self._cur - 1]
self._cur -= 1
elif key == curses.KEY_LEFT:
self._cur = clamp(self._cur - 1, 0, len(self._buf))
elif key == curses.KEY_RIGHT:
self._cur = clamp(self._cur + 1, 0, len(self._buf))
elif key == curses.KEY_HOME:
self._cur = 0
elif key == curses.KEY_END:
self._cur = len(self._buf)
elif key == curses.KEY_DC:
if self._cur < len(self._buf):
del self._buf[self._cur]
elif 32 <= key <= 126:
self._buf.insert(self._cur, chr(key))
self._cur += 1
# (remaining sections added in later tasks)
# ============================================================