Files
Autoparts-DB/console/renderers/textual_renderer.py
consultoria-as 8c7caf3969 fix(console): rewrite modern renderer with buffered output
Major issues fixed:
- Rich printed directly to stdout causing visible flicker on every redraw
- get_key() toggled raw mode per keypress causing glitches and slowness
- No alternate screen buffer — output contaminated terminal scrollback

Rewrite approach:
- Use alternate screen buffer (ESC[?1049h) for clean enter/exit
- Persistent raw mode for entire session instead of per-keypress toggle
- Buffer all Rich renderables during render cycle, flush once in refresh()
- Render to StringIO then write entire frame in single sys.stdout.write()
- Reduced ESC sequence timeout from 50ms to 20ms for snappier response

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

613 lines
21 KiB
Python

"""
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.
"""
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._buffer = [] # list of Rich renderables per render cycle
# ── Lifecycle ────────────────────────────────────────────────────
def init_screen(self):
"""Enter alternate screen, hide cursor, set persistent raw mode."""
fd = sys.stdin.fileno()
try:
self._old_term_settings = termios.tcgetattr(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(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:
fd = sys.stdin.fileno()
termios.tcsetattr(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 = []
def refresh(self):
"""Flush the entire buffered output to screen in one write."""
# Render all buffered content to a string via Rich
sio = StringIO()
console = Console(
file=sio,
width=self._width,
highlight=False,
force_terminal=True,
color_system="truecolor",
)
for item in self._buffer:
if isinstance(item, str):
console.print(item, end="", highlight=False)
else:
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()
def get_key(self) -> int:
"""Read a key (terminal is already in raw mode)."""
fd = sys.stdin.fileno()
ch = sys.stdin.read(1)
if ch == "\x1b":
if _has_data(fd):
ch2 = sys.stdin.read(1)
if ch2 == "[":
return self._parse_csi(fd)
elif ch2 == "O":
return self._parse_ss3(fd)
# Drain unknown escape
while _has_data(fd):
sys.stdin.read(1)
return Key.ESCAPE
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":
return Key.ESCAPE
return ord(ch)
def _parse_csi(self, fd):
"""Parse CSI escape sequence (ESC [ ...)."""
ch3 = sys.stdin.read(1)
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
if ch3 == "5":
sys.stdin.read(1) # ~
return Key.PGUP
if ch3 == "6":
sys.stdin.read(1) # ~
return Key.PGDN
if ch3 == "1":
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
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
return Key.ESCAPE
if ch3 == "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
while _has_data(fd):
sys.stdin.read(1)
return Key.ESCAPE
def _parse_ss3(self, fd):
"""Parse SS3 escape sequence (ESC O ...)."""
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
# ── 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._buffer.append(header)
self._buffer.append(Text("" * w, style="blue"))
def draw_footer(self, key_labels):
h, w = self.get_size()
# Pad remaining space before footer
used = len(self._buffer)
remaining = h - used - 2 # 2 lines for separator + footer
for _ in range(max(remaining, 0)):
self._buffer.append(Text(""))
self._buffer.append(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._buffer.append(footer)
def draw_menu(self, items, selected_index=0, title=''):
h, w = self.get_size()
visible_lines = h - 6
if title:
self._buffer.append(Text(f" {title}", style="bold white"))
self._buffer.append(Text(""))
visible_lines -= 2
if visible_lines < 1:
return
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
if num == "\u2500" or num == "---":
self._buffer.append(
Text(" " + "" * (w - 4), style="dim blue")
)
drawn += 1
continue
marker = "\u25b8 " if idx == selected_index else " "
if idx == selected_index:
entry = pad_right(f"{marker}{num}. {label}", w - 4)
self._buffer.append(
Text(f" {entry}", style="bold white on rgb(30,60,120)")
)
else:
self._buffer.append(
Text(f" {marker}{num}. {label}", style="white")
)
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"],
)
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
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._buffer.append(table)
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._buffer.append(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._buffer.append(Text(f" {title}", style="bold white"))
self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
self._buffer.append(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._buffer.append(line)
def draw_form(self, fields, focused_index=0, title=''):
h, w = self.get_size()
if title:
self._buffer.append(Text(f" {title}", style="bold white"))
self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
self._buffer.append(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._buffer.append(line)
self._buffer.append(Text("")) # spacing
def draw_filter_list(self, items, filter_text, selected_index,
title=''):
h, w = self.get_size()
if title:
self._buffer.append(Text(f" {title}", style="bold white"))
self._buffer.append(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._buffer.append(filter_line)
self._buffer.append(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._buffer.append(
Text(f" {entry}",
style="bold white on rgb(30,60,120)")
)
else:
self._buffer.append(
Text(f" {marker}{num}. {label}", style="white")
)
drawn += 1
self._buffer.append(Text(
f" {len(items)} elementos", style="dim cyan"
))
def draw_comparison(self, columns, title=''):
h, w = self.get_size()
if title:
self._buffer.append(Text(f" {title}", style="bold white"))
self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
self._buffer.append(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._buffer.append(table)
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._buffer.append(table)
# ── Low-level drawing ────────────────────────────────────────────
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)",
}
# For positioned text, write directly (used by dialogs)
rich_style = style_map.get(style, "white")
sys.stdout.write(f"\033[{row + 1};{col + 1}H")
sio = StringIO()
c = Console(file=sio, width=self._width, highlight=False,
force_terminal=True, color_system="truecolor")
c.print(Text(text, style=rich_style), end="")
sys.stdout.write(sio.getvalue())
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
style = "blue"
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, style)
for r in range(1, height - 1):
self._write_at(top + r, left, BOX_V + " " * (width - 2) + BOX_V,
style)
bottom_line = BOX_BL + BOX_H * (width - 2) + BOX_BR
self._write_at(top + height - 1, left, bottom_line, style)
def _write_at(self, row, col, text, style="white"):
"""Write styled text at absolute screen position."""
sio = StringIO()
c = Console(file=sio, width=self._width, highlight=False,
force_terminal=True, color_system="truecolor")
c.print(Text(text, style=style), end="")
sys.stdout.write(f"\033[{row + 1};{col + 1}H{sio.getvalue()}")
# ── 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 = []
# Show cursor during input
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))
# ── Module-level helpers ─────────────────────────────────────────────
def _has_data(fd, timeout=0.02):
"""Return True if there is data waiting on file descriptor *fd*."""
r, _, _ = select.select([fd], [], [], timeout)
return bool(r)