diff --git a/console/renderers/textual_renderer.py b/console/renderers/textual_renderer.py new file mode 100644 index 0000000..1c2dc41 --- /dev/null +++ b/console/renderers/textual_renderer.py @@ -0,0 +1,712 @@ +""" +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)