From 8c7caf39693e0f6282a2ba0bb3923643e35ae03a Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sun, 15 Feb 2026 02:25:35 +0000 Subject: [PATCH] fix(console): rewrite modern renderer with buffered output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- console/renderers/textual_renderer.py | 554 +++++++++++--------------- 1 file changed, 227 insertions(+), 327 deletions(-) diff --git a/console/renderers/textual_renderer.py b/console/renderers/textual_renderer.py index 1c2dc41..41166e7 100644 --- a/console/renderers/textual_renderer.py +++ b/console/renderers/textual_renderer.py @@ -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 + """Enter alternate screen, hide cursor, set persistent raw mode.""" + fd = sys.stdin.fileno() 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") + + # 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,143 +69,125 @@ 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) + 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) + 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": - # Ctrl-C -- treat as escape so the app can exit gracefully - return Key.ESCAPE - return ord(ch) - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old) + 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 ─────────────────────────────────────────── @@ -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}", - 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 - # 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", - "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", + "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") - 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)