feat(console): add Rich-based modern renderer

Implement TextualRenderer in console/renderers/textual_renderer.py using
the Rich library for a modern dark-themed TUI with blue/cyan accents.
All 18 BaseRenderer methods are implemented: lifecycle (init_screen,
cleanup), primitives (clear, refresh, get_key, get_size), widgets
(draw_header, draw_footer, draw_menu, draw_table, draw_detail,
draw_form, draw_filter_list, draw_comparison, draw_text, draw_box),
and dialogs (show_message, show_input). Keyboard input uses raw
terminal mode via tty/termios with full escape sequence decoding
for arrow keys, F-keys, Page Up/Down, Home/End.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 01:57:25 +00:00
parent 8194167c51
commit 7bf50a2c67

View File

@@ -0,0 +1,712 @@
"""
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. Keyboard input is handled via
raw terminal mode using :mod:`tty` / :mod:`termios` since Rich is a
display-only library.
NOTE: Despite the module name (historical), this uses **Rich** only --
not the Textual framework.
"""
import sys
import tty
import termios
import select
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
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
class TextualRenderer(BaseRenderer):
"""Rich-based modern renderer with blue/cyan colour scheme."""
def __init__(self):
self._console = None
self._old_term_settings = None
# ── Lifecycle ────────────────────────────────────────────────────
def init_screen(self):
"""Create a Rich Console and put the terminal into raw mode."""
self._console = Console(highlight=False, force_terminal=True)
# Save terminal state *before* entering raw mode
try:
fd = sys.stdin.fileno()
self._old_term_settings = termios.tcgetattr(fd)
except (termios.error, ValueError, OSError):
self._old_term_settings = None
# Hide cursor
sys.stdout.write("\033[?25l")
sys.stdout.flush()
def cleanup(self):
"""Restore the terminal to its original state."""
# Show cursor
sys.stdout.write("\033[?25h")
sys.stdout.flush()
# Restore original terminal attributes
if self._old_term_settings is not None:
try:
fd = sys.stdin.fileno()
termios.tcsetattr(fd, termios.TCSADRAIN,
self._old_term_settings)
except (termios.error, ValueError, OSError):
pass
self._old_term_settings = None
self._console = None
# ── Screen queries ───────────────────────────────────────────────
def get_size(self) -> tuple:
"""Return ``(height, width)`` of the terminal."""
size = self._console.size
return (size.height, size.width)
# ── Primitive operations ─────────────────────────────────────────
def clear(self):
"""Clear the screen."""
self._console.clear()
def refresh(self):
"""No-op -- Rich prints immediately to stdout."""
pass
def get_key(self) -> int:
"""Read a single key from stdin using raw terminal mode.
Escape sequences (arrows, F-keys, etc.) are decoded and mapped
to the same integer constants used by :class:`Key` (which mirror
curses key codes).
"""
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
if ch == "\x1b":
# Check if more bytes are available (escape sequence)
if _has_data(fd):
ch2 = sys.stdin.read(1)
if ch2 == "[":
ch3 = sys.stdin.read(1)
# Arrow keys
if ch3 == "A":
return Key.UP
if ch3 == "B":
return Key.DOWN
if ch3 == "C":
return Key.RIGHT
if ch3 == "D":
return Key.LEFT
if ch3 == "H":
return Key.HOME
if ch3 == "F":
return Key.END
# Page Up / Page Down / Home / End / Insert / Delete
if ch3 == "5":
sys.stdin.read(1) # consume '~'
return Key.PGUP
if ch3 == "6":
sys.stdin.read(1) # consume '~'
return Key.PGDN
if ch3 == "1":
# Could be: Home (1~), F5-F8 (15~,17~,18~,19~)
ch4 = sys.stdin.read(1)
if ch4 == "~":
return Key.HOME
if ch4 == "5":
sys.stdin.read(1) # ~
return Key.F5
if ch4 == "7":
sys.stdin.read(1) # ~
return Key.F6
if ch4 == "8":
sys.stdin.read(1) # ~
return Key.F7
if ch4 == "9":
sys.stdin.read(1) # ~
return Key.F8
# Consume trailing ~ if present
if _has_data(fd):
sys.stdin.read(1)
return Key.HOME
if ch3 == "2":
ch4 = sys.stdin.read(1)
if ch4 == "0":
sys.stdin.read(1) # ~
return Key.F9
if ch4 == "1":
sys.stdin.read(1) # ~
return Key.F10
# 2~ = Insert -- map to escape for now
return Key.ESCAPE
if ch3 == "3":
# Delete key: 3~
if _has_data(fd):
sys.stdin.read(1) # ~
return Key.BACKSPACE
if ch3 == "4":
if _has_data(fd):
sys.stdin.read(1) # ~
return Key.END
# Drain any remaining escape sequence bytes
while _has_data(fd):
sys.stdin.read(1)
return Key.ESCAPE
elif ch2 == "O":
# SS3 sequences (F1-F4, sometimes Home/End)
ch3 = sys.stdin.read(1)
if ch3 == "P":
return Key.F1
if ch3 == "Q":
return Key.F2
if ch3 == "R":
return Key.F3
if ch3 == "S":
return Key.F4
if ch3 == "H":
return Key.HOME
if ch3 == "F":
return Key.END
return Key.ESCAPE
# Unknown escape -- drain and return ESC
while _has_data(fd):
sys.stdin.read(1)
return Key.ESCAPE
# Bare ESC (no further bytes)
return Key.ESCAPE
if ch == "\r" or ch == "\n":
return Key.ENTER
if ch == "\t":
return Key.TAB
if ch == "\x7f" or ch == "\x08":
return Key.BACKSPACE
if ch == "\x03":
# Ctrl-C -- treat as escape so the app can exit gracefully
return Key.ESCAPE
return ord(ch)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
# ── 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")
# Pad to full width
if header.cell_len < w:
header.append(" " * (w - header.cell_len))
header.stylize("on rgb(20,40,80)")
self._console.print(header, end="")
# Separator line
sep = Text("" * w, style="blue")
self._console.print(sep, end="")
def draw_footer(self, key_labels):
h, w = self.get_size()
# Separator
sep = Text("" * w, style="blue")
self._console.print(sep, end="")
# Key labels
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)")
# Pad to full width
if footer.cell_len < w:
footer.append(
" " * (w - footer.cell_len),
style="on rgb(20,40,80)",
)
self._console.print(footer, end="")
def draw_menu(self, items, selected_index=0, title=''):
h, w = self.get_size()
visible_lines = h - 6 # header(2) + footer(2) + margins
if title:
title_text = Text()
title_text.append(f" {title}", style="bold white")
self._console.print(title_text, end="")
self._console.print("", end="") # blank line
visible_lines -= 2
if visible_lines < 1:
return
# Scrolling offset
offset = 0
if selected_index >= visible_lines:
offset = selected_index - visible_lines + 1
drawn = 0
for idx, (num, label) in enumerate(items):
if drawn >= visible_lines:
break
if idx < offset:
continue
# Separator
if num == "\u2500" or num == "---":
sep = Text(" " + "" * (w - 4), style="dim blue")
self._console.print(sep, end="")
drawn += 1
continue
line = Text()
marker = "\u25b8 " if idx == selected_index else " "
if idx == selected_index:
entry = f"{marker}{num}. {label}"
entry = pad_right(entry, w - 4)
line.append(f" {entry}", style="bold white on rgb(30,60,120)")
else:
line.append(f" {marker}{num}. {label}", style="white")
self._console.print(line, end="")
drawn += 1
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"],
)
# Row number column
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 # header, table header, separator, footer, page info
if visible < 1:
visible = 1
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)
self._console.print(table, end="")
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))
info = Text()
info.append(
f" Pagina {page}/{total} ({total_rows} registros)",
style="dim cyan",
)
self._console.print(info, end="")
def draw_detail(self, fields, title=''):
h, w = self.get_size()
if title:
title_text = Text()
title_text.append(f" {title}", style="bold white")
self._console.print(title_text, end="")
sep = Text(" " + "" * (w - 4), style="blue")
self._console.print(sep, end="")
self._console.print("", end="") # blank line
max_label = max((len(lbl) for lbl, _ in fields), default=10)
dot_total = max_label + 4
lines_available = h - 6
if title:
lines_available -= 3
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._console.print(line, end="")
def draw_form(self, fields, focused_index=0, title=''):
h, w = self.get_size()
if title:
title_text = Text()
title_text.append(f" {title}", style="bold white")
self._console.print(title_text, end="")
sep = Text(" " + "" * (w - 4), style="blue")
self._console.print(sep, end="")
self._console.print("", end="") # blank line
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._console.print(line, end="")
# Blank line between fields for spacing
self._console.print("", end="")
def draw_filter_list(self, items, filter_text, selected_index,
title=''):
h, w = self.get_size()
if title:
title_text = Text()
title_text.append(f" {title}", style="bold white")
self._console.print(title_text, end="")
# Separator
sep = Text(" " + "" * (w - 4), style="blue")
self._console.print(sep, end="")
# Filter input
filter_line = Text()
filter_line.append(" Filtro: ", style="cyan")
filter_line.append(filter_text, style="bold white on rgb(0,100,140)")
filter_line.append("_", style="bold white on rgb(0,100,140)")
self._console.print(filter_line, end="")
# Separator
self._console.print(sep, end="")
# Scrollable list
visible = h - 10 # header, title, filter, separators, footer, count
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 " "
line = Text()
if idx == selected_index:
entry = f"{marker}{num}. {label}"
entry = pad_right(entry, w - 4)
line.append(f" {entry}",
style="bold white on rgb(30,60,120)")
else:
line.append(f" {marker}{num}. {label}", style="white")
self._console.print(line, end="")
drawn += 1
# Count at bottom
count_line = Text()
count_line.append(f" {len(items)} elementos", style="dim cyan")
self._console.print(count_line, end="")
def draw_comparison(self, columns, title=''):
h, w = self.get_size()
if title:
title_text = Text()
title_text.append(f" {title}", style="bold white")
self._console.print(title_text, end="")
sep = Text(" " + "" * (w - 4), style="blue")
self._console.print(sep, end="")
self._console.print("", end="") # blank line
n_cols = len(columns)
if n_cols == 0:
return
# Build a Rich Table for the comparison
table = Table(
box=rich_box.SIMPLE_HEAD,
show_edge=False,
pad_edge=True,
expand=True,
style="white",
header_style="bold cyan",
)
# Label column
table.add_column("", style="cyan", no_wrap=True)
for col in columns:
table.add_column(
col.get("header", ""),
style="white",
no_wrap=True,
)
# Data rows -- use the first column's labels as the canonical set
if not columns[0].get("rows"):
self._console.print(table, end="")
return
n_rows = len(columns[0]["rows"])
max_rows = h - 8
for i in range(min(n_rows, max_rows)):
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._console.print(table, end="")
# ── Low-level drawing ────────────────────────────────────────────
def draw_text(self, row, col, text, style='normal'):
"""Draw text using Rich styling.
Since Rich does not support absolute cursor positioning the way
curses does, we approximate by printing the text preceded by
ANSI escape codes that move the cursor to the requested row/col.
"""
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")
styled = Text(text, style=rich_style)
# Use ANSI escape to position cursor
sys.stdout.write(f"\033[{row + 1};{col + 1}H")
sys.stdout.flush()
self._console.print(styled, end="")
def draw_box(self, top, left, height, width, title=''):
"""Draw a box using Rich's Panel.
Since Rich Panel does not support absolute positioning, we build
the box manually with Unicode line-drawing characters and ANSI
cursor movement for precise placement.
"""
BOX_H = "\u2500"
BOX_V = "\u2502"
BOX_TL = "\u256d" # rounded corners for modern look
BOX_TR = "\u256e"
BOX_BL = "\u2570"
BOX_BR = "\u256f"
if height < 2 or width < 2:
return
# Top border
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
sys.stdout.write(f"\033[{top + 1};{left + 1}H")
styled = Text(top_line, style="blue")
self._console.print(styled, end="")
# Side borders
for r in range(1, height - 1):
sys.stdout.write(f"\033[{top + r + 1};{left + 1}H")
styled = Text(BOX_V, style="blue")
self._console.print(styled, end="")
sys.stdout.write(f"\033[{top + r + 1};{left + width}H")
self._console.print(styled, end="")
# Bottom border
bottom_line = BOX_BL + BOX_H * (width - 2) + BOX_BR
sys.stdout.write(f"\033[{top + height};{left + 1}H")
styled = Text(bottom_line, style="blue")
self._console.print(styled, end="")
# ── 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)
# Determine style
if msg_type == "error":
border_style = "bold red"
text_style = "bold red"
title_label = " Error "
elif msg_type == "confirm":
border_style = "bold yellow"
text_style = "white"
title_label = " Confirmar "
else:
border_style = "bold cyan"
text_style = "white"
title_label = " Info "
# Draw box
self.draw_box(top, left, box_h, box_w, title_label)
# Fill interior and draw message lines
interior_style = text_style
for r in range(1, box_h - 1):
sys.stdout.write(f"\033[{top + r + 1};{left + 2}H")
fill = Text(" " * (box_w - 2), style=interior_style)
self._console.print(fill, end="")
for i, line in enumerate(lines):
x = left + max((box_w - len(line)) // 2, 2)
sys.stdout.write(f"\033[{top + 2 + i};{x + 1}H")
styled = Text(line, style=text_style)
self._console.print(styled, end="")
# 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)
sys.stdout.write(f"\033[{top + box_h};{px + 1}H")
prompt_styled = Text(prompt, style="bold cyan")
self._console.print(prompt_styled, end="")
sys.stdout.flush()
# 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"), 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 = []
# Show cursor during input
sys.stdout.write("\033[?25h")
sys.stdout.flush()
while True:
self.draw_box(top, left, box_h, box_w, " Entrada ")
# Fill interior
for r in range(1, box_h - 1):
sys.stdout.write(f"\033[{top + r + 1};{left + 2}H")
fill = Text(" " * (box_w - 2))
self._console.print(fill, end="")
# Prompt label
sys.stdout.write(f"\033[{top + 2};{left + 3}H")
label = Text(prompt, style="cyan")
self._console.print(label, end="")
# Input field
val = "".join(buf)
display = pad_right(val, max_len)
sys.stdout.write(f"\033[{top + 3};{left + 3}H")
field = Text(f"[{display}]",
style="bold white on rgb(0,100,140)")
self._console.print(field, end="")
# Hint
hint = "ENTER=Aceptar ESC=Cancelar"
hx = left + max((box_w - len(hint)) // 2, 2)
sys.stdout.write(f"\033[{top + 4};{hx + 1}H")
hint_styled = Text(hint, style="dim cyan")
self._console.print(hint_styled, end="")
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))
# ── Module-level helpers ─────────────────────────────────────────────
def _has_data(fd, timeout=0.05):
"""Return True if there is data waiting on file descriptor *fd*."""
r, _, _ = select.select([fd], [], [], timeout)
return bool(r)