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.
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.
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.panel import Panel
from rich.text import Text
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.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._console = None
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):
"""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:
"""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
# Hide cursor
sys.stdout.write("\033[?25l")
# 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 the terminal to its original state."""
# Show cursor
sys.stdout.write("\033[?25h")
"""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:
@@ -62,129 +69,64 @@ class TextualRenderer(BaseRenderer):
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)
"""Return ``(height, width)``."""
return (self._height, self._width)
# ── Primitive operations ─────────────────────────────────────────
def clear(self):
"""Clear the screen."""
self._console.clear()
"""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):
"""No-op -- Rich prints immediately to stdout."""
pass
"""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 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).
"""
"""Read a key (terminal is already in raw mode)."""
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
return self._parse_csi(fd)
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
return self._parse_ss3(fd)
# Drain unknown escape
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":
@@ -194,11 +136,58 @@ class TextualRenderer(BaseRenderer):
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)
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 ───────────────────────────────────────────
@@ -211,50 +200,46 @@ class TextualRenderer(BaseRenderer):
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="")
self._buffer.append(header)
self._buffer.append(Text("" * w, style="blue"))
def draw_footer(self, key_labels):
h, w = self.get_size()
# Separator
sep = Text("" * w, style="blue")
self._console.print(sep, end="")
# Key labels
# 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)")
# 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="")
self._buffer.append(footer)
def draw_menu(self, items, selected_index=0, title=''):
h, w = self.get_size()
visible_lines = h - 6 # header(2) + footer(2) + margins
visible_lines = h - 6
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
self._buffer.append(Text(f" {title}", style="bold white"))
self._buffer.append(Text(""))
visible_lines -= 2
if visible_lines < 1:
return
# Scrolling offset
offset = 0
if selected_index >= visible_lines:
offset = selected_index - visible_lines + 1
@@ -266,24 +251,23 @@ class TextualRenderer(BaseRenderer):
if idx < offset:
continue
# Separator
if num == "\u2500" or num == "---":
sep = Text(" " + "" * (w - 4), style="dim blue")
self._console.print(sep, end="")
self._buffer.append(
Text(" " + "" * (w - 4), style="dim blue")
)
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)")
entry = pad_right(f"{marker}{num}. {label}", w - 4)
self._buffer.append(
Text(f" {entry}", style="bold white on rgb(30,60,120)")
)
else:
line.append(f" {marker}{num}. {label}", style="white")
self._console.print(line, end="")
self._buffer.append(
Text(f" {marker}{num}. {label}", style="white")
)
drawn += 1
def draw_table(self, headers, rows, widths, page_info=None,
@@ -300,13 +284,11 @@ class TextualRenderer(BaseRenderer):
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
visible = h - 8
if visible < 1:
visible = 1
@@ -318,36 +300,28 @@ class TextualRenderer(BaseRenderer):
if i == selected_row else None)
table.add_row(str(i + 1), *cells, style=row_style)
self._console.print(table, end="")
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))
info = Text()
info.append(
self._buffer.append(Text(
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
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
if title:
lines_available -= 3
lines_available = h - 6 - (3 if title else 0)
for i, (label, value) in enumerate(fields):
if i >= lines_available:
@@ -356,18 +330,15 @@ class TextualRenderer(BaseRenderer):
line = Text()
line.append(f" {label}{dots}: ", style="cyan")
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=''):
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
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
@@ -394,35 +365,27 @@ class TextualRenderer(BaseRenderer):
if hint:
line.append(f" {hint}", style="dim cyan")
self._console.print(line, end="")
# Blank line between fields for spacing
self._console.print("", end="")
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:
title_text = Text()
title_text.append(f" {title}", style="bold white")
self._console.print(title_text, end="")
self._buffer.append(Text(f" {title}", style="bold white"))
# Separator
sep = Text(" " + "" * (w - 4), style="blue")
self._console.print(sep, end="")
self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
# 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="")
filter_line.append(filter_text + "_",
style="bold white on rgb(0,100,140)")
self._buffer.append(filter_line)
# Separator
self._console.print(sep, end="")
self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
# Scrollable list
visible = h - 10 # header, title, filter, separators, footer, count
visible = h - 10
if visible < 1:
visible = 1
@@ -438,39 +401,34 @@ class TextualRenderer(BaseRenderer):
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}",
entry = pad_right(f"{marker}{num}. {label}", w - 4)
self._buffer.append(
Text(f" {entry}",
style="bold white on rgb(30,60,120)")
)
else:
line.append(f" {marker}{num}. {label}", style="white")
self._console.print(line, end="")
self._buffer.append(
Text(f" {marker}{num}. {label}", style="white")
)
drawn += 1
# Count at bottom
count_line = Text()
count_line.append(f" {len(items)} elementos", style="dim cyan")
self._console.print(count_line, end="")
self._buffer.append(Text(
f" {len(items)} elementos", style="dim cyan"
))
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
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
# Build a Rich Table for the comparison
table = Table(
box=rich_box.SIMPLE_HEAD,
show_edge=False,
@@ -480,19 +438,13 @@ class TextualRenderer(BaseRenderer):
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,
)
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="")
self._buffer.append(table)
return
n_rows = len(columns[0]["rows"])
@@ -507,47 +459,34 @@ class TextualRenderer(BaseRenderer):
vals.append(str(val))
table.add_row(lbl, *vals)
self._console.print(table, end="")
self._buffer.append(table)
# ── 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",
"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",
"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")
styled = Text(text, style=rich_style)
# Use ANSI escape to position cursor
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()
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_TL = "\u256d"
BOX_TR = "\u256e"
BOX_BL = "\u2570"
BOX_BR = "\u256f"
@@ -555,31 +494,29 @@ class TextualRenderer(BaseRenderer):
if height < 2 or width < 2:
return
# Top border
style = "blue"
if title:
t = truncate(title, width - 4)
top_line = (BOX_TL + BOX_H + t
+ BOX_H * (width - 3 - len(t)) + BOX_TR)
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="")
self._write_at(top, left, top_line, style)
# 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="")
self._write_at(top + r, left, BOX_V + " " * (width - 2) + BOX_V,
style)
# 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="")
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 ──────────────────────────────────────────────────────
@@ -591,48 +528,27 @@ class TextualRenderer(BaseRenderer):
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 "
text_style = "bold yellow"
else:
border_style = "bold cyan"
text_style = "white"
title_label = " Info "
text_style = "bold cyan"
# 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="")
self.draw_box(top, left, box_h, box_w)
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="")
self._write_at(top + 1 + i, x, line, text_style)
# 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="")
self._write_at(top + box_h - 2, px, prompt, "bold cyan")
sys.stdout.flush()
# Wait for key
if msg_type == "confirm":
while True:
key = self.get_key()
@@ -652,39 +568,23 @@ class TextualRenderer(BaseRenderer):
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 ")
self.draw_box(top, left, box_h, box_w)
# 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="")
self._write_at(top + 1, left + 2, prompt, "cyan")
# 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="")
self._write_at(top + 2, left + 2, f"[{display}]",
"bold white on rgb(0,100,140)")
# 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="")
self._write_at(top + 3, hx, hint, "dim cyan")
sys.stdout.flush()
key = self.get_key()
@@ -706,7 +606,7 @@ class TextualRenderer(BaseRenderer):
# ── 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*."""
r, _, _ = select.select([fd], [], [], timeout)
return bool(r)