diff --git a/console/renderers/textual_renderer.py b/console/renderers/textual_renderer.py index 41166e7..ec63cea 100644 --- a/console/renderers/textual_renderer.py +++ b/console/renderers/textual_renderer.py @@ -4,6 +4,9 @@ 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. + +Key input uses os.read() on the raw fd to bypass Python's stdin buffer, +which ensures escape sequences (arrow keys, F-keys) are read correctly. """ import os @@ -33,15 +36,17 @@ class TextualRenderer(BaseRenderer): self._old_term_settings = None self._width = 80 self._height = 24 - self._buffer = [] # list of Rich renderables per render cycle + self._fd = -1 + self._buffer = [] # list of Rich renderables per render cycle + self._line_count = 0 # track actual lines consumed by buffer # ── Lifecycle ──────────────────────────────────────────────────── def init_screen(self): """Enter alternate screen, hide cursor, set persistent raw mode.""" - fd = sys.stdin.fileno() + self._fd = sys.stdin.fileno() try: - self._old_term_settings = termios.tcgetattr(fd) + self._old_term_settings = termios.tcgetattr(self._fd) except (termios.error, ValueError, OSError): self._old_term_settings = None @@ -51,7 +56,7 @@ class TextualRenderer(BaseRenderer): # Set raw mode once for the entire session try: - tty.setraw(fd) + tty.setraw(self._fd) except termios.error: pass @@ -63,8 +68,7 @@ class TextualRenderer(BaseRenderer): # Restore original terminal attributes if self._old_term_settings is not None: try: - fd = sys.stdin.fileno() - termios.tcsetattr(fd, termios.TCSADRAIN, + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_term_settings) except (termios.error, ValueError, OSError): pass @@ -87,10 +91,20 @@ class TextualRenderer(BaseRenderer): except OSError: pass self._buffer = [] + self._line_count = 0 + + def _add_line(self, renderable): + """Add a single-line renderable to the buffer.""" + self._buffer.append(renderable) + self._line_count += 1 + + def _add_lines(self, renderable, line_count): + """Add a multi-line renderable (like a Table) to the buffer.""" + self._buffer.append(renderable) + self._line_count += line_count 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, @@ -100,10 +114,7 @@ class TextualRenderer(BaseRenderer): 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) + console.print(item, end="", highlight=False) rendered = sio.getvalue() @@ -111,82 +122,100 @@ class TextualRenderer(BaseRenderer): 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) + # ── Key input (uses os.read to bypass Python stdin buffer) ────── - 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) + def _read_byte(self) -> bytes: + """Read exactly one byte from the raw fd.""" + return os.read(self._fd, 1) + + def _has_data(self, timeout=0.02) -> bool: + """Return True if there is data waiting on the fd.""" + r, _, _ = select.select([self._fd], [], [], timeout) + return bool(r) + + def get_key(self) -> int: + """Read a key using os.read() to bypass Python's stdin buffer.""" + b = self._read_byte() + ch = b[0] if b else 0 + + if ch == 0x1b: # ESC + if self._has_data(): + b2 = self._read_byte() + ch2 = b2[0] if b2 else 0 + if ch2 == 0x5b: # [ + return self._parse_csi() + elif ch2 == 0x4f: # O + return self._parse_ss3() # Drain unknown escape - while _has_data(fd): - sys.stdin.read(1) + while self._has_data(): + self._read_byte() return Key.ESCAPE return Key.ESCAPE - if ch == "\r" or ch == "\n": + if ch == 0x0d or ch == 0x0a: # CR or LF return Key.ENTER - if ch == "\t": + if ch == 0x09: # TAB return Key.TAB - if ch == "\x7f" or ch == "\x08": + if ch == 0x7f or ch == 0x08: # DEL or BS return Key.BACKSPACE - if ch == "\x03": + if ch == 0x03: # Ctrl-C return Key.ESCAPE - return ord(ch) + return ch - def _parse_csi(self, fd): + def _read_one(self) -> int: + """Read one byte and return as int.""" + b = self._read_byte() + return b[0] if b else 0 + + def _parse_csi(self): """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) # ~ + ch3 = self._read_one() + if ch3 == 0x41: return Key.UP # A + if ch3 == 0x42: return Key.DOWN # B + if ch3 == 0x43: return Key.RIGHT # C + if ch3 == 0x44: return Key.LEFT # D + if ch3 == 0x48: return Key.HOME # H + if ch3 == 0x46: return Key.END # F + if ch3 == 0x35: # 5 + self._read_one() # ~ return Key.PGUP - if ch3 == "6": - sys.stdin.read(1) # ~ + if ch3 == 0x36: # 6 + self._read_one() # ~ 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) + if ch3 == 0x31: # 1 + ch4 = self._read_one() + if ch4 == 0x7e: return Key.HOME # 1~ + if ch4 == 0x35: self._read_one(); return Key.F5 # 15~ + if ch4 == 0x37: self._read_one(); return Key.F6 # 17~ + if ch4 == 0x38: self._read_one(); return Key.F7 # 18~ + if ch4 == 0x39: self._read_one(); return Key.F8 # 19~ + if self._has_data(): self._read_one() 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 + if ch3 == 0x32: # 2 + ch4 = self._read_one() + if ch4 == 0x30: self._read_one(); return Key.F9 # 20~ + if ch4 == 0x31: self._read_one(); return Key.F10 # 21~ return Key.ESCAPE - if ch3 == "3": - if _has_data(fd): sys.stdin.read(1) + if ch3 == 0x33: # 3 (Delete) + if self._has_data(): self._read_one() return Key.BACKSPACE - if ch3 == "4": - if _has_data(fd): sys.stdin.read(1) + if ch3 == 0x34: # 4 + if self._has_data(): self._read_one() return Key.END - while _has_data(fd): - sys.stdin.read(1) + # Drain remaining + while self._has_data(): + self._read_one() return Key.ESCAPE - def _parse_ss3(self, fd): + def _parse_ss3(self): """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 + ch3 = self._read_one() + if ch3 == 0x50: return Key.F1 # P + if ch3 == 0x51: return Key.F2 # Q + if ch3 == 0x52: return Key.F3 # R + if ch3 == 0x53: return Key.F4 # S + if ch3 == 0x48: return Key.HOME # H + if ch3 == 0x46: return Key.END # F return Key.ESCAPE # ── High-level widgets ─────────────────────────────────────────── @@ -203,18 +232,17 @@ class TextualRenderer(BaseRenderer): 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")) + self._add_line(header) + self._add_line(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 + # Pad remaining lines to push footer to bottom + remaining = h - self._line_count - 2 # 2 = separator + footer for _ in range(max(remaining, 0)): - self._buffer.append(Text("")) + self._add_line(Text("")) - self._buffer.append(Text("─" * w, style="blue")) + self._add_line(Text("─" * w, style="blue")) footer = Text() for i, (key, desc) in enumerate(key_labels): if i > 0: @@ -226,15 +254,15 @@ class TextualRenderer(BaseRenderer): " " * (w - footer.cell_len), style="on rgb(20,40,80)", ) - self._buffer.append(footer) + self._add_line(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("")) + self._add_line(Text(f" {title}", style="bold white")) + self._add_line(Text("")) visible_lines -= 2 if visible_lines < 1: @@ -252,7 +280,7 @@ class TextualRenderer(BaseRenderer): continue if num == "\u2500" or num == "---": - self._buffer.append( + self._add_line( Text(" " + "─" * (w - 4), style="dim blue") ) drawn += 1 @@ -261,11 +289,11 @@ class TextualRenderer(BaseRenderer): marker = "\u25b8 " if idx == selected_index else " " if idx == selected_index: entry = pad_right(f"{marker}{num}. {label}", w - 4) - self._buffer.append( + self._add_line( Text(f" {entry}", style="bold white on rgb(30,60,120)") ) else: - self._buffer.append( + self._add_line( Text(f" {marker}{num}. {label}", style="white") ) drawn += 1 @@ -292,6 +320,7 @@ class TextualRenderer(BaseRenderer): if visible < 1: visible = 1 + actual_rows = 0 for i, row_data in enumerate(rows): if i >= visible: break @@ -299,14 +328,16 @@ class TextualRenderer(BaseRenderer): 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) + actual_rows += 1 - self._buffer.append(table) + # Table renders: 1 header + 1 separator + actual_rows + self._add_lines(table, actual_rows + 2) 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( + self._add_line(Text( f" Pagina {page}/{total} ({total_rows} registros)", style="dim cyan", )) @@ -315,9 +346,9 @@ class TextualRenderer(BaseRenderer): 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("")) + self._add_line(Text(f" {title}", style="bold white")) + self._add_line(Text(" " + "─" * (w - 4), style="blue")) + self._add_line(Text("")) max_label = max((len(lbl) for lbl, _ in fields), default=10) dot_total = max_label + 4 @@ -330,15 +361,15 @@ class TextualRenderer(BaseRenderer): line = Text() line.append(f" {label}{dots}: ", style="cyan") line.append(str(value), style="bold white") - self._buffer.append(line) + self._add_line(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("")) + self._add_line(Text(f" {title}", style="bold white")) + self._add_line(Text(" " + "─" * (w - 4), style="blue")) + self._add_line(Text("")) max_label = max((len(f.get("label", "")) for f in fields), default=10) dot_total = max_label + 4 @@ -365,25 +396,25 @@ class TextualRenderer(BaseRenderer): if hint: line.append(f" {hint}", style="dim cyan") - self._buffer.append(line) - self._buffer.append(Text("")) # spacing + self._add_line(line) + self._add_line(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._add_line(Text(f" {title}", style="bold white")) - self._buffer.append(Text(" " + "─" * (w - 4), style="blue")) + self._add_line(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._add_line(filter_line) - self._buffer.append(Text(" " + "─" * (w - 4), style="blue")) + self._add_line(Text(" " + "─" * (w - 4), style="blue")) visible = h - 10 if visible < 1: @@ -403,17 +434,17 @@ class TextualRenderer(BaseRenderer): marker = "\u25b8 " if idx == selected_index else " " if idx == selected_index: entry = pad_right(f"{marker}{num}. {label}", w - 4) - self._buffer.append( + self._add_line( Text(f" {entry}", style="bold white on rgb(30,60,120)") ) else: - self._buffer.append( + self._add_line( Text(f" {marker}{num}. {label}", style="white") ) drawn += 1 - self._buffer.append(Text( + self._add_line(Text( f" {len(items)} elementos", style="dim cyan" )) @@ -421,9 +452,9 @@ class TextualRenderer(BaseRenderer): 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("")) + self._add_line(Text(f" {title}", style="bold white")) + self._add_line(Text(" " + "─" * (w - 4), style="blue")) + self._add_line(Text("")) n_cols = len(columns) if n_cols == 0: @@ -444,12 +475,13 @@ class TextualRenderer(BaseRenderer): no_wrap=True) if not columns[0].get("rows"): - self._buffer.append(table) + self._add_lines(table, 2) return n_rows = len(columns[0]["rows"]) max_rows = h - 8 - for i in range(min(n_rows, max_rows)): + actual = min(n_rows, max_rows) + for i in range(actual): lbl = (columns[0]["rows"][i][0] if i < len(columns[0]["rows"]) else "") vals = [] @@ -459,10 +491,18 @@ class TextualRenderer(BaseRenderer): vals.append(str(val)) table.add_row(lbl, *vals) - self._buffer.append(table) + self._add_lines(table, actual + 2) # ── Low-level drawing ──────────────────────────────────────────── + def _render_styled(self, text, style="white"): + """Render styled text to a string via Rich.""" + sio = StringIO() + c = Console(file=sio, width=self._width, highlight=False, + force_terminal=True, color_system="truecolor") + c.print(Text(text, style=style), end="") + return sio.getvalue() + def draw_text(self, row, col, text, style='normal'): style_map = { "normal": "white", "header": "bold cyan", @@ -473,14 +513,9 @@ class TextualRenderer(BaseRenderer): "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()) + rendered = self._render_styled(text, rich_style) + sys.stdout.write(f"\033[{row + 1};{col + 1}H{rendered}") sys.stdout.flush() def draw_box(self, top, left, height, width, title=''): @@ -494,29 +529,25 @@ class TextualRenderer(BaseRenderer): 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) + self._write_at(top, left, top_line, "blue") for r in range(1, height - 1): - self._write_at(top + r, left, BOX_V + " " * (width - 2) + BOX_V, - style) + self._write_at(top + r, left, + BOX_V + " " * (width - 2) + BOX_V, "blue") bottom_line = BOX_BL + BOX_H * (width - 2) + BOX_BR - self._write_at(top + height - 1, left, bottom_line, style) + self._write_at(top + height - 1, left, bottom_line, "blue") 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()}") + rendered = self._render_styled(text, style) + sys.stdout.write(f"\033[{row + 1};{col + 1}H{rendered}") # ── Dialogs ────────────────────────────────────────────────────── @@ -568,7 +599,6 @@ class TextualRenderer(BaseRenderer): left = max((w - box_w) // 2, 0) buf = [] - # Show cursor during input sys.stdout.write("\033[?25h") sys.stdout.flush() @@ -602,11 +632,3 @@ class TextualRenderer(BaseRenderer): 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)