""" 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. """ import sys import tty import termios import select 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 from console.core.keybindings import Key from console.renderers.base import BaseRenderer from console.utils.formatting import pad_right, truncate class TextualRenderer(BaseRenderer): """Rich-based modern renderer with blue/cyan colour scheme.""" def __init__(self): self._console = None self._old_term_settings = None # ── 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: 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") sys.stdout.flush() def cleanup(self): """Restore the terminal to its original state.""" # Show cursor sys.stdout.write("\033[?25h") sys.stdout.flush() # Restore original terminal attributes if self._old_term_settings is not None: try: fd = sys.stdin.fileno() termios.tcsetattr(fd, termios.TCSADRAIN, self._old_term_settings) except (termios.error, ValueError, OSError): pass self._old_term_settings = None 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) # ── Primitive operations ───────────────────────────────────────── def clear(self): """Clear the screen.""" self._console.clear() def refresh(self): """No-op -- Rich prints immediately to stdout.""" pass 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). """ 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 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) 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) # ── High-level widgets ─────────────────────────────────────────── def draw_header(self, title, subtitle=''): h, w = self.get_size() header = Text() header.append(title, style="bold cyan") if subtitle: padding = w - len(title) - len(subtitle) if padding > 0: header.append(" " * padding) header.append(subtitle, style="dim white") # 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="") def draw_footer(self, key_labels): h, w = self.get_size() # Separator sep = Text("─" * w, style="blue") self._console.print(sep, end="") # Key labels 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="") def draw_menu(self, items, selected_index=0, title=''): h, w = self.get_size() visible_lines = h - 6 # header(2) + footer(2) + margins 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 visible_lines -= 2 if visible_lines < 1: return # Scrolling offset offset = 0 if selected_index >= visible_lines: offset = selected_index - visible_lines + 1 drawn = 0 for idx, (num, label) in enumerate(items): if drawn >= visible_lines: break if idx < offset: continue # Separator if num == "\u2500" or num == "---": sep = Text(" " + "─" * (w - 4), style="dim blue") self._console.print(sep, end="") 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)") else: line.append(f" {marker}{num}. {label}", style="white") self._console.print(line, end="") drawn += 1 def draw_table(self, headers, rows, widths, page_info=None, selected_row=-1): h, w = self.get_size() table = Table( box=rich_box.SIMPLE_HEAD, show_edge=False, pad_edge=False, expand=True, style="white", header_style="bold cyan", row_styles=["white", "dim white"], ) # 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 if visible < 1: visible = 1 for i, row_data in enumerate(rows): if i >= visible: break cells = [str(v) for v in row_data] row_style = ("bold white on rgb(30,60,120)" if i == selected_row else None) table.add_row(str(i + 1), *cells, style=row_style) self._console.print(table, end="") 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( 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 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 for i, (label, value) in enumerate(fields): if i >= lines_available: break dots = "." * (dot_total - len(label)) line = Text() line.append(f" {label}{dots}: ", style="cyan") line.append(str(value), style="bold white") self._console.print(line, end="") 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 max_label = max((len(f.get("label", "")) for f in fields), default=10) dot_total = max_label + 4 for i, field in enumerate(fields): label = field.get("label", "") value = field.get("value", "") fw = field.get("width", 20) hint = field.get("hint", "") dots = "." * (dot_total - len(label)) num_str = f"{i + 1}. " line = Text() line.append(f" {num_str}{label}{dots}: ", style="cyan") display_val = pad_right(str(value), fw) if i == focused_index: line.append(f"[{display_val}]", style="bold white on rgb(0,100,140)") else: line.append(f"[{display_val}]", style="white") if hint: line.append(f" {hint}", style="dim cyan") self._console.print(line, end="") # Blank line between fields for spacing self._console.print("", end="") 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="") # Separator sep = Text(" " + "─" * (w - 4), style="blue") self._console.print(sep, end="") # 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="") # Separator self._console.print(sep, end="") # Scrollable list visible = h - 10 # header, title, filter, separators, footer, count if visible < 1: visible = 1 offset = 0 if selected_index >= visible: offset = selected_index - visible + 1 drawn = 0 for idx, (num, label) in enumerate(items): if drawn >= visible: break if idx < offset: continue marker = "\u25b8 " if idx == selected_index else " " 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)") else: line.append(f" {marker}{num}. {label}", style="white") self._console.print(line, end="") drawn += 1 # Count at bottom count_line = Text() count_line.append(f" {len(items)} elementos", style="dim cyan") self._console.print(count_line, end="") 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 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, pad_edge=True, expand=True, style="white", 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, ) # Data rows -- use the first column's labels as the canonical set if not columns[0].get("rows"): self._console.print(table, end="") return n_rows = len(columns[0]["rows"]) max_rows = h - 8 for i in range(min(n_rows, max_rows)): lbl = (columns[0]["rows"][i][0] if i < len(columns[0]["rows"]) else "") vals = [] for col in columns: rows_data = col.get("rows", []) val = rows_data[i][1] if i < len(rows_data) else "" vals.append(str(val)) table.add_row(lbl, *vals) self._console.print(table, end="") # ── 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", "field_active": "bold white on rgb(0,100,140)", } 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.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_TR = "\u256e" BOX_BL = "\u2570" BOX_BR = "\u256f" if height < 2 or width < 2: return # Top border 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 sys.stdout.write(f"\033[{top + 1};{left + 1}H") styled = Text(top_line, style="blue") self._console.print(styled, end="") # 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="") # 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="") # ── Dialogs ────────────────────────────────────────────────────── def show_message(self, text, msg_type='info') -> bool: h, w = self.get_size() lines = text.split("\n") box_w = max(max((len(l) for l in lines), default=20) + 6, 30) box_h = len(lines) + 4 top = max((h - box_h) // 2, 0) left = max((w - box_w) // 2, 0) # 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 " else: border_style = "bold cyan" text_style = "white" title_label = " Info " # 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="") 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="") # 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="") sys.stdout.flush() # Wait for key if msg_type == "confirm": while True: key = self.get_key() if key in (ord("s"), ord("S")): return True if key in (ord("n"), ord("N"), Key.ESCAPE): return False else: self.get_key() return True def show_input(self, prompt, max_len=40): h, w = self.get_size() box_w = max(len(prompt) + max_len + 8, 30) box_h = 5 top = max((h - box_h) // 2, 0) left = max((w - box_w) // 2, 0) buf = [] # Show cursor during input sys.stdout.write("\033[?25h") sys.stdout.flush() while True: self.draw_box(top, left, box_h, box_w, " Entrada ") # 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="") # 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="") # 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="") sys.stdout.flush() key = self.get_key() if key == Key.ESCAPE: sys.stdout.write("\033[?25l") sys.stdout.flush() return None elif key == Key.ENTER: sys.stdout.write("\033[?25l") sys.stdout.flush() return "".join(buf) elif key == Key.BACKSPACE: if buf: buf.pop() elif 32 <= key <= 126: if len(buf) < max_len: buf.append(chr(key)) # ── Module-level helpers ───────────────────────────────────────────── def _has_data(fd, timeout=0.05): """Return True if there is data waiting on file descriptor *fd*.""" r, _, _ = select.select([fd], [], [], timeout) return bool(r)