feat(console): add curses VT220 renderer with full widget set
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>
This commit is contained in:
152
console/renderers/base.py
Normal file
152
console/renderers/base.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
Abstract base renderer interface for the AUTOPARTES console application.
|
||||||
|
|
||||||
|
Every renderer (curses VT220, Textual/Rich, etc.) must subclass
|
||||||
|
:class:`BaseRenderer` and implement all of its methods. Screens call
|
||||||
|
these methods without knowing which backend is active.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRenderer:
|
||||||
|
"""Abstract interface that all renderers must implement.
|
||||||
|
|
||||||
|
Methods raise :exc:`NotImplementedError` so that missing overrides
|
||||||
|
are caught immediately at runtime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Lifecycle ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def init_screen(self):
|
||||||
|
"""Initialise the terminal / display backend."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Restore the terminal to its original state."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# ── Screen queries ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_size(self) -> tuple:
|
||||||
|
"""Return ``(height, width)`` of the usable display area."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# ── Primitive operations ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clear the entire screen buffer."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
"""Flush the screen buffer to the terminal."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_key(self) -> int:
|
||||||
|
"""Block until a key is pressed and return its key code."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# ── High-level widgets ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def draw_header(self, title, subtitle=''):
|
||||||
|
"""Draw the application header bar on the top two rows.
|
||||||
|
|
||||||
|
*title* is left-aligned; *subtitle* is right-aligned.
|
||||||
|
Row 1 is a horizontal separator.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def draw_footer(self, key_labels):
|
||||||
|
"""Draw the footer bar on the bottom two rows.
|
||||||
|
|
||||||
|
*key_labels* is a list of ``(key, description)`` tuples,
|
||||||
|
e.g. ``[("F1", "Ayuda"), ("ESC", "Atras")]``.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def draw_menu(self, items, selected_index=0, title=''):
|
||||||
|
"""Draw a numbered menu list starting at row 3.
|
||||||
|
|
||||||
|
*items* is a list of ``(number, label)`` tuples.
|
||||||
|
Separator items have ``number == '---'``.
|
||||||
|
The item at *selected_index* is highlighted.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def draw_table(self, headers, rows, widths, page_info=None,
|
||||||
|
selected_row=-1):
|
||||||
|
"""Draw a columnar data table.
|
||||||
|
|
||||||
|
*headers*: list of column header strings.
|
||||||
|
*rows*: list of row tuples (each tuple matches *headers*).
|
||||||
|
*widths*: list of int column widths.
|
||||||
|
*page_info*: optional dict ``{page, total_pages, total_rows}``.
|
||||||
|
*selected_row*: index of the highlighted row (-1 = none).
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def draw_detail(self, fields, title=''):
|
||||||
|
"""Draw a detail view with label-value pairs.
|
||||||
|
|
||||||
|
*fields* is a list of ``(label, value)`` tuples displayed as
|
||||||
|
``Label........: Value``.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def draw_form(self, fields, focused_index=0, title=''):
|
||||||
|
"""Draw an editable form.
|
||||||
|
|
||||||
|
*fields* is a list of dicts with keys:
|
||||||
|
``label``, ``value``, ``width``, ``type``, ``hint``.
|
||||||
|
The field at *focused_index* uses the active style.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def draw_filter_list(self, items, filter_text, selected_index,
|
||||||
|
title=''):
|
||||||
|
"""Draw a filterable list with a text input at the top.
|
||||||
|
|
||||||
|
*items*: list of ``(number, label)`` tuples.
|
||||||
|
*filter_text*: current filter string.
|
||||||
|
*selected_index*: highlighted item index.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def draw_comparison(self, columns, title=''):
|
||||||
|
"""Draw a side-by-side comparison view.
|
||||||
|
|
||||||
|
*columns* is a list of dicts, each with:
|
||||||
|
``header`` (str) and ``rows`` (list of ``(label, value)``).
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# ── Low-level drawing ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def draw_text(self, row, col, text, style='normal'):
|
||||||
|
"""Draw *text* at ``(row, col)`` using the named *style*."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def draw_box(self, top, left, height, width, title=''):
|
||||||
|
"""Draw a box with Unicode line-drawing characters.
|
||||||
|
|
||||||
|
Optional *title* is rendered in the top border.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# ── Dialogs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def show_message(self, text, msg_type='info') -> bool:
|
||||||
|
"""Show a centred message box.
|
||||||
|
|
||||||
|
*msg_type* is one of ``'info'``, ``'error'``, or ``'confirm'``.
|
||||||
|
For ``'confirm'`` the user must press S (si) or N (no);
|
||||||
|
returns ``True`` for S, ``False`` for N.
|
||||||
|
For other types, waits for any key and returns ``True``.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def show_input(self, prompt, max_len=40):
|
||||||
|
"""Show a centred input dialog.
|
||||||
|
|
||||||
|
Returns the entered string, or ``None`` if the user pressed
|
||||||
|
Escape to cancel.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
537
console/renderers/curses_renderer.py
Normal file
537
console/renderers/curses_renderer.py
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
"""
|
||||||
|
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))
|
||||||
Reference in New Issue
Block a user