From 3ea2de61e2713079d3f0969fabefb98e8ea1f9d6 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sun, 15 Feb 2026 02:37:35 +0000 Subject: [PATCH] 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 --- README.md | 15 +- console/README.md | 65 +-- console/config.py | 1 - console/main.py | 34 +- console/renderers/textual_renderer.py | 682 -------------------------- 5 files changed, 29 insertions(+), 768 deletions(-) delete mode 100644 console/renderers/textual_renderer.py diff --git a/README.md b/README.md index a259d35..5d97073 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Autopartes/ │ ├── db.py # Capa de datos abstracta │ ├── core/ # Framework (app, screens, nav, keys) │ ├── screens/ # 14 pantallas (menú, CRUD, búsqueda) -│ ├── renderers/ # VT220 (curses) y moderno (Rich) +│ ├── renderers/ # Renderer VT220 (curses) │ ├── utils/ # Formato y API VIN │ └── tests/ # 116 tests │ @@ -84,17 +84,10 @@ Autopartes/ ## Consola Pick/VT220 -Interfaz de terminal inspirada en los sistemas Pick/D3, 100% operada con teclado. Incluye dos modos de visualización: - -- **VT220** (curses): Terminal clásica verde sobre negro con caracteres de caja -- **Modern** (Rich): Interfaz moderna con colores y estilos TUI +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. ```bash -# Modo clásico VT220 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. @@ -119,7 +112,6 @@ Funcionalidades: navegación por vehículo (marca→modelo→año→motor), bús 2. **Instalar dependencias** ```bash 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)** @@ -142,8 +134,7 @@ El dashboard estará disponible en: `http://localhost:5000` ### Iniciar la Consola Pick/VT220 ```bash -python -m console # Modo VT220 (clásico) -python -m console --mode modern # Modo moderno (Rich) +python -m console ``` ### Usar la Interfaz CLI Legacy diff --git a/console/README.md b/console/README.md index 61bf136..76ae019 100644 --- a/console/README.md +++ b/console/README.md @@ -1,26 +1,20 @@ # 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 - Python 3.8+ - SQLite 3 (incluido con Python) -- Paquete `rich` (solo para modo moderno) -```bash -pip install rich # Opcional, solo para --mode modern -``` +No requiere dependencias externas. ## Inicio Rápido ```bash -# Modo VT220 (clásico, verde sobre negro) +# Iniciar la consola python -m console -# Modo moderno (Rich/TUI con colores) -python -m console --mode modern - # Especificar base de datos 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 ``` -## 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 ``` -╔══════════════════════════════════════╗ -║ AUTOPARTES v1.0.0 ║ -║ Sistema de Catalogo de Autopartes ║ -╠══════════════════════════════════════╣ -║ 1. Buscar por Vehiculo ║ -║ 2. Buscar por Numero de Parte ║ -║ 3. Buscar por Texto ║ -║ 4. Decodificar VIN ║ -║ 5. Catalogo por Categoria ║ -║ ────────────────────────── ║ -║ 6. Admin: Partes ║ -║ 7. Admin: Fabricantes ║ -║ 8. Admin: Referencias Cruzadas ║ -║ 9. Import/Export ║ -║ ────────────────────────── ║ -║ S. Estadisticas del Sistema ║ -║ 0. Salir ║ -╚══════════════════════════════════════╝ + ┌──────────────────────────────────────────┐ + │ MENU PRINCIPAL │ + ├──────────────────────────────────────────┤ + │ ▸ 1. Consulta por Vehiculo │ + │ 2. Busqueda por Numero de Parte │ + │ 3. Busqueda por Descripcion │ + │ 4. Decodificador VIN │ + │ 5. Catalogo de Categorias │ + ├──────────────────────────────────────────┤ + │ 6. Administracion de Partes │ + │ 7. Administracion de Fabricantes │ + │ 8. Cross-References │ + │ 9. Importar / Exportar Datos │ + ├──────────────────────────────────────────┤ + │ 0. Estadisticas del Sistema │ + └──────────────────────────────────────────┘ ``` ## Teclas de Función @@ -122,7 +100,7 @@ Dashboard con contadores de la base de datos (marcas, modelos, partes, etc.) y m ``` console/ -├── main.py # Punto de entrada, --mode vt220|modern +├── main.py # Punto de entrada ├── config.py # Configuración (DB, colores, paginación) ├── db.py # Capa de datos abstracta (SQLite) │ @@ -149,8 +127,7 @@ console/ │ ├── renderers/ │ ├── base.py # Interfaz abstracta BaseRenderer -│ ├── curses_renderer.py # Modo VT220 (curses) -│ └── textual_renderer.py # Modo moderno (Rich) +│ └── curses_renderer.py # Renderer VT220 (curses) │ ├── utils/ │ ├── formatting.py # Formato de tablas, moneda, números diff --git a/console/config.py b/console/config.py index 0fe7838..875361f 100644 --- a/console/config.py +++ b/console/config.py @@ -19,7 +19,6 @@ NHTSA_API_URL = "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin" VIN_CACHE_DAYS = 30 # Display defaults -DEFAULT_MODE = "vt220" PAGE_SIZE = 15 # VT220 color pairs: (foreground, background) diff --git a/console/main.py b/console/main.py index 38bf191..055a6c0 100644 --- a/console/main.py +++ b/console/main.py @@ -11,7 +11,7 @@ import argparse import os 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): @@ -20,12 +20,6 @@ def parse_args(argv=None): prog=APP_NAME.lower(), 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( "--version", action="version", @@ -39,15 +33,14 @@ def parse_args(argv=None): return parser.parse_args(argv) -def _print_banner(mode, db_path): +def _print_banner(db_path): """Print a startup banner before entering terminal mode.""" border = "=" * 58 print(border) print(f" {APP_NAME} v{VERSION}") print(f" {APP_SUBTITLE}") print(border) - print(f" Mode : {mode}") - print(f" DB : {db_path}") + print(f" DB : {db_path}") print(border) print() @@ -57,7 +50,6 @@ def main(argv=None): args = parse_args(argv) db_path = args.db - mode = args.mode # Verify the database file exists before proceeding if not os.path.isfile(db_path): @@ -79,26 +71,10 @@ def main(argv=None): from console.core.app import App # Print startup banner - _print_banner(mode, db_path) + _print_banner(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() - else: - renderer = CursesRenderer() - + renderer = CursesRenderer() app = App(renderer=renderer, db=db) try: diff --git a/console/renderers/textual_renderer.py b/console/renderers/textual_renderer.py deleted file mode 100644 index ae10b82..0000000 --- a/console/renderers/textual_renderer.py +++ /dev/null @@ -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))