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

@@ -541,12 +541,22 @@ def init_colors() -> None:
# (terminfo has no "op"/"AX" capability); fall back to explicit black.
bg = curses.COLOR_BLACK
disabled_fg = 8 if curses.COLORS > 8 else curses.COLOR_WHITE
curses.init_pair(COLOR_NORMAL, curses.COLOR_WHITE, bg)
curses.init_pair(COLOR_SELECTED, curses.COLOR_BLACK, curses.COLOR_CYAN)
curses.init_pair(COLOR_DISABLED, disabled_fg, bg)
curses.init_pair(COLOR_TITLE, curses.COLOR_WHITE, bg)
curses.init_pair(COLOR_ERROR, curses.COLOR_RED, bg)
curses.init_pair(COLOR_SUCCESS, curses.COLOR_GREEN, bg)
# Some serial-console terminfo entries advertise start_color()/COLORS
# support but still can't actually set custom pairs (e.g. COLOR_PAIRS is
# too small). Set each pair independently so a rejected pair just falls
# back to the terminal's default rendering instead of crashing the app.
for pair_id, fg, pair_bg in (
(COLOR_NORMAL, curses.COLOR_WHITE, bg),
(COLOR_SELECTED, curses.COLOR_BLACK, curses.COLOR_CYAN),
(COLOR_DISABLED, disabled_fg, bg),
(COLOR_TITLE, curses.COLOR_WHITE, bg),
(COLOR_ERROR, curses.COLOR_RED, bg),
(COLOR_SUCCESS, curses.COLOR_GREEN, bg),
):
try:
curses.init_pair(pair_id, fg, pair_bg)
except curses.error:
pass
def draw_box(win, title: str = "") -> None:
@@ -752,9 +762,10 @@ def show_cert_form(
("Continue", _BTN_CONTINUE, _btn_x_cont),
("Cancel", _BTN_CANCEL, _btn_x_canc),
):
btn_attr = (curses.color_pair(COLOR_SELECTED)
if current == btn_id
else curses.color_pair(COLOR_NORMAL))
if current == btn_id:
btn_attr = curses.color_pair(COLOR_SELECTED) | curses.A_REVERSE
else:
btn_attr = curses.color_pair(COLOR_NORMAL)
try:
win.addstr(_BTN_Y, bx, f"[ {btn_label} ]", btn_attr | curses.A_BOLD)
except curses.error:
@@ -905,6 +916,11 @@ def show_main_screen(
break
is_cur = (i == cursor)
sel_attr = curses.color_pair(COLOR_SELECTED if is_cur else COLOR_NORMAL)
if is_cur:
# A_REVERSE keeps the cursor visible even when the terminal
# (e.g. a serial console) silently rejects the COLOR_SELECTED
# pair and this color_pair() call is a no-op.
sel_attr |= curses.A_REVERSE
if is_cert(i):
cert = certs[i]
@@ -922,6 +938,8 @@ def show_main_screen(
notice = " ← clear all checkboxes to use"
line = f" {item}{notice}"
attr = curses.color_pair(COLOR_SELECTED if is_cur else COLOR_DISABLED)
if is_cur:
attr |= curses.A_REVERSE
else:
line = f" {item}"
attr = sel_attr