diff --git a/openvpncertupdate.py b/openvpncertupdate.py index e6de6bc..e4967c9 100644 --- a/openvpncertupdate.py +++ b/openvpncertupdate.py @@ -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) # ============================================================ diff --git a/tests/test_widgets.py b/tests/test_widgets.py new file mode 100644 index 0000000..a429ce0 --- /dev/null +++ b/tests/test_widgets.py @@ -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