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)
|
# (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