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:
@@ -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)
|
||||
# ============================================================
|
||||
|
||||
|
||||
Reference in New Issue
Block a user