refactor(console): remove modern renderer, keep VT220 only

Remove the Rich-based textual renderer and all --mode modern references.
The console now runs exclusively in VT220 curses mode (green on black).
No external dependencies required.

Removed: console/renderers/textual_renderer.py, --mode flag, DEFAULT_MODE
Updated: main.py, config.py, README.md, console/README.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 02:37:35 +00:00
parent 7866194e65
commit 3ea2de61e2
5 changed files with 29 additions and 768 deletions

View File

@@ -66,7 +66,7 @@ Autopartes/
│ ├── db.py # Capa de datos abstracta │ ├── db.py # Capa de datos abstracta
│ ├── core/ # Framework (app, screens, nav, keys) │ ├── core/ # Framework (app, screens, nav, keys)
│ ├── screens/ # 14 pantallas (menú, CRUD, búsqueda) │ ├── screens/ # 14 pantallas (menú, CRUD, búsqueda)
│ ├── renderers/ # VT220 (curses) y moderno (Rich) │ ├── renderers/ # Renderer VT220 (curses)
│ ├── utils/ # Formato y API VIN │ ├── utils/ # Formato y API VIN
│ └── tests/ # 116 tests │ └── tests/ # 116 tests
@@ -84,17 +84,10 @@ Autopartes/
## Consola Pick/VT220 ## Consola Pick/VT220
Interfaz de terminal inspirada en los sistemas Pick/D3, 100% operada con teclado. Incluye dos modos de visualización: Interfaz de terminal inspirada en los sistemas Pick/D3, 100% operada con teclado. Estética verde sobre negro con caracteres de caja, sin dependencias externas.
- **VT220** (curses): Terminal clásica verde sobre negro con caracteres de caja
- **Modern** (Rich): Interfaz moderna con colores y estilos TUI
```bash ```bash
# Modo clásico VT220
python -m console python -m console
# Modo moderno
python -m console --mode modern
``` ```
Funcionalidades: navegación por vehículo (marca→modelo→año→motor), búsqueda por número de parte, búsqueda full-text, decodificador VIN (NHTSA), catálogo por categorías, comparador OEM vs aftermarket, y administración CRUD completa. Funcionalidades: navegación por vehículo (marca→modelo→año→motor), búsqueda por número de parte, búsqueda full-text, decodificador VIN (NHTSA), catálogo por categorías, comparador OEM vs aftermarket, y administración CRUD completa.
@@ -119,7 +112,6 @@ Funcionalidades: navegación por vehículo (marca→modelo→año→motor), bús
2. **Instalar dependencias** 2. **Instalar dependencias**
```bash ```bash
pip install flask requests beautifulsoup4 lxml pip install flask requests beautifulsoup4 lxml
pip install rich # Opcional: para modo moderno de consola
``` ```
3. **Inicializar la base de datos (opcional - ya incluye datos)** 3. **Inicializar la base de datos (opcional - ya incluye datos)**
@@ -142,8 +134,7 @@ El dashboard estará disponible en: `http://localhost:5000`
### Iniciar la Consola Pick/VT220 ### Iniciar la Consola Pick/VT220
```bash ```bash
python -m console # Modo VT220 (clásico) python -m console
python -m console --mode modern # Modo moderno (Rich)
``` ```
### Usar la Interfaz CLI Legacy ### Usar la Interfaz CLI Legacy

View File

@@ -1,26 +1,20 @@
# AUTOPARTES Console - Sistema Pick/VT220 # AUTOPARTES Console - Sistema Pick/VT220
Interfaz de consola para el catálogo de autopartes, inspirada en los sistemas Pick/D3 con estética de terminal VT220. Funciona 100% con teclado. Interfaz de consola para el catálogo de autopartes, inspirada en los sistemas Pick/D3 con estética de terminal VT220. Funciona 100% con teclado, verde sobre negro.
## Requisitos ## Requisitos
- Python 3.8+ - Python 3.8+
- SQLite 3 (incluido con Python) - SQLite 3 (incluido con Python)
- Paquete `rich` (solo para modo moderno)
```bash No requiere dependencias externas.
pip install rich # Opcional, solo para --mode modern
```
## Inicio Rápido ## Inicio Rápido
```bash ```bash
# Modo VT220 (clásico, verde sobre negro) # Iniciar la consola
python -m console python -m console
# Modo moderno (Rich/TUI con colores)
python -m console --mode modern
# Especificar base de datos # Especificar base de datos
python -m console --db /ruta/a/vehicle_database.db python -m console --db /ruta/a/vehicle_database.db
@@ -28,41 +22,25 @@ python -m console --db /ruta/a/vehicle_database.db
python -m console --version python -m console --version
``` ```
## Modos de Visualización
### VT220 (por defecto)
- Terminal clásica verde sobre negro
- Caracteres de dibujo de cajas (box-drawing)
- Compatible con cualquier terminal
- Usa la librería `curses` (incluida en Python)
### Modern
- Interfaz moderna con colores y estilos Rich
- Tema azul/cian
- Requiere `pip install rich`
- Si `rich` no está instalado, cae automáticamente a modo VT220
## Menú Principal ## Menú Principal
``` ```
╔══════════════════════════════════════╗ ┌──────────────────────────────────────────┐
AUTOPARTES v1.0.0 │ MENU PRINCIPAL
Sistema de Catalogo de Autopartes ║ ├──────────────────────────────────────────┤
╠══════════════════════════════════════╣ │ ▸ 1. Consulta por Vehiculo │
║ 1. Buscar por Vehiculo │ 2. Busqueda por Numero de Parte
2. Buscar por Numero de Parte │ 3. Busqueda por Descripcion
║ 3. Buscar por Texto │ 4. Decodificador VIN
║ 4. Decodificar VIN │ 5. Catalogo de Categorias
5. Catalogo por Categoria ║ ├──────────────────────────────────────────┤
║ ────────────────────────── │ 6. Administracion de Partes
6. Admin: Partes 7. Administracion de Fabricantes
║ 7. Admin: Fabricantes │ 8. Cross-References
║ 8. Admin: Referencias Cruzadas ║ │ 9. Importar / Exportar Datos │
║ 9. Import/Export ║ ├──────────────────────────────────────────┤
║ ────────────────────────── │ 0. Estadisticas del Sistema
S. Estadisticas del Sistema ║ └──────────────────────────────────────────┘
║ 0. Salir ║
╚══════════════════════════════════════╝
``` ```
## Teclas de Función ## Teclas de Función
@@ -122,7 +100,7 @@ Dashboard con contadores de la base de datos (marcas, modelos, partes, etc.) y m
``` ```
console/ console/
├── main.py # Punto de entrada, --mode vt220|modern ├── main.py # Punto de entrada
├── config.py # Configuración (DB, colores, paginación) ├── config.py # Configuración (DB, colores, paginación)
├── db.py # Capa de datos abstracta (SQLite) ├── db.py # Capa de datos abstracta (SQLite)
@@ -149,8 +127,7 @@ console/
├── renderers/ ├── renderers/
│ ├── base.py # Interfaz abstracta BaseRenderer │ ├── base.py # Interfaz abstracta BaseRenderer
── curses_renderer.py # Modo VT220 (curses) ── curses_renderer.py # Renderer VT220 (curses)
│ └── textual_renderer.py # Modo moderno (Rich)
├── utils/ ├── utils/
│ ├── formatting.py # Formato de tablas, moneda, números │ ├── formatting.py # Formato de tablas, moneda, números

View File

@@ -19,7 +19,6 @@ NHTSA_API_URL = "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin"
VIN_CACHE_DAYS = 30 VIN_CACHE_DAYS = 30
# Display defaults # Display defaults
DEFAULT_MODE = "vt220"
PAGE_SIZE = 15 PAGE_SIZE = 15
# VT220 color pairs: (foreground, background) # VT220 color pairs: (foreground, background)

View File

@@ -11,7 +11,7 @@ import argparse
import os import os
import sys import sys
from console.config import VERSION, APP_NAME, APP_SUBTITLE, DB_PATH, DEFAULT_MODE from console.config import VERSION, APP_NAME, APP_SUBTITLE, DB_PATH
def parse_args(argv=None): def parse_args(argv=None):
@@ -20,12 +20,6 @@ def parse_args(argv=None):
prog=APP_NAME.lower(), prog=APP_NAME.lower(),
description=f"{APP_NAME} - {APP_SUBTITLE}", description=f"{APP_NAME} - {APP_SUBTITLE}",
) )
parser.add_argument(
"--mode",
choices=["vt220", "modern"],
default=DEFAULT_MODE,
help=f"Display mode (default: {DEFAULT_MODE})",
)
parser.add_argument( parser.add_argument(
"--version", "--version",
action="version", action="version",
@@ -39,14 +33,13 @@ def parse_args(argv=None):
return parser.parse_args(argv) return parser.parse_args(argv)
def _print_banner(mode, db_path): def _print_banner(db_path):
"""Print a startup banner before entering terminal mode.""" """Print a startup banner before entering terminal mode."""
border = "=" * 58 border = "=" * 58
print(border) print(border)
print(f" {APP_NAME} v{VERSION}") print(f" {APP_NAME} v{VERSION}")
print(f" {APP_SUBTITLE}") print(f" {APP_SUBTITLE}")
print(border) print(border)
print(f" Mode : {mode}")
print(f" DB : {db_path}") print(f" DB : {db_path}")
print(border) print(border)
print() print()
@@ -57,7 +50,6 @@ def main(argv=None):
args = parse_args(argv) args = parse_args(argv)
db_path = args.db db_path = args.db
mode = args.mode
# Verify the database file exists before proceeding # Verify the database file exists before proceeding
if not os.path.isfile(db_path): if not os.path.isfile(db_path):
@@ -79,26 +71,10 @@ def main(argv=None):
from console.core.app import App from console.core.app import App
# Print startup banner # Print startup banner
_print_banner(mode, db_path) _print_banner(db_path)
db = Database(db_path) db = Database(db_path)
# Select renderer based on mode
if mode == "modern":
try:
from console.renderers.textual_renderer import TextualRenderer
renderer = TextualRenderer()
except ImportError:
print(
"Warning: 'modern' mode requires the 'rich' package.\n"
"Falling back to vt220 mode.\n"
"Install with: pip install rich\n",
file=sys.stderr,
)
renderer = CursesRenderer() renderer = CursesRenderer()
else:
renderer = CursesRenderer()
app = App(renderer=renderer, db=db) app = App(renderer=renderer, db=db)
try: try:

View File

@@ -1,682 +0,0 @@
"""
Rich-based modern renderer for the AUTOPARTES console application.
Implements :class:`BaseRenderer` using the ``rich`` library for a modern
dark-themed TUI with blue/cyan accents. Uses alternate screen buffer
and buffered output for flicker-free rendering.
Key input uses os.read() on the raw fd to bypass Python's stdin buffer,
which ensures escape sequences (arrow keys, F-keys) are read correctly.
"""
import os
import sys
import tty
import termios
import select
from io import StringIO
from rich.console import Console
from rich.table import Table
from rich.text import Text
from rich import box as rich_box
from console.core.keybindings import Key
from console.renderers.base import BaseRenderer
from console.utils.formatting import pad_right, truncate
# Reduce ESC delay for this renderer too
os.environ.setdefault('ESCDELAY', '25')
class TextualRenderer(BaseRenderer):
"""Rich-based modern renderer with blue/cyan colour scheme."""
def __init__(self):
self._old_term_settings = None
self._width = 80
self._height = 24
self._fd = -1
self._buffer = [] # list of Rich renderables per render cycle
self._line_count = 0 # track actual lines consumed by buffer
# ── Lifecycle ────────────────────────────────────────────────────
def init_screen(self):
"""Enter alternate screen, hide cursor, set persistent raw mode."""
self._fd = sys.stdin.fileno()
try:
self._old_term_settings = termios.tcgetattr(self._fd)
except (termios.error, ValueError, OSError):
self._old_term_settings = None
# Enter alternate screen buffer + hide cursor
sys.stdout.write("\033[?1049h\033[?25l")
sys.stdout.flush()
# Set raw mode once for the entire session
try:
tty.setraw(self._fd)
except termios.error:
pass
def cleanup(self):
"""Restore terminal: exit alternate screen, show cursor, reset mode."""
# Show cursor + exit alternate screen
sys.stdout.write("\033[?25h\033[?1049l")
sys.stdout.flush()
# Restore original terminal attributes
if self._old_term_settings is not None:
try:
termios.tcsetattr(self._fd, termios.TCSADRAIN,
self._old_term_settings)
except (termios.error, ValueError, OSError):
pass
self._old_term_settings = None
# ── Screen queries ───────────────────────────────────────────────
def get_size(self) -> tuple:
"""Return ``(height, width)``."""
return (self._height, self._width)
# ── Primitive operations ─────────────────────────────────────────
def clear(self):
"""Start a new render cycle — update size and reset buffer."""
try:
size = os.get_terminal_size()
self._width = size.columns
self._height = size.lines
except OSError:
pass
self._buffer = []
self._line_count = 0
def _add_line(self, renderable):
"""Add a single-line renderable to the buffer."""
self._buffer.append(renderable)
self._line_count += 1
def _add_lines(self, renderable, line_count):
"""Add a multi-line renderable (like a Table) to the buffer."""
self._buffer.append(renderable)
self._line_count += line_count
def refresh(self):
"""Flush the entire buffered output to screen in one write."""
sio = StringIO()
console = Console(
file=sio,
width=self._width,
highlight=False,
force_terminal=True,
color_system="truecolor",
)
for item in self._buffer:
console.print(item, end="", highlight=False)
rendered = sio.getvalue()
# Move cursor home, clear screen, write everything at once
sys.stdout.write(f"\033[H\033[2J{rendered}")
sys.stdout.flush()
# ── Key input (uses os.read to bypass Python stdin buffer) ──────
def _read_byte(self) -> bytes:
"""Read exactly one byte from the raw fd."""
return os.read(self._fd, 1)
def _has_data(self, timeout=0.02) -> bool:
"""Return True if there is data waiting on the fd."""
r, _, _ = select.select([self._fd], [], [], timeout)
return bool(r)
def get_key(self) -> int:
"""Read a key using os.read() to bypass Python's stdin buffer."""
b = self._read_byte()
ch = b[0] if b else 0
if ch == 0x1b: # ESC
if self._has_data():
b2 = self._read_byte()
ch2 = b2[0] if b2 else 0
if ch2 == 0x5b: # [
return self._parse_csi()
elif ch2 == 0x4f: # O
return self._parse_ss3()
# Drain unknown escape
while self._has_data():
self._read_byte()
return Key.ESCAPE
return Key.ESCAPE
if ch == 0x0d or ch == 0x0a: # CR or LF
return Key.ENTER
if ch == 0x09: # TAB
return Key.TAB
if ch == 0x7f or ch == 0x08: # DEL or BS
return Key.BACKSPACE
if ch == 0x03: # Ctrl-C
return Key.ESCAPE
return ch
def _read_one(self) -> int:
"""Read one byte and return as int."""
b = self._read_byte()
return b[0] if b else 0
def _parse_csi(self):
"""Parse CSI escape sequence (ESC [ ...)."""
ch3 = self._read_one()
if ch3 == 0x41: return Key.UP # A
if ch3 == 0x42: return Key.DOWN # B
if ch3 == 0x43: return Key.RIGHT # C
if ch3 == 0x44: return Key.LEFT # D
if ch3 == 0x48: return Key.HOME # H
if ch3 == 0x46: return Key.END # F
if ch3 == 0x35: # 5
self._read_one() # ~
return Key.PGUP
if ch3 == 0x36: # 6
self._read_one() # ~
return Key.PGDN
if ch3 == 0x31: # 1
ch4 = self._read_one()
if ch4 == 0x7e: return Key.HOME # 1~
if ch4 == 0x35: self._read_one(); return Key.F5 # 15~
if ch4 == 0x37: self._read_one(); return Key.F6 # 17~
if ch4 == 0x38: self._read_one(); return Key.F7 # 18~
if ch4 == 0x39: self._read_one(); return Key.F8 # 19~
if self._has_data(): self._read_one()
return Key.HOME
if ch3 == 0x32: # 2
ch4 = self._read_one()
if ch4 == 0x30: self._read_one(); return Key.F9 # 20~
if ch4 == 0x31: self._read_one(); return Key.F10 # 21~
return Key.ESCAPE
if ch3 == 0x33: # 3 (Delete)
if self._has_data(): self._read_one()
return Key.BACKSPACE
if ch3 == 0x34: # 4
if self._has_data(): self._read_one()
return Key.END
# Drain remaining
while self._has_data():
self._read_one()
return Key.ESCAPE
def _parse_ss3(self):
"""Parse SS3 escape sequence (ESC O ...)."""
ch3 = self._read_one()
if ch3 == 0x50: return Key.F1 # P
if ch3 == 0x51: return Key.F2 # Q
if ch3 == 0x52: return Key.F3 # R
if ch3 == 0x53: return Key.F4 # S
if ch3 == 0x48: return Key.HOME # H
if ch3 == 0x46: return Key.END # F
return Key.ESCAPE
# ── High-level widgets ───────────────────────────────────────────
def draw_header(self, title, subtitle=''):
h, w = self.get_size()
header = Text()
header.append(title, style="bold cyan")
if subtitle:
padding = w - len(title) - len(subtitle)
if padding > 0:
header.append(" " * padding)
header.append(subtitle, style="dim white")
if header.cell_len < w:
header.append(" " * (w - header.cell_len))
header.stylize("on rgb(20,40,80)")
self._add_line(header)
self._add_line(Text("" * w, style="blue"))
def draw_footer(self, key_labels):
h, w = self.get_size()
# Pad remaining lines to push footer to bottom
remaining = h - self._line_count - 2 # 2 = separator + footer
for _ in range(max(remaining, 0)):
self._add_line(Text(""))
self._add_line(Text("" * w, style="blue"))
footer = Text()
for i, (key, desc) in enumerate(key_labels):
if i > 0:
footer.append(" ", style="dim white on rgb(20,40,80)")
footer.append(f" {key} ", style="bold white on rgb(40,80,120)")
footer.append(f" {desc}", style="white on rgb(20,40,80)")
if footer.cell_len < w:
footer.append(
" " * (w - footer.cell_len),
style="on rgb(20,40,80)",
)
self._add_line(footer)
def draw_menu(self, items, selected_index=0, title=''):
h, w = self.get_size()
# Calculate menu box dimensions
max_label = 0
for num, label in items:
if num != "---" and num != "\u2500":
max_label = max(max_label, len(f" {num}. {label} "))
box_inner = max(max_label + 4, 40)
box_inner = min(box_inner, w - 8)
# Vertical centering: blank lines before menu
item_count = len(items)
menu_height = item_count + (3 if title else 0)
top_pad = max((h - menu_height - 6) // 2 - 1, 0)
for _ in range(top_pad):
self._add_line(Text(""))
# Horizontal padding
pad_left = max((w - box_inner - 4) // 2, 2)
pad_str = " " * pad_left
# Box top border
self._add_line(Text(
f"{pad_str}\u256d{'' * (box_inner + 2)}\u256e",
style="blue",
))
if title:
# Title centered inside box
title_pad = max((box_inner - len(title)) // 2, 0)
line = Text()
line.append(f"{pad_str}\u2502 ", style="blue")
line.append(" " * title_pad, style="")
line.append(title, style="bold white")
line.append(
" " * (box_inner - title_pad - len(title)),
style="",
)
line.append(" \u2502", style="blue")
self._add_line(line)
# Separator inside box
self._add_line(Text(
f"{pad_str}\u251c{'' * (box_inner + 2)}\u2524",
style="blue",
))
visible = h - self._line_count - 4
if visible < 1:
visible = item_count
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
if num == "\u2500" or num == "---":
self._add_line(Text(
f"{pad_str}\u251c{'' * (box_inner + 2)}\u2524",
style="dim blue",
))
drawn += 1
continue
marker = "\u25b8 " if idx == selected_index else " "
entry = f"{marker}{num}. {label}"
entry = pad_right(entry, box_inner)
line = Text()
line.append(f"{pad_str}\u2502 ", style="blue")
if idx == selected_index:
line.append(f" {entry}", style="bold white on rgb(30,60,120)")
else:
line.append(f" {entry}", style="white")
line.append(" \u2502", style="blue")
self._add_line(line)
drawn += 1
# Box bottom border
self._add_line(Text(
f"{pad_str}\u2570{'' * (box_inner + 2)}\u256f",
style="blue",
))
def draw_table(self, headers, rows, widths, page_info=None,
selected_row=-1):
h, w = self.get_size()
table = Table(
box=rich_box.SIMPLE_HEAD,
show_edge=False,
pad_edge=False,
expand=True,
style="white",
header_style="bold cyan",
row_styles=["white", "dim white"],
)
table.add_column("#", style="dim cyan", width=4, justify="right")
for hdr, wd in zip(headers, widths):
table.add_column(hdr, width=wd, no_wrap=True, overflow="ellipsis")
visible = h - 8
if visible < 1:
visible = 1
actual_rows = 0
for i, row_data in enumerate(rows):
if i >= visible:
break
cells = [str(v) for v in row_data]
row_style = ("bold white on rgb(30,60,120)"
if i == selected_row else None)
table.add_row(str(i + 1), *cells, style=row_style)
actual_rows += 1
# Table renders: 1 header + 1 separator + actual_rows
self._add_lines(table, actual_rows + 2)
if page_info:
page = page_info.get("page", 1)
total = page_info.get("total_pages", 1)
total_rows = page_info.get("total_rows", len(rows))
self._add_line(Text(
f" Pagina {page}/{total} ({total_rows} registros)",
style="dim cyan",
))
def draw_detail(self, fields, title=''):
h, w = self.get_size()
if title:
self._add_line(Text(f" {title}", style="bold white"))
self._add_line(Text(" " + "" * (w - 4), style="blue"))
self._add_line(Text(""))
max_label = max((len(lbl) for lbl, _ in fields), default=10)
dot_total = max_label + 4
lines_available = h - 6 - (3 if title else 0)
for i, (label, value) in enumerate(fields):
if i >= lines_available:
break
dots = "." * (dot_total - len(label))
line = Text()
line.append(f" {label}{dots}: ", style="cyan")
line.append(str(value), style="bold white")
self._add_line(line)
def draw_form(self, fields, focused_index=0, title=''):
h, w = self.get_size()
if title:
self._add_line(Text(f" {title}", style="bold white"))
self._add_line(Text(" " + "" * (w - 4), style="blue"))
self._add_line(Text(""))
max_label = max((len(f.get("label", "")) for f in fields), default=10)
dot_total = max_label + 4
for i, field in enumerate(fields):
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}. "
line = Text()
line.append(f" {num_str}{label}{dots}: ", style="cyan")
display_val = pad_right(str(value), fw)
if i == focused_index:
line.append(f"[{display_val}]",
style="bold white on rgb(0,100,140)")
else:
line.append(f"[{display_val}]", style="white")
if hint:
line.append(f" {hint}", style="dim cyan")
self._add_line(line)
self._add_line(Text("")) # spacing
def draw_filter_list(self, items, filter_text, selected_index,
title=''):
h, w = self.get_size()
if title:
self._add_line(Text(f" {title}", style="bold white"))
self._add_line(Text(" " + "" * (w - 4), style="blue"))
filter_line = Text()
filter_line.append(" Filtro: ", style="cyan")
filter_line.append(filter_text + "_",
style="bold white on rgb(0,100,140)")
self._add_line(filter_line)
self._add_line(Text(" " + "" * (w - 4), style="blue"))
visible = h - 10
if visible < 1:
visible = 1
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
marker = "\u25b8 " if idx == selected_index else " "
if idx == selected_index:
entry = pad_right(f"{marker}{num}. {label}", w - 4)
self._add_line(
Text(f" {entry}",
style="bold white on rgb(30,60,120)")
)
else:
self._add_line(
Text(f" {marker}{num}. {label}", style="white")
)
drawn += 1
self._add_line(Text(
f" {len(items)} elementos", style="dim cyan"
))
def draw_comparison(self, columns, title=''):
h, w = self.get_size()
if title:
self._add_line(Text(f" {title}", style="bold white"))
self._add_line(Text(" " + "" * (w - 4), style="blue"))
self._add_line(Text(""))
n_cols = len(columns)
if n_cols == 0:
return
table = Table(
box=rich_box.SIMPLE_HEAD,
show_edge=False,
pad_edge=True,
expand=True,
style="white",
header_style="bold cyan",
)
table.add_column("", style="cyan", no_wrap=True)
for col in columns:
table.add_column(col.get("header", ""), style="white",
no_wrap=True)
if not columns[0].get("rows"):
self._add_lines(table, 2)
return
n_rows = len(columns[0]["rows"])
max_rows = h - 8
actual = min(n_rows, max_rows)
for i in range(actual):
lbl = (columns[0]["rows"][i][0]
if i < len(columns[0]["rows"]) else "")
vals = []
for col in columns:
rows_data = col.get("rows", [])
val = rows_data[i][1] if i < len(rows_data) else ""
vals.append(str(val))
table.add_row(lbl, *vals)
self._add_lines(table, actual + 2)
# ── Low-level drawing ────────────────────────────────────────────
def _render_styled(self, text, style="white"):
"""Render styled text to a string via Rich."""
sio = StringIO()
c = Console(file=sio, width=self._width, highlight=False,
force_terminal=True, color_system="truecolor")
c.print(Text(text, style=style), end="")
return sio.getvalue()
def draw_text(self, row, col, text, style='normal'):
style_map = {
"normal": "white", "header": "bold cyan",
"footer": "white on rgb(20,40,80)",
"highlight": "bold white on rgb(30,60,120)",
"border": "blue", "title": "bold white",
"error": "bold red", "info": "dim cyan",
"field_label": "cyan", "field_value": "bold white",
"field_active": "bold white on rgb(0,100,140)",
}
rich_style = style_map.get(style, "white")
rendered = self._render_styled(text, rich_style)
sys.stdout.write(f"\033[{row + 1};{col + 1}H{rendered}")
sys.stdout.flush()
def draw_box(self, top, left, height, width, title=''):
BOX_H = "\u2500"
BOX_V = "\u2502"
BOX_TL = "\u256d"
BOX_TR = "\u256e"
BOX_BL = "\u2570"
BOX_BR = "\u256f"
if height < 2 or width < 2:
return
if title:
t = truncate(title, width - 4)
top_line = BOX_TL + BOX_H + t + BOX_H * (width - 3 - len(t)) + BOX_TR
else:
top_line = BOX_TL + BOX_H * (width - 2) + BOX_TR
self._write_at(top, left, top_line, "blue")
for r in range(1, height - 1):
self._write_at(top + r, left,
BOX_V + " " * (width - 2) + BOX_V, "blue")
bottom_line = BOX_BL + BOX_H * (width - 2) + BOX_BR
self._write_at(top + height - 1, left, bottom_line, "blue")
def _write_at(self, row, col, text, style="white"):
"""Write styled text at absolute screen position."""
rendered = self._render_styled(text, style)
sys.stdout.write(f"\033[{row + 1};{col + 1}H{rendered}")
# ── 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)
if msg_type == "error":
text_style = "bold red"
elif msg_type == "confirm":
text_style = "bold yellow"
else:
text_style = "bold cyan"
self.draw_box(top, left, box_h, box_w)
for i, line in enumerate(lines):
x = left + max((box_w - len(line)) // 2, 2)
self._write_at(top + 1 + i, x, line, text_style)
if msg_type == "confirm":
prompt = "[S]i / [N]o"
else:
prompt = "Presione cualquier tecla..."
px = left + max((box_w - len(prompt)) // 2, 2)
self._write_at(top + box_h - 2, px, prompt, "bold cyan")
sys.stdout.flush()
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"), Key.ESCAPE):
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 = []
sys.stdout.write("\033[?25h")
sys.stdout.flush()
while True:
self.draw_box(top, left, box_h, box_w)
self._write_at(top + 1, left + 2, prompt, "cyan")
val = "".join(buf)
display = pad_right(val, max_len)
self._write_at(top + 2, left + 2, f"[{display}]",
"bold white on rgb(0,100,140)")
hint = "ENTER=Aceptar ESC=Cancelar"
hx = left + max((box_w - len(hint)) // 2, 2)
self._write_at(top + 3, hx, hint, "dim cyan")
sys.stdout.flush()
key = self.get_key()
if key == Key.ESCAPE:
sys.stdout.write("\033[?25l")
sys.stdout.flush()
return None
elif key == Key.ENTER:
sys.stdout.write("\033[?25l")
sys.stdout.flush()
return "".join(buf)
elif key == Key.BACKSPACE:
if buf:
buf.pop()
elif 32 <= key <= 126:
if len(buf) < max_len:
buf.append(chr(key))