fix invisible TUI cursor/buttons on serial consoles with broken color pairs

init_colors() now survives a terminal rejecting every init_pair() call
(some serial-console terminfo entries claim start_color()/COLORS support
but can't actually set custom pairs), but that exposed a follow-on bug:
the list cursor and the cert-form Continue/Cancel buttons indicated
selection purely via the COLOR_SELECTED color pair, so once that pair
silently no-ops the current row/button becomes indistinguishable from
an unselected one. Add curses.A_REVERSE alongside the color pair for
the selected state — a plain video attribute that doesn't depend on
custom-pair support, so the cursor stays visible either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vlad Doloman
2026-07-08 16:07:50 +03:00
parent 4798ce3f18
commit 17abe31650
3 changed files with 53 additions and 9 deletions

View File

@@ -107,6 +107,20 @@ def test_capital_Q_exits():
assert result.action == Action.EXIT
def test_cursor_row_has_reverse_attribute():
# A_REVERSE must mark the cursor row so it's still visible even when a
# terminal (e.g. a serial console) silently rejects the COLOR_SELECTED
# pair and color_pair() becomes a no-op.
stdscr = _make_stdscr()
stdscr.getch.side_effect = [ord("q")]
with patch("openvpncertupdate.init_colors"):
show_main_screen(stdscr, _CERTS) # cursor defaults to row 0 ("bob")
bob_calls = [c for c in stdscr.addstr.call_args_list if "bob" in str(c.args[2])]
alice_calls = [c for c in stdscr.addstr.call_args_list if "alice" in str(c.args[2])]
assert bob_calls and bob_calls[0].args[3] & _curses.A_REVERSE
assert alice_calls and not (alice_calls[0].args[3] & _curses.A_REVERSE)
# ---------------------------------------------------------------------------
# Enter on cert → RENEW_SELECTED
# ---------------------------------------------------------------------------

View File

@@ -82,3 +82,15 @@ def test_init_colors_falls_back_on_serial_console():
disabled = next(c for c in calls if c[0] == COLOR_DISABLED)
assert normal[2] == _curses.COLOR_BLACK
assert disabled[1:] == (_curses.COLOR_WHITE, _curses.COLOR_BLACK)
def test_init_colors_survives_init_pair_rejecting_every_pair():
# Some serial-console terminfo entries claim start_color()/COLORS
# support but reject every init_pair() call (e.g. COLOR_PAIRS too
# small). init_colors() must not raise -- the app should still start,
# just without custom colors.
with patch.object(_curses, "start_color"), \
patch.object(_curses, "use_default_colors"), \
patch.object(_curses, "init_pair",
side_effect=_curses.error("init_pair() returned ERR")), \
patch.object(_curses, "COLORS", 256, create=True):
init_colors() # must not raise