""" 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. """ 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.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 # 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._old_term_settings = None self._width = 80 self._height = 24 self._buffer = [] # list of Rich renderables per render cycle # ── Lifecycle ──────────────────────────────────────────────────── def init_screen(self): """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 # 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 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: 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 # ── Screen queries ─────────────────────────────────────────────── def get_size(self) -> tuple: """Return ``(height, width)``.""" return (self._height, self._width) # ── Primitive operations ───────────────────────────────────────── def clear(self): """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): """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 key (terminal is already in raw mode).""" fd = sys.stdin.fileno() ch = sys.stdin.read(1) 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": 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 ─────────────────────────────────────────── 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") 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")) 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 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)") if footer.cell_len < w: footer.append( " " * (w - footer.cell_len), style="on rgb(20,40,80)", ) self._buffer.append(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("")) visible_lines -= 2 if visible_lines < 1: return 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 if num == "\u2500" or num == "---": self._buffer.append( Text(" " + "─" * (w - 4), style="dim blue") ) drawn += 1 continue marker = "\u25b8 " if idx == selected_index else " " if idx == selected_index: entry = pad_right(f"{marker}{num}. {label}", w - 4) self._buffer.append( Text(f" {entry}", style="bold white on rgb(30,60,120)") ) else: self._buffer.append( Text(f" {marker}{num}. {label}", style="white") ) 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"], ) 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 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._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)) self._buffer.append(Text( f" Pagina {page}/{total} ({total_rows} registros)", style="dim cyan", )) def draw_detail(self, fields, 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("")) max_label = max((len(lbl) for lbl, _ in fields), default=10) dot_total = max_label + 4 lines_available = h - 6 - (3 if title else 0) 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._buffer.append(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("")) 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._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: self._buffer.append(Text(f" {title}", style="bold white")) self._buffer.append(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._buffer.append(Text(" " + "─" * (w - 4), style="blue")) visible = h - 10 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 " " if idx == selected_index: entry = pad_right(f"{marker}{num}. {label}", w - 4) self._buffer.append( Text(f" {entry}", style="bold white on rgb(30,60,120)") ) else: self._buffer.append( Text(f" {marker}{num}. {label}", style="white") ) drawn += 1 self._buffer.append(Text( f" {len(items)} elementos", style="dim cyan" )) def draw_comparison(self, columns, 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("")) n_cols = len(columns) if n_cols == 0: return table = Table( box=rich_box.SIMPLE_HEAD, show_edge=False, pad_edge=True, expand=True, style="white", header_style="bold cyan", ) table.add_column("", style="cyan", no_wrap=True) for col in columns: table.add_column(col.get("header", ""), style="white", no_wrap=True) if not columns[0].get("rows"): self._buffer.append(table) 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._buffer.append(table) # ── Low-level drawing ──────────────────────────────────────────── def draw_text(self, row, col, text, style='normal'): 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)", } # 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()) sys.stdout.flush() def draw_box(self, top, left, height, width, title=''): BOX_H = "\u2500" BOX_V = "\u2502" BOX_TL = "\u256d" BOX_TR = "\u256e" BOX_BL = "\u2570" BOX_BR = "\u256f" 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) for r in range(1, height - 1): self._write_at(top + r, left, BOX_V + " " * (width - 2) + BOX_V, style) bottom_line = BOX_BL + BOX_H * (width - 2) + BOX_BR 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 ────────────────────────────────────────────────────── 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) if msg_type == "error": text_style = "bold red" elif msg_type == "confirm": text_style = "bold yellow" else: text_style = "bold cyan" self.draw_box(top, left, box_h, box_w) for i, line in enumerate(lines): x = left + max((box_w - len(line)) // 2, 2) self._write_at(top + 1 + i, x, line, text_style) if msg_type == "confirm": prompt = "[S]i / [N]o" else: prompt = "Presione cualquier tecla..." px = left + max((box_w - len(prompt)) // 2, 2) self._write_at(top + box_h - 2, px, prompt, "bold cyan") sys.stdout.flush() 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) self._write_at(top + 1, left + 2, prompt, "cyan") val = "".join(buf) display = pad_right(val, max_len) self._write_at(top + 2, left + 2, f"[{display}]", "bold white on rgb(0,100,140)") hint = "ENTER=Aceptar ESC=Cancelar" hx = left + max((box_w - len(hint)) // 2, 2) self._write_at(top + 3, hx, hint, "dim cyan") 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.02): """Return True if there is data waiting on file descriptor *fd*.""" r, _, _ = select.select([fd], [], [], timeout) return bool(r)