Implements BaseRenderer abstract interface and CursesRenderer with green-on-black VT220 aesthetic. Includes all 18 widget methods: header, footer, menu, table, detail, form, filter list, comparison view, box drawing, message dialogs, and input prompts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
538 lines
19 KiB
Python
538 lines
19 KiB
Python
"""
|
|
Curses-based VT220 renderer for the AUTOPARTES console application.
|
|
|
|
Implements :class:`BaseRenderer` with a green-on-black aesthetic inspired
|
|
by classic Pick/UNIX VT220 terminals. All drawing is done through Python's
|
|
built-in :mod:`curses` library.
|
|
"""
|
|
|
|
import curses
|
|
|
|
from console.config import COLORS_VT220
|
|
from console.renderers.base import BaseRenderer
|
|
from console.utils.formatting import pad_right, truncate
|
|
|
|
# ── Colour-name-to-curses mapping ────────────────────────────────────
|
|
|
|
_CURSES_COLORS = {
|
|
"black": curses.COLOR_BLACK,
|
|
"red": curses.COLOR_RED,
|
|
"green": curses.COLOR_GREEN,
|
|
"yellow": curses.COLOR_YELLOW,
|
|
"blue": curses.COLOR_BLUE,
|
|
"magenta": curses.COLOR_MAGENTA,
|
|
"cyan": curses.COLOR_CYAN,
|
|
"white": curses.COLOR_WHITE,
|
|
}
|
|
|
|
# Box-drawing characters
|
|
_BOX_H = "\u2500" # ─
|
|
_BOX_V = "\u2502" # │
|
|
_BOX_TL = "\u250c" # ┌
|
|
_BOX_TR = "\u2510" # ┐
|
|
_BOX_BL = "\u2514" # └
|
|
_BOX_BR = "\u2518" # ┘
|
|
|
|
|
|
class CursesRenderer(BaseRenderer):
|
|
"""Full curses implementation of the VT220 green-on-black renderer."""
|
|
|
|
def __init__(self):
|
|
self._screen = None
|
|
self._color_pairs: dict[str, int] = {}
|
|
|
|
# ── Lifecycle ────────────────────────────────────────────────────
|
|
|
|
def init_screen(self):
|
|
"""Set up curses: raw mode, no echo, hidden cursor, colours."""
|
|
self._screen = curses.initscr()
|
|
curses.noecho()
|
|
curses.cbreak()
|
|
curses.curs_set(0)
|
|
self._screen.keypad(True)
|
|
self._init_colors()
|
|
|
|
def cleanup(self):
|
|
"""Restore the terminal to a usable state."""
|
|
if self._screen is None:
|
|
return
|
|
try:
|
|
curses.nocbreak()
|
|
self._screen.keypad(False)
|
|
curses.echo()
|
|
except curses.error:
|
|
pass
|
|
curses.endwin()
|
|
self._screen = None
|
|
|
|
# ── Screen queries ───────────────────────────────────────────────
|
|
|
|
def get_size(self) -> tuple:
|
|
"""Return ``(height, width)``."""
|
|
return self._screen.getmaxyx()
|
|
|
|
# ── Primitive operations ─────────────────────────────────────────
|
|
|
|
def clear(self):
|
|
self._screen.erase()
|
|
|
|
def refresh(self):
|
|
self._screen.refresh()
|
|
|
|
def get_key(self) -> int:
|
|
return self._screen.getch()
|
|
|
|
# ── Colour helpers ───────────────────────────────────────────────
|
|
|
|
def _init_colors(self):
|
|
"""Initialise curses colour pairs from ``COLORS_VT220``."""
|
|
curses.start_color()
|
|
curses.use_default_colors()
|
|
for idx, (name, (fg, bg)) in enumerate(COLORS_VT220.items(), start=1):
|
|
curses.init_pair(idx, _CURSES_COLORS[fg], _CURSES_COLORS[bg])
|
|
self._color_pairs[name] = idx
|
|
|
|
def _attr(self, style: str) -> int:
|
|
"""Return the curses attribute for a named style.
|
|
|
|
Falls back to the *normal* pair if *style* is unknown.
|
|
"""
|
|
pair_id = self._color_pairs.get(style,
|
|
self._color_pairs.get("normal", 1))
|
|
attr = curses.color_pair(pair_id)
|
|
if style in ("header", "title"):
|
|
attr |= curses.A_BOLD
|
|
return attr
|
|
|
|
# ── Safe drawing helpers ─────────────────────────────────────────
|
|
|
|
def _safe_addstr(self, row, col, text, attr=None):
|
|
"""Write *text* at (row, col), silently ignoring edge overflows."""
|
|
if attr is None:
|
|
attr = self._attr("normal")
|
|
h, w = self.get_size()
|
|
if row < 0 or row >= h or col >= w:
|
|
return
|
|
# Truncate to fit within the screen width
|
|
max_chars = w - col
|
|
if max_chars <= 0:
|
|
return
|
|
text = text[:max_chars]
|
|
try:
|
|
self._screen.addstr(row, col, text, attr)
|
|
except curses.error:
|
|
# Writing to the bottom-right corner raises an error after
|
|
# the character is actually drawn. Safe to ignore.
|
|
pass
|
|
|
|
def _hline(self, row, col, width, char=_BOX_H, style="border"):
|
|
"""Draw a horizontal line of *char* across *width* columns."""
|
|
self._safe_addstr(row, col, char * width, self._attr(style))
|
|
|
|
# ── High-level widgets ───────────────────────────────────────────
|
|
|
|
def draw_header(self, title, subtitle=''):
|
|
h, w = self.get_size()
|
|
attr = self._attr("header")
|
|
# Row 0: title (left) + subtitle (right)
|
|
header_line = pad_right(title, w)
|
|
if subtitle:
|
|
sub = subtitle[:w - len(title) - 1]
|
|
header_line = (title
|
|
+ " " * max(w - len(title) - len(sub), 0)
|
|
+ sub)
|
|
header_line = pad_right(header_line, w)
|
|
self._safe_addstr(0, 0, header_line, attr | curses.A_BOLD)
|
|
# Row 1: separator
|
|
self._hline(1, 0, w)
|
|
|
|
def draw_footer(self, key_labels):
|
|
h, w = self.get_size()
|
|
if h < 3:
|
|
return
|
|
# Row h-2: separator
|
|
self._hline(h - 2, 0, w)
|
|
# Row h-1: key labels
|
|
attr = self._attr("footer")
|
|
parts = [f"{k}={d}" for k, d in key_labels]
|
|
line = " ".join(parts)
|
|
self._safe_addstr(h - 1, 0, pad_right(line, w), attr)
|
|
|
|
def draw_menu(self, items, selected_index=0, title=''):
|
|
h, w = self.get_size()
|
|
start_row = 3
|
|
|
|
if title:
|
|
self._safe_addstr(start_row, 2, title, self._attr("title"))
|
|
start_row += 2
|
|
|
|
visible = h - start_row - 3 # leave room for footer
|
|
if visible < 1:
|
|
return
|
|
|
|
# Scrolling offset
|
|
offset = 0
|
|
if selected_index >= visible:
|
|
offset = selected_index - visible + 1
|
|
|
|
drawn = 0
|
|
for idx, (num, label) in enumerate(items):
|
|
if drawn >= visible:
|
|
break
|
|
if idx < offset:
|
|
continue
|
|
row = start_row + drawn
|
|
|
|
# Separator
|
|
if num == "\u2500" or num == "---":
|
|
self._hline(row, 2, w - 4)
|
|
drawn += 1
|
|
continue
|
|
|
|
marker = "\u25b8 " if idx == selected_index else " "
|
|
text = f"{marker}{num}. {label}"
|
|
style = "highlight" if idx == selected_index else "normal"
|
|
self._safe_addstr(row, 2, pad_right(text, w - 4),
|
|
self._attr(style))
|
|
drawn += 1
|
|
|
|
def draw_table(self, headers, rows, widths, page_info=None,
|
|
selected_row=-1):
|
|
h, w = self.get_size()
|
|
start_row = 3
|
|
|
|
# Header row
|
|
header_cells = [pad_right(hdr, wd) for hdr, wd in zip(headers, widths)]
|
|
header_text = " # " + " \u2502 ".join(header_cells)
|
|
self._safe_addstr(start_row, 0, pad_right(header_text, w),
|
|
self._attr("title"))
|
|
# Separator
|
|
self._hline(start_row + 1, 0, w)
|
|
|
|
visible = h - start_row - 5 # room for header, sep, footer
|
|
if visible < 1:
|
|
return
|
|
|
|
for i, row_data in enumerate(rows):
|
|
if i >= visible:
|
|
break
|
|
row_num = start_row + 2 + i
|
|
row_idx_str = pad_right(str(i + 1), 3)
|
|
cells = [pad_right(str(v), wd) for v, wd in zip(row_data, widths)]
|
|
line = f" {row_idx_str}\u2502 " + " \u2502 ".join(cells)
|
|
style = "highlight" if i == selected_row else "normal"
|
|
self._safe_addstr(row_num, 0, pad_right(line, w),
|
|
self._attr(style))
|
|
|
|
# Page info
|
|
if page_info:
|
|
info_row = start_row + 2 + min(len(rows), visible)
|
|
page = page_info.get("page", 1)
|
|
total = page_info.get("total_pages", 1)
|
|
total_rows = page_info.get("total_rows", len(rows))
|
|
info_text = (f" Pagina {page}/{total}"
|
|
f" ({total_rows} registros)")
|
|
self._safe_addstr(info_row, 0, info_text, self._attr("info"))
|
|
|
|
def draw_detail(self, fields, title=''):
|
|
h, w = self.get_size()
|
|
start_row = 3
|
|
|
|
if title:
|
|
self._safe_addstr(start_row, 2, title, self._attr("title"))
|
|
self._hline(start_row + 1, 2, w - 4)
|
|
start_row += 3
|
|
|
|
# Determine max label width for alignment
|
|
max_label = max((len(lbl) for lbl, _ in fields), default=10)
|
|
dot_total = max_label + 4 # label + dots
|
|
|
|
for i, (label, value) in enumerate(fields):
|
|
row = start_row + i
|
|
if row >= h - 3:
|
|
break
|
|
dots = "." * (dot_total - len(label))
|
|
label_part = f" {label}{dots}: "
|
|
self._safe_addstr(row, 0, label_part,
|
|
self._attr("field_label"))
|
|
self._safe_addstr(row, len(label_part), str(value),
|
|
self._attr("field_value"))
|
|
|
|
def draw_form(self, fields, focused_index=0, title=''):
|
|
h, w = self.get_size()
|
|
start_row = 3
|
|
|
|
if title:
|
|
self._safe_addstr(start_row, 2, title, self._attr("title"))
|
|
self._hline(start_row + 1, 2, w - 4)
|
|
start_row += 3
|
|
|
|
max_label = max((len(f.get("label", "")) for f in fields),
|
|
default=10)
|
|
dot_total = max_label + 4
|
|
|
|
for i, field in enumerate(fields):
|
|
row = start_row + i * 2 # space between fields
|
|
if row >= h - 3:
|
|
break
|
|
|
|
label = field.get("label", "")
|
|
value = field.get("value", "")
|
|
fw = field.get("width", 20)
|
|
hint = field.get("hint", "")
|
|
|
|
dots = "." * (dot_total - len(label))
|
|
num_str = f"{i + 1}. "
|
|
label_part = f" {num_str}{label}{dots}: "
|
|
|
|
self._safe_addstr(row, 0, label_part,
|
|
self._attr("field_label"))
|
|
|
|
# Editable field value in brackets
|
|
style = "field_active" if i == focused_index else "field_value"
|
|
display_val = pad_right(str(value), fw)
|
|
field_text = f"[{display_val}]"
|
|
self._safe_addstr(row, len(label_part), field_text,
|
|
self._attr(style))
|
|
|
|
# Optional hint
|
|
if hint:
|
|
hint_col = len(label_part) + len(field_text) + 2
|
|
self._safe_addstr(row, hint_col, hint,
|
|
self._attr("info"))
|
|
|
|
def draw_filter_list(self, items, filter_text, selected_index,
|
|
title=''):
|
|
h, w = self.get_size()
|
|
start_row = 3
|
|
|
|
if title:
|
|
self._safe_addstr(start_row, 2, title, self._attr("title"))
|
|
start_row += 1
|
|
|
|
# Separator
|
|
self._hline(start_row, 2, w - 4)
|
|
start_row += 1
|
|
|
|
# Filter input
|
|
prompt = "Filtro: "
|
|
self._safe_addstr(start_row, 2, prompt,
|
|
self._attr("field_label"))
|
|
self._safe_addstr(start_row, 2 + len(prompt),
|
|
filter_text + "_",
|
|
self._attr("field_active"))
|
|
start_row += 1
|
|
|
|
# Separator
|
|
self._hline(start_row, 2, w - 4)
|
|
start_row += 1
|
|
|
|
# Scrollable list
|
|
visible = h - start_row - 4
|
|
if visible < 1:
|
|
return
|
|
|
|
offset = 0
|
|
if selected_index >= visible:
|
|
offset = selected_index - visible + 1
|
|
|
|
drawn = 0
|
|
for idx, (num, label) in enumerate(items):
|
|
if drawn >= visible:
|
|
break
|
|
if idx < offset:
|
|
continue
|
|
row = start_row + drawn
|
|
marker = "\u25b8 " if idx == selected_index else " "
|
|
text = f"{marker}{num}. {label}"
|
|
style = "highlight" if idx == selected_index else "normal"
|
|
self._safe_addstr(row, 2, pad_right(text, w - 4),
|
|
self._attr(style))
|
|
drawn += 1
|
|
|
|
# Count at bottom
|
|
count_row = start_row + min(drawn, visible)
|
|
count_text = f" {len(items)} elementos"
|
|
self._safe_addstr(count_row, 2, count_text, self._attr("info"))
|
|
|
|
def draw_comparison(self, columns, title=''):
|
|
h, w = self.get_size()
|
|
start_row = 3
|
|
|
|
if title:
|
|
self._safe_addstr(start_row, 2, title, self._attr("title"))
|
|
self._hline(start_row + 1, 2, w - 4)
|
|
start_row += 3
|
|
|
|
n_cols = len(columns)
|
|
if n_cols == 0:
|
|
return
|
|
|
|
# Determine label width from the first column's row labels
|
|
all_labels = []
|
|
for col in columns:
|
|
for lbl, _ in col.get("rows", []):
|
|
all_labels.append(lbl)
|
|
label_w = max((len(l) for l in all_labels), default=8) + 2
|
|
|
|
# Available width for data columns
|
|
avail = w - label_w - 4
|
|
col_w = max(avail // n_cols, 10)
|
|
|
|
# Header row
|
|
header_line = pad_right("", label_w)
|
|
for col in columns:
|
|
header_line += " \u2502 " + pad_right(col.get("header", ""), col_w)
|
|
self._safe_addstr(start_row, 2, header_line, self._attr("title"))
|
|
|
|
# Separator
|
|
self._hline(start_row + 1, 2, w - 4)
|
|
|
|
# Data rows — use the first column's labels as the canonical set
|
|
if not columns[0].get("rows"):
|
|
return
|
|
n_rows = len(columns[0]["rows"])
|
|
for i in range(n_rows):
|
|
row = start_row + 2 + i
|
|
if row >= h - 3:
|
|
break
|
|
lbl = columns[0]["rows"][i][0] if i < len(columns[0]["rows"]) else ""
|
|
line = pad_right(lbl, label_w)
|
|
for col in columns:
|
|
rows_data = col.get("rows", [])
|
|
val = rows_data[i][1] if i < len(rows_data) else ""
|
|
line += " \u2502 " + pad_right(str(val), col_w)
|
|
self._safe_addstr(row, 2, line, self._attr("normal"))
|
|
|
|
# ── Low-level drawing ────────────────────────────────────────────
|
|
|
|
def draw_text(self, row, col, text, style='normal'):
|
|
self._safe_addstr(row, col, text, self._attr(style))
|
|
|
|
def draw_box(self, top, left, height, width, title=''):
|
|
if height < 2 or width < 2:
|
|
return
|
|
attr = self._attr("border")
|
|
|
|
# Top border
|
|
top_line = _BOX_TL + _BOX_H * (width - 2) + _BOX_TR
|
|
if title:
|
|
t = truncate(title, width - 4)
|
|
top_line = (_BOX_TL + _BOX_H + t
|
|
+ _BOX_H * (width - 3 - len(t)) + _BOX_TR)
|
|
self._safe_addstr(top, left, top_line, attr)
|
|
|
|
# Side borders
|
|
for r in range(1, height - 1):
|
|
self._safe_addstr(top + r, left, _BOX_V, attr)
|
|
self._safe_addstr(top + r, left + width - 1, _BOX_V, attr)
|
|
|
|
# Bottom border
|
|
bottom_line = _BOX_BL + _BOX_H * (width - 2) + _BOX_BR
|
|
self._safe_addstr(top + height - 1, left, bottom_line, attr)
|
|
|
|
# ── Dialogs ──────────────────────────────────────────────────────
|
|
|
|
def show_message(self, text, msg_type='info') -> bool:
|
|
h, w = self.get_size()
|
|
lines = text.split("\n")
|
|
box_w = max(max((len(l) for l in lines), default=20) + 6, 30)
|
|
box_h = len(lines) + 4
|
|
top = max((h - box_h) // 2, 0)
|
|
left = max((w - box_w) // 2, 0)
|
|
|
|
style = "error" if msg_type == "error" else "info"
|
|
self.draw_box(top, left, box_h, box_w)
|
|
|
|
# Fill interior with spaces
|
|
interior_attr = self._attr(style)
|
|
for r in range(1, box_h - 1):
|
|
self._safe_addstr(top + r, left + 1,
|
|
" " * (box_w - 2), interior_attr)
|
|
|
|
# Draw message lines
|
|
for i, line in enumerate(lines):
|
|
x = left + max((box_w - len(line)) // 2, 2)
|
|
self._safe_addstr(top + 1 + i, x, line, interior_attr)
|
|
|
|
# Prompt line
|
|
if msg_type == "confirm":
|
|
prompt = "[S]i / [N]o"
|
|
else:
|
|
prompt = "Presione cualquier tecla..."
|
|
px = left + max((box_w - len(prompt)) // 2, 2)
|
|
self._safe_addstr(top + box_h - 2, px, prompt,
|
|
self._attr("highlight"))
|
|
self.refresh()
|
|
|
|
# Wait for key
|
|
if msg_type == "confirm":
|
|
while True:
|
|
key = self.get_key()
|
|
if key in (ord("s"), ord("S")):
|
|
return True
|
|
if key in (ord("n"), ord("N"), 27): # 27 = ESC
|
|
return False
|
|
else:
|
|
self.get_key()
|
|
return True
|
|
|
|
def show_input(self, prompt, max_len=40):
|
|
h, w = self.get_size()
|
|
box_w = max(len(prompt) + max_len + 8, 30)
|
|
box_h = 5
|
|
top = max((h - box_h) // 2, 0)
|
|
left = max((w - box_w) // 2, 0)
|
|
|
|
buf = []
|
|
|
|
try:
|
|
curses.curs_set(1) # show cursor during input
|
|
except curses.error:
|
|
pass
|
|
|
|
while True:
|
|
self.draw_box(top, left, box_h, box_w)
|
|
# Fill interior
|
|
interior_attr = self._attr("normal")
|
|
for r in range(1, box_h - 1):
|
|
self._safe_addstr(top + r, left + 1,
|
|
" " * (box_w - 2), interior_attr)
|
|
|
|
# Prompt
|
|
self._safe_addstr(top + 1, left + 2, prompt,
|
|
self._attr("field_label"))
|
|
|
|
# Input field
|
|
val = "".join(buf)
|
|
field_text = "[" + pad_right(val, max_len) + "]"
|
|
self._safe_addstr(top + 2, left + 2, field_text,
|
|
self._attr("field_active"))
|
|
|
|
# Hint
|
|
hint = "ENTER=Aceptar ESC=Cancelar"
|
|
hx = left + max((box_w - len(hint)) // 2, 2)
|
|
self._safe_addstr(top + 3, hx, hint, self._attr("info"))
|
|
|
|
self.refresh()
|
|
|
|
key = self.get_key()
|
|
if key == 27: # ESC
|
|
try:
|
|
curses.curs_set(0)
|
|
except curses.error:
|
|
pass
|
|
return None
|
|
elif key in (10, curses.KEY_ENTER): # ENTER
|
|
try:
|
|
curses.curs_set(0)
|
|
except curses.error:
|
|
pass
|
|
return "".join(buf)
|
|
elif key in (127, curses.KEY_BACKSPACE, 8): # BACKSPACE
|
|
if buf:
|
|
buf.pop()
|
|
elif 32 <= key <= 126: # printable ASCII
|
|
if len(buf) < max_len:
|
|
buf.append(chr(key))
|