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)
|
||||
# ============================================================
|
||||
|
||||
|
||||
53
tests/test_widgets.py
Normal file
53
tests/test_widgets.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from unittest.mock import MagicMock
|
||||
import curses as _curses
|
||||
|
||||
# Stub just enough of the curses API for InputField to import
|
||||
_curses.color_pair = lambda x: 0
|
||||
_curses.A_UNDERLINE = 0
|
||||
_curses.A_BOLD = 0
|
||||
_curses.KEY_BACKSPACE = 263
|
||||
_curses.KEY_LEFT = 260
|
||||
_curses.KEY_RIGHT = 261
|
||||
_curses.KEY_HOME = 262
|
||||
_curses.KEY_END = 360
|
||||
_curses.KEY_DC = 330
|
||||
|
||||
from openvpncertupdate import InputField, clamp
|
||||
|
||||
|
||||
def _field(initial="", mask=False):
|
||||
win = MagicMock()
|
||||
win.getmaxyx.return_value = (1, 80)
|
||||
return InputField(win, 0, 0, 40, initial=initial, mask=mask)
|
||||
|
||||
|
||||
def test_initial_value():
|
||||
assert _field("hello").value == "hello"
|
||||
|
||||
def test_backspace_removes_last():
|
||||
f = _field("hello")
|
||||
f.handle_key(_curses.KEY_BACKSPACE)
|
||||
assert f.value == "hell"
|
||||
|
||||
def test_type_appends():
|
||||
f = _field("hi")
|
||||
f.handle_key(ord("!"))
|
||||
assert f.value == "hi!"
|
||||
|
||||
def test_left_then_insert():
|
||||
f = _field("ac")
|
||||
f.handle_key(_curses.KEY_LEFT)
|
||||
f.handle_key(_curses.KEY_LEFT)
|
||||
f.handle_key(ord("b"))
|
||||
assert f.value == "bac"
|
||||
|
||||
def test_delete_key():
|
||||
f = _field("abc")
|
||||
f.handle_key(_curses.KEY_HOME)
|
||||
f.handle_key(_curses.KEY_DC)
|
||||
assert f.value == "bc"
|
||||
|
||||
def test_clamp_bounds():
|
||||
assert clamp(5, 0, 10) == 5
|
||||
assert clamp(-1, 0, 10) == 0
|
||||
assert clamp(11, 0, 10) == 10
|
||||
Reference in New Issue
Block a user