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:
15
README.md
15
README.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
|
||||||
Reference in New Issue
Block a user