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>
This commit is contained in:
2026-02-15 02:25:35 +00:00
parent f5e0525dfc
commit 8c7caf3969

View File

@@ -2,22 +2,19 @@
Rich-based modern renderer for the AUTOPARTES console application. Rich-based modern renderer for the AUTOPARTES console application.
Implements :class:`BaseRenderer` using the ``rich`` library for a modern Implements :class:`BaseRenderer` using the ``rich`` library for a modern
dark-themed TUI with blue/cyan accents. Keyboard input is handled via dark-themed TUI with blue/cyan accents. Uses alternate screen buffer
raw terminal mode using :mod:`tty` / :mod:`termios` since Rich is a and buffered output for flicker-free rendering.
display-only library.
NOTE: Despite the module name (historical), this uses **Rich** only --
not the Textual framework.
""" """
import os
import sys import sys
import tty import tty
import termios import termios
import select import select
from io import StringIO
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from rich.panel import Panel
from rich.text import Text from rich.text import Text
from rich import box as rich_box from rich import box as rich_box
@@ -25,33 +22,43 @@ from console.core.keybindings import Key
from console.renderers.base import BaseRenderer from console.renderers.base import BaseRenderer
from console.utils.formatting import pad_right, truncate from console.utils.formatting import pad_right, truncate
# Reduce ESC delay for this renderer too
os.environ.setdefault('ESCDELAY', '25')
class TextualRenderer(BaseRenderer): class TextualRenderer(BaseRenderer):
"""Rich-based modern renderer with blue/cyan colour scheme.""" """Rich-based modern renderer with blue/cyan colour scheme."""
def __init__(self): def __init__(self):
self._console = None
self._old_term_settings = None self._old_term_settings = None
self._width = 80
self._height = 24
self._buffer = [] # list of Rich renderables per render cycle
# ── Lifecycle ──────────────────────────────────────────────────── # ── Lifecycle ────────────────────────────────────────────────────
def init_screen(self): def init_screen(self):
"""Create a Rich Console and put the terminal into raw mode.""" """Enter alternate screen, hide cursor, set persistent raw mode."""
self._console = Console(highlight=False, force_terminal=True)
# Save terminal state *before* entering raw mode
try:
fd = sys.stdin.fileno() fd = sys.stdin.fileno()
try:
self._old_term_settings = termios.tcgetattr(fd) self._old_term_settings = termios.tcgetattr(fd)
except (termios.error, ValueError, OSError): except (termios.error, ValueError, OSError):
self._old_term_settings = None self._old_term_settings = None
# Hide cursor
sys.stdout.write("\033[?25l") # Enter alternate screen buffer + hide cursor
sys.stdout.write("\033[?1049h\033[?25l")
sys.stdout.flush() sys.stdout.flush()
# Set raw mode once for the entire session
try:
tty.setraw(fd)
except termios.error:
pass
def cleanup(self): def cleanup(self):
"""Restore the terminal to its original state.""" """Restore terminal: exit alternate screen, show cursor, reset mode."""
# Show cursor # Show cursor + exit alternate screen
sys.stdout.write("\033[?25h") sys.stdout.write("\033[?25h\033[?1049l")
sys.stdout.flush() sys.stdout.flush()
# Restore original terminal attributes # Restore original terminal attributes
if self._old_term_settings is not None: if self._old_term_settings is not None:
@@ -62,129 +69,64 @@ class TextualRenderer(BaseRenderer):
except (termios.error, ValueError, OSError): except (termios.error, ValueError, OSError):
pass pass
self._old_term_settings = None self._old_term_settings = None
self._console = None
# ── Screen queries ─────────────────────────────────────────────── # ── Screen queries ───────────────────────────────────────────────
def get_size(self) -> tuple: def get_size(self) -> tuple:
"""Return ``(height, width)`` of the terminal.""" """Return ``(height, width)``."""
size = self._console.size return (self._height, self._width)
return (size.height, size.width)
# ── Primitive operations ───────────────────────────────────────── # ── Primitive operations ─────────────────────────────────────────
def clear(self): def clear(self):
"""Clear the screen.""" """Start a new render cycle — update size and reset buffer."""
self._console.clear() try:
size = os.get_terminal_size()
self._width = size.columns
self._height = size.lines
except OSError:
pass
self._buffer = []
def refresh(self): def refresh(self):
"""No-op -- Rich prints immediately to stdout.""" """Flush the entire buffered output to screen in one write."""
pass # 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: def get_key(self) -> int:
"""Read a single key from stdin using raw terminal mode. """Read a key (terminal is already in raw 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() fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1) ch = sys.stdin.read(1)
if ch == "\x1b": if ch == "\x1b":
# Check if more bytes are available (escape sequence)
if _has_data(fd): if _has_data(fd):
ch2 = sys.stdin.read(1) ch2 = sys.stdin.read(1)
if ch2 == "[": if ch2 == "[":
ch3 = sys.stdin.read(1) return self._parse_csi(fd)
# 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": elif ch2 == "O":
# SS3 sequences (F1-F4, sometimes Home/End) return self._parse_ss3(fd)
ch3 = sys.stdin.read(1) # Drain unknown escape
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): while _has_data(fd):
sys.stdin.read(1) sys.stdin.read(1)
return Key.ESCAPE return Key.ESCAPE
# Bare ESC (no further bytes)
return Key.ESCAPE return Key.ESCAPE
if ch == "\r" or ch == "\n": if ch == "\r" or ch == "\n":
@@ -194,11 +136,58 @@ class TextualRenderer(BaseRenderer):
if ch == "\x7f" or ch == "\x08": if ch == "\x7f" or ch == "\x08":
return Key.BACKSPACE return Key.BACKSPACE
if ch == "\x03": if ch == "\x03":
# Ctrl-C -- treat as escape so the app can exit gracefully
return Key.ESCAPE return Key.ESCAPE
return ord(ch) return ord(ch)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old) 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 ─────────────────────────────────────────── # ── High-level widgets ───────────────────────────────────────────
@@ -211,50 +200,46 @@ class TextualRenderer(BaseRenderer):
if padding > 0: if padding > 0:
header.append(" " * padding) header.append(" " * padding)
header.append(subtitle, style="dim white") header.append(subtitle, style="dim white")
# Pad to full width
if header.cell_len < w: if header.cell_len < w:
header.append(" " * (w - header.cell_len)) header.append(" " * (w - header.cell_len))
header.stylize("on rgb(20,40,80)") header.stylize("on rgb(20,40,80)")
self._console.print(header, end="") self._buffer.append(header)
# Separator line self._buffer.append(Text("" * w, style="blue"))
sep = Text("" * w, style="blue")
self._console.print(sep, end="")
def draw_footer(self, key_labels): def draw_footer(self, key_labels):
h, w = self.get_size() h, w = self.get_size()
# Separator # Pad remaining space before footer
sep = Text("" * w, style="blue") used = len(self._buffer)
self._console.print(sep, end="") remaining = h - used - 2 # 2 lines for separator + footer
# Key labels for _ in range(max(remaining, 0)):
self._buffer.append(Text(""))
self._buffer.append(Text("" * w, style="blue"))
footer = Text() footer = Text()
for i, (key, desc) in enumerate(key_labels): for i, (key, desc) in enumerate(key_labels):
if i > 0: if i > 0:
footer.append(" ", style="dim white on rgb(20,40,80)") 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" {key} ", style="bold white on rgb(40,80,120)")
footer.append(f" {desc}", style="white on rgb(20,40,80)") footer.append(f" {desc}", style="white on rgb(20,40,80)")
# Pad to full width
if footer.cell_len < w: if footer.cell_len < w:
footer.append( footer.append(
" " * (w - footer.cell_len), " " * (w - footer.cell_len),
style="on rgb(20,40,80)", style="on rgb(20,40,80)",
) )
self._console.print(footer, end="") self._buffer.append(footer)
def draw_menu(self, items, selected_index=0, title=''): def draw_menu(self, items, selected_index=0, title=''):
h, w = self.get_size() h, w = self.get_size()
visible_lines = h - 6 # header(2) + footer(2) + margins visible_lines = h - 6
if title: if title:
title_text = Text() self._buffer.append(Text(f" {title}", style="bold white"))
title_text.append(f" {title}", style="bold white") self._buffer.append(Text(""))
self._console.print(title_text, end="")
self._console.print("", end="") # blank line
visible_lines -= 2 visible_lines -= 2
if visible_lines < 1: if visible_lines < 1:
return return
# Scrolling offset
offset = 0 offset = 0
if selected_index >= visible_lines: if selected_index >= visible_lines:
offset = selected_index - visible_lines + 1 offset = selected_index - visible_lines + 1
@@ -266,24 +251,23 @@ class TextualRenderer(BaseRenderer):
if idx < offset: if idx < offset:
continue continue
# Separator
if num == "\u2500" or num == "---": if num == "\u2500" or num == "---":
sep = Text(" " + "" * (w - 4), style="dim blue") self._buffer.append(
self._console.print(sep, end="") Text(" " + "" * (w - 4), style="dim blue")
)
drawn += 1 drawn += 1
continue continue
line = Text()
marker = "\u25b8 " if idx == selected_index else " " marker = "\u25b8 " if idx == selected_index else " "
if idx == selected_index: if idx == selected_index:
entry = f"{marker}{num}. {label}" entry = pad_right(f"{marker}{num}. {label}", w - 4)
entry = pad_right(entry, w - 4) self._buffer.append(
line.append(f" {entry}", style="bold white on rgb(30,60,120)") Text(f" {entry}", style="bold white on rgb(30,60,120)")
)
else: else:
line.append(f" {marker}{num}. {label}", style="white") self._buffer.append(
Text(f" {marker}{num}. {label}", style="white")
self._console.print(line, end="") )
drawn += 1 drawn += 1
def draw_table(self, headers, rows, widths, page_info=None, def draw_table(self, headers, rows, widths, page_info=None,
@@ -300,13 +284,11 @@ class TextualRenderer(BaseRenderer):
row_styles=["white", "dim white"], row_styles=["white", "dim white"],
) )
# Row number column
table.add_column("#", style="dim cyan", width=4, justify="right") table.add_column("#", style="dim cyan", width=4, justify="right")
for hdr, wd in zip(headers, widths): for hdr, wd in zip(headers, widths):
table.add_column(hdr, width=wd, no_wrap=True, overflow="ellipsis") table.add_column(hdr, width=wd, no_wrap=True, overflow="ellipsis")
visible = h - 8 # header, table header, separator, footer, page info visible = h - 8
if visible < 1: if visible < 1:
visible = 1 visible = 1
@@ -318,36 +300,28 @@ class TextualRenderer(BaseRenderer):
if i == selected_row else None) if i == selected_row else None)
table.add_row(str(i + 1), *cells, style=row_style) table.add_row(str(i + 1), *cells, style=row_style)
self._console.print(table, end="") self._buffer.append(table)
if page_info: if page_info:
page = page_info.get("page", 1) page = page_info.get("page", 1)
total = page_info.get("total_pages", 1) total = page_info.get("total_pages", 1)
total_rows = page_info.get("total_rows", len(rows)) total_rows = page_info.get("total_rows", len(rows))
info = Text() self._buffer.append(Text(
info.append(
f" Pagina {page}/{total} ({total_rows} registros)", f" Pagina {page}/{total} ({total_rows} registros)",
style="dim cyan", style="dim cyan",
) ))
self._console.print(info, end="")
def draw_detail(self, fields, title=''): def draw_detail(self, fields, title=''):
h, w = self.get_size() h, w = self.get_size()
if title: if title:
title_text = Text() self._buffer.append(Text(f" {title}", style="bold white"))
title_text.append(f" {title}", style="bold white") self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
self._console.print(title_text, end="") self._buffer.append(Text(""))
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) max_label = max((len(lbl) for lbl, _ in fields), default=10)
dot_total = max_label + 4 dot_total = max_label + 4
lines_available = h - 6 - (3 if title else 0)
lines_available = h - 6
if title:
lines_available -= 3
for i, (label, value) in enumerate(fields): for i, (label, value) in enumerate(fields):
if i >= lines_available: if i >= lines_available:
@@ -356,18 +330,15 @@ class TextualRenderer(BaseRenderer):
line = Text() line = Text()
line.append(f" {label}{dots}: ", style="cyan") line.append(f" {label}{dots}: ", style="cyan")
line.append(str(value), style="bold white") line.append(str(value), style="bold white")
self._console.print(line, end="") self._buffer.append(line)
def draw_form(self, fields, focused_index=0, title=''): def draw_form(self, fields, focused_index=0, title=''):
h, w = self.get_size() h, w = self.get_size()
if title: if title:
title_text = Text() self._buffer.append(Text(f" {title}", style="bold white"))
title_text.append(f" {title}", style="bold white") self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
self._console.print(title_text, end="") self._buffer.append(Text(""))
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) max_label = max((len(f.get("label", "")) for f in fields), default=10)
dot_total = max_label + 4 dot_total = max_label + 4
@@ -394,35 +365,27 @@ class TextualRenderer(BaseRenderer):
if hint: if hint:
line.append(f" {hint}", style="dim cyan") line.append(f" {hint}", style="dim cyan")
self._console.print(line, end="") self._buffer.append(line)
# Blank line between fields for spacing self._buffer.append(Text("")) # spacing
self._console.print("", end="")
def draw_filter_list(self, items, filter_text, selected_index, def draw_filter_list(self, items, filter_text, selected_index,
title=''): title=''):
h, w = self.get_size() h, w = self.get_size()
if title: if title:
title_text = Text() self._buffer.append(Text(f" {title}", style="bold white"))
title_text.append(f" {title}", style="bold white")
self._console.print(title_text, end="")
# Separator self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
sep = Text(" " + "" * (w - 4), style="blue")
self._console.print(sep, end="")
# Filter input
filter_line = Text() filter_line = Text()
filter_line.append(" Filtro: ", style="cyan") filter_line.append(" Filtro: ", style="cyan")
filter_line.append(filter_text, style="bold white on rgb(0,100,140)") filter_line.append(filter_text + "_",
filter_line.append("_", style="bold white on rgb(0,100,140)") style="bold white on rgb(0,100,140)")
self._console.print(filter_line, end="") self._buffer.append(filter_line)
# Separator self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
self._console.print(sep, end="")
# Scrollable list visible = h - 10
visible = h - 10 # header, title, filter, separators, footer, count
if visible < 1: if visible < 1:
visible = 1 visible = 1
@@ -438,39 +401,34 @@ class TextualRenderer(BaseRenderer):
continue continue
marker = "\u25b8 " if idx == selected_index else " " marker = "\u25b8 " if idx == selected_index else " "
line = Text()
if idx == selected_index: if idx == selected_index:
entry = f"{marker}{num}. {label}" entry = pad_right(f"{marker}{num}. {label}", w - 4)
entry = pad_right(entry, w - 4) self._buffer.append(
line.append(f" {entry}", Text(f" {entry}",
style="bold white on rgb(30,60,120)") style="bold white on rgb(30,60,120)")
)
else: else:
line.append(f" {marker}{num}. {label}", style="white") self._buffer.append(
Text(f" {marker}{num}. {label}", style="white")
self._console.print(line, end="") )
drawn += 1 drawn += 1
# Count at bottom self._buffer.append(Text(
count_line = Text() f" {len(items)} elementos", style="dim cyan"
count_line.append(f" {len(items)} elementos", style="dim cyan") ))
self._console.print(count_line, end="")
def draw_comparison(self, columns, title=''): def draw_comparison(self, columns, title=''):
h, w = self.get_size() h, w = self.get_size()
if title: if title:
title_text = Text() self._buffer.append(Text(f" {title}", style="bold white"))
title_text.append(f" {title}", style="bold white") self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
self._console.print(title_text, end="") self._buffer.append(Text(""))
sep = Text(" " + "" * (w - 4), style="blue")
self._console.print(sep, end="")
self._console.print("", end="") # blank line
n_cols = len(columns) n_cols = len(columns)
if n_cols == 0: if n_cols == 0:
return return
# Build a Rich Table for the comparison
table = Table( table = Table(
box=rich_box.SIMPLE_HEAD, box=rich_box.SIMPLE_HEAD,
show_edge=False, show_edge=False,
@@ -480,19 +438,13 @@ class TextualRenderer(BaseRenderer):
header_style="bold cyan", header_style="bold cyan",
) )
# Label column
table.add_column("", style="cyan", no_wrap=True) table.add_column("", style="cyan", no_wrap=True)
for col in columns: for col in columns:
table.add_column( table.add_column(col.get("header", ""), style="white",
col.get("header", ""), no_wrap=True)
style="white",
no_wrap=True,
)
# Data rows -- use the first column's labels as the canonical set
if not columns[0].get("rows"): if not columns[0].get("rows"):
self._console.print(table, end="") self._buffer.append(table)
return return
n_rows = len(columns[0]["rows"]) n_rows = len(columns[0]["rows"])
@@ -507,47 +459,34 @@ class TextualRenderer(BaseRenderer):
vals.append(str(val)) vals.append(str(val))
table.add_row(lbl, *vals) table.add_row(lbl, *vals)
self._console.print(table, end="") self._buffer.append(table)
# ── Low-level drawing ──────────────────────────────────────────── # ── Low-level drawing ────────────────────────────────────────────
def draw_text(self, row, col, text, style='normal'): 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 = { style_map = {
"normal": "white", "normal": "white", "header": "bold cyan",
"header": "bold cyan",
"footer": "white on rgb(20,40,80)", "footer": "white on rgb(20,40,80)",
"highlight": "bold white on rgb(30,60,120)", "highlight": "bold white on rgb(30,60,120)",
"border": "blue", "border": "blue", "title": "bold white",
"title": "bold white", "error": "bold red", "info": "dim cyan",
"error": "bold red", "field_label": "cyan", "field_value": "bold white",
"info": "dim cyan",
"field_label": "cyan",
"field_value": "bold white",
"field_active": "bold white on rgb(0,100,140)", "field_active": "bold white on rgb(0,100,140)",
} }
# For positioned text, write directly (used by dialogs)
rich_style = style_map.get(style, "white") 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.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() sys.stdout.flush()
self._console.print(styled, end="")
def draw_box(self, top, left, height, width, title=''): 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_H = "\u2500"
BOX_V = "\u2502" BOX_V = "\u2502"
BOX_TL = "\u256d" # rounded corners for modern look BOX_TL = "\u256d"
BOX_TR = "\u256e" BOX_TR = "\u256e"
BOX_BL = "\u2570" BOX_BL = "\u2570"
BOX_BR = "\u256f" BOX_BR = "\u256f"
@@ -555,31 +494,29 @@ class TextualRenderer(BaseRenderer):
if height < 2 or width < 2: if height < 2 or width < 2:
return return
# Top border style = "blue"
if title: if title:
t = truncate(title, width - 4) t = truncate(title, width - 4)
top_line = (BOX_TL + BOX_H + t top_line = BOX_TL + BOX_H + t + BOX_H * (width - 3 - len(t)) + BOX_TR
+ BOX_H * (width - 3 - len(t)) + BOX_TR)
else: else:
top_line = BOX_TL + BOX_H * (width - 2) + BOX_TR top_line = BOX_TL + BOX_H * (width - 2) + BOX_TR
sys.stdout.write(f"\033[{top + 1};{left + 1}H") self._write_at(top, left, top_line, style)
styled = Text(top_line, style="blue")
self._console.print(styled, end="")
# Side borders
for r in range(1, height - 1): for r in range(1, height - 1):
sys.stdout.write(f"\033[{top + r + 1};{left + 1}H") self._write_at(top + r, left, BOX_V + " " * (width - 2) + BOX_V,
styled = Text(BOX_V, style="blue") style)
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 bottom_line = BOX_BL + BOX_H * (width - 2) + BOX_BR
sys.stdout.write(f"\033[{top + height};{left + 1}H") self._write_at(top + height - 1, left, bottom_line, style)
styled = Text(bottom_line, style="blue")
self._console.print(styled, end="") 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 ────────────────────────────────────────────────────── # ── Dialogs ──────────────────────────────────────────────────────
@@ -591,48 +528,27 @@ class TextualRenderer(BaseRenderer):
top = max((h - box_h) // 2, 0) top = max((h - box_h) // 2, 0)
left = max((w - box_w) // 2, 0) left = max((w - box_w) // 2, 0)
# Determine style
if msg_type == "error": if msg_type == "error":
border_style = "bold red"
text_style = "bold red" text_style = "bold red"
title_label = " Error "
elif msg_type == "confirm": elif msg_type == "confirm":
border_style = "bold yellow" text_style = "bold yellow"
text_style = "white"
title_label = " Confirmar "
else: else:
border_style = "bold cyan" text_style = "bold cyan"
text_style = "white"
title_label = " Info "
# Draw box self.draw_box(top, left, box_h, box_w)
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): for i, line in enumerate(lines):
x = left + max((box_w - len(line)) // 2, 2) x = left + max((box_w - len(line)) // 2, 2)
sys.stdout.write(f"\033[{top + 2 + i};{x + 1}H") self._write_at(top + 1 + i, x, line, text_style)
styled = Text(line, style=text_style)
self._console.print(styled, end="")
# Prompt line
if msg_type == "confirm": if msg_type == "confirm":
prompt = "[S]i / [N]o" prompt = "[S]i / [N]o"
else: else:
prompt = "Presione cualquier tecla..." prompt = "Presione cualquier tecla..."
px = left + max((box_w - len(prompt)) // 2, 2) px = left + max((box_w - len(prompt)) // 2, 2)
sys.stdout.write(f"\033[{top + box_h};{px + 1}H") self._write_at(top + box_h - 2, px, prompt, "bold cyan")
prompt_styled = Text(prompt, style="bold cyan")
self._console.print(prompt_styled, end="")
sys.stdout.flush() sys.stdout.flush()
# Wait for key
if msg_type == "confirm": if msg_type == "confirm":
while True: while True:
key = self.get_key() key = self.get_key()
@@ -652,39 +568,23 @@ class TextualRenderer(BaseRenderer):
left = max((w - box_w) // 2, 0) left = max((w - box_w) // 2, 0)
buf = [] buf = []
# Show cursor during input # Show cursor during input
sys.stdout.write("\033[?25h") sys.stdout.write("\033[?25h")
sys.stdout.flush() sys.stdout.flush()
while True: while True:
self.draw_box(top, left, box_h, box_w, " Entrada ") self.draw_box(top, left, box_h, box_w)
# Fill interior self._write_at(top + 1, left + 2, prompt, "cyan")
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) val = "".join(buf)
display = pad_right(val, max_len) display = pad_right(val, max_len)
sys.stdout.write(f"\033[{top + 3};{left + 3}H") self._write_at(top + 2, left + 2, f"[{display}]",
field = Text(f"[{display}]", "bold white on rgb(0,100,140)")
style="bold white on rgb(0,100,140)")
self._console.print(field, end="")
# Hint
hint = "ENTER=Aceptar ESC=Cancelar" hint = "ENTER=Aceptar ESC=Cancelar"
hx = left + max((box_w - len(hint)) // 2, 2) hx = left + max((box_w - len(hint)) // 2, 2)
sys.stdout.write(f"\033[{top + 4};{hx + 1}H") self._write_at(top + 3, hx, hint, "dim cyan")
hint_styled = Text(hint, style="dim cyan")
self._console.print(hint_styled, end="")
sys.stdout.flush() sys.stdout.flush()
key = self.get_key() key = self.get_key()
@@ -706,7 +606,7 @@ class TextualRenderer(BaseRenderer):
# ── Module-level helpers ───────────────────────────────────────────── # ── Module-level helpers ─────────────────────────────────────────────
def _has_data(fd, timeout=0.05): def _has_data(fd, timeout=0.02):
"""Return True if there is data waiting on file descriptor *fd*.""" """Return True if there is data waiting on file descriptor *fd*."""
r, _, _ = select.select([fd], [], [], timeout) r, _, _ = select.select([fd], [], [], timeout)
return bool(r) return bool(r)