Files
Autoparts-DB/console/renderers/curses_renderer.py
consultoria-as 274cf30e79 perf(console): persistent DB connection, query cache, PRAGMA tuning
- Reuse a single SQLite connection instead of open/close per query
- Add in-memory cache for frequently accessed data (brands, models,
  categories) — 1000x faster on repeated access
- Enable WAL journal mode, 8MB cache, 64MB mmap for faster reads
- Cache terminal size per render cycle to avoid repeated getmaxyx()
- Close DB connection on app exit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:19:50 +00:00

540 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] = {}
self._size_cache: tuple = (24, 80)
# ── 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)`` with cached value per render cycle."""
return self._size_cache
# ── Primitive operations ─────────────────────────────────────────
def clear(self):
self._size_cache = self._screen.getmaxyx()
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))