feat: TUI modal dialogs (confirm Y/N, cert detail form)

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

View File

@@ -453,6 +453,138 @@ class InputField:
self._cur += 1
# ============================================================
# === TUI DIALOGS ===
# ============================================================
def show_confirm(stdscr, message: str) -> bool:
"""Y/N modal dialog. Returns True if user presses y/Y."""
sh, sw = stdscr.getmaxyx()
lines = message.splitlines()
h = len(lines) + 6
w = max(max(len(l) for l in lines) + 6, 38)
win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2)
win.keypad(True)
draw_box(win, "Confirm")
for i, line in enumerate(lines):
try:
win.addstr(2 + i, 3, line, curses.color_pair(COLOR_NORMAL))
except curses.error:
pass
prompt = " [Y] Confirm [N / Esc] Cancel "
try:
win.addstr(h - 2, max(1, (w - len(prompt)) // 2), prompt,
curses.color_pair(COLOR_NORMAL))
except curses.error:
pass
win.refresh()
while True:
key = win.getch()
if key in (ord("y"), ord("Y")):
return True
if key in (ord("n"), ord("N"), 27, ord("q")):
return False
@dataclass
class CertFormResult:
cn: str
email: str
password: str
cancelled: bool
_FORM_FIELDS = ["cn", "email", "password"]
_FORM_LABELS = {
"cn": "Common Name (CN):",
"email": "Email address:",
"password": "Password [F5 = regenerate]:",
}
_FORM_FIELD_Y = {"cn": 3, "email": 6, "password": 9}
def show_cert_form(
stdscr,
cn: str = "",
email: str = "",
cn_readonly: bool = False,
) -> CertFormResult:
"""Modal cert-detail form.
Tab / Shift-Tab cycle fields. F5 regenerates password.
Enter on last field confirms. Escape cancels."""
sh, sw = stdscr.getmaxyx()
h, w = 16, 62
win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2)
win.keypad(True)
fw = w - 6 # field width
active = [n for n in _FORM_FIELDS if not (n == "cn" and cn_readonly)]
fields: dict[str, InputField] = {
"cn": InputField(win, _FORM_FIELD_Y["cn"], 3, fw, initial=cn),
"email": InputField(win, _FORM_FIELD_Y["email"], 3, fw, initial=email),
"password": InputField(win, _FORM_FIELD_Y["password"], 3, fw,
initial=generate_password()),
}
focus = 0
while True:
win.clear()
draw_box(win, "Certificate Details")
for name in _FORM_FIELDS:
fy = _FORM_FIELD_Y[name]
label = _FORM_LABELS[name]
ro = name == "cn" and cn_readonly
attr = curses.color_pair(COLOR_DISABLED if ro else COLOR_NORMAL)
try:
win.addstr(fy - 1, 3, label, attr)
except curses.error:
pass
if ro:
try:
win.addstr(fy, 3, cn.ljust(fw)[:fw],
curses.color_pair(COLOR_DISABLED) | curses.A_UNDERLINE)
except curses.error:
pass
else:
fields[name].draw()
hint = "Tab=next F5=regen password Enter=confirm Esc=cancel"
try:
win.addstr(h - 2, 2, hint[:w - 4], curses.color_pair(COLOR_DISABLED))
except curses.error:
pass
win.refresh()
key = win.getch()
if key == 27:
return CertFormResult(cn=cn, email="", password="", cancelled=True)
if key == 9: # Tab
focus = (focus + 1) % len(active)
continue
if key == curses.KEY_BTAB: # Shift-Tab
focus = (focus - 1) % len(active)
continue
if key == curses.KEY_F5:
fields["password"] = InputField(
win, _FORM_FIELD_Y["password"], 3, fw, initial=generate_password(),
)
continue
if key in (10, 13, curses.KEY_ENTER):
if focus < len(active) - 1:
focus += 1
continue
final_cn = cn if cn_readonly else fields["cn"].value
return CertFormResult(
cn=final_cn,
email=fields["email"].value,
password=fields["password"].value,
cancelled=False,
)
fields[active[focus]].handle_key(key)
# (remaining sections added in later tasks)
# ============================================================

96
tests/test_dialogs.py Normal file
View File

@@ -0,0 +1,96 @@
"""Tests for TUI DIALOGS (show_confirm, show_cert_form).
Uses the same mock-curses approach as test_widgets.py — no real terminal needed.
"""
from unittest.mock import MagicMock, patch
import curses as _curses
# Stub curses constants before importing the module under test, mirroring
# what test_widgets.py does for InputField's dependencies.
_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
_curses.KEY_BTAB = 353
_curses.KEY_F5 = 269
_curses.KEY_ENTER = 343
from openvpncertupdate import show_confirm, show_cert_form, CertFormResult
def _make_stdscr(rows=40, cols=120):
"""Return a minimal MagicMock that looks like a curses stdscr."""
stdscr = MagicMock()
stdscr.getmaxyx.return_value = (rows, cols)
return stdscr
def _make_win(rows=20, cols=60):
"""Return a minimal MagicMock for the subwindow created by curses.newwin."""
win = MagicMock()
win.getmaxyx.return_value = (rows, cols)
return win
# ---------------------------------------------------------------------------
# show_confirm tests
# ---------------------------------------------------------------------------
def test_show_confirm_yes():
"""Pressing 'y' returns True."""
stdscr = _make_stdscr()
win = _make_win()
win.getch.side_effect = [ord("y")]
with patch("curses.newwin", return_value=win):
result = show_confirm(stdscr, "Do you want to continue?")
assert result is True
def test_show_confirm_esc():
"""Pressing Escape (27) returns False."""
stdscr = _make_stdscr()
win = _make_win()
win.getch.side_effect = [27]
with patch("curses.newwin", return_value=win):
result = show_confirm(stdscr, "Do you want to continue?")
assert result is False
# ---------------------------------------------------------------------------
# show_cert_form tests
# ---------------------------------------------------------------------------
def test_show_cert_form_cancel():
"""Pressing Escape returns a CertFormResult with cancelled=True."""
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
win.getch.side_effect = [27]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="alice", email="alice@example.com")
assert isinstance(result, CertFormResult)
assert result.cancelled is True
def test_show_cert_form_confirm():
"""Pressing Enter three times advances through all 3 fields and confirms.
The form has 3 active fields (cn, email, password). Enter on field 0
advances to field 1; Enter on field 1 advances to field 2; Enter on
field 2 (the last) confirms. We therefore need 3 Enter keypresses.
"""
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
# Three Enter keypresses to advance through each field and confirm.
win.getch.side_effect = [10, 10, 10]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="bob", email="bob@example.com")
assert isinstance(result, CertFormResult)
assert result.cancelled is False
assert result.cn == "bob"
assert result.email == "bob@example.com"
assert len(result.password) > 0