""" Curses-based VT220 renderer for the AUTOPARTES console application. Implements :class:`BaseRenderer` with a green-on-black aesthetic inspired by classic Pick/UNIX VT220 terminals. All drawing is done through Python's built-in :mod:`curses` library. """ import curses from console.config import COLORS_VT220 from console.renderers.base import BaseRenderer from console.utils.formatting import pad_right, truncate # ── Colour-name-to-curses mapping ──────────────────────────────────── _CURSES_COLORS = { "black": curses.COLOR_BLACK, "red": curses.COLOR_RED, "green": curses.COLOR_GREEN, "yellow": curses.COLOR_YELLOW, "blue": curses.COLOR_BLUE, "magenta": curses.COLOR_MAGENTA, "cyan": curses.COLOR_CYAN, "white": curses.COLOR_WHITE, } # Box-drawing characters _BOX_H = "\u2500" # ─ _BOX_V = "\u2502" # │ _BOX_TL = "\u250c" # ┌ _BOX_TR = "\u2510" # ┐ _BOX_BL = "\u2514" # └ _BOX_BR = "\u2518" # ┘ class CursesRenderer(BaseRenderer): """Full curses implementation of the VT220 green-on-black renderer.""" def __init__(self): self._screen = None self._color_pairs: dict[str, int] = {} # ── Lifecycle ──────────────────────────────────────────────────── def init_screen(self): """Set up curses: raw mode, no echo, hidden cursor, colours.""" self._screen = curses.initscr() curses.noecho() curses.cbreak() curses.curs_set(0) self._screen.keypad(True) self._init_colors() def cleanup(self): """Restore the terminal to a usable state.""" if self._screen is None: return try: curses.nocbreak() self._screen.keypad(False) curses.echo() except curses.error: pass curses.endwin() self._screen = None # ── Screen queries ─────────────────────────────────────────────── def get_size(self) -> tuple: """Return ``(height, width)``.""" return self._screen.getmaxyx() # ── Primitive operations ───────────────────────────────────────── def clear(self): self._screen.erase() def refresh(self): self._screen.refresh() def get_key(self) -> int: return self._screen.getch() # ── Colour helpers ─────────────────────────────────────────────── def _init_colors(self): """Initialise curses colour pairs from ``COLORS_VT220``.""" curses.start_color() curses.use_default_colors() for idx, (name, (fg, bg)) in enumerate(COLORS_VT220.items(), start=1): curses.init_pair(idx, _CURSES_COLORS[fg], _CURSES_COLORS[bg]) self._color_pairs[name] = idx def _attr(self, style: str) -> int: """Return the curses attribute for a named style. Falls back to the *normal* pair if *style* is unknown. """ pair_id = self._color_pairs.get(style, self._color_pairs.get("normal", 1)) attr = curses.color_pair(pair_id) if style in ("header", "title"): attr |= curses.A_BOLD return attr # ── Safe drawing helpers ───────────────────────────────────────── def _safe_addstr(self, row, col, text, attr=None): """Write *text* at (row, col), silently ignoring edge overflows.""" if attr is None: attr = self._attr("normal") h, w = self.get_size() if row < 0 or row >= h or col >= w: return # Truncate to fit within the screen width max_chars = w - col if max_chars <= 0: return text = text[:max_chars] try: self._screen.addstr(row, col, text, attr) except curses.error: # Writing to the bottom-right corner raises an error after # the character is actually drawn. Safe to ignore. pass def _hline(self, row, col, width, char=_BOX_H, style="border"): """Draw a horizontal line of *char* across *width* columns.""" self._safe_addstr(row, col, char * width, self._attr(style)) # ── High-level widgets ─────────────────────────────────────────── def draw_header(self, title, subtitle=''): h, w = self.get_size() attr = self._attr("header") # Row 0: title (left) + subtitle (right) header_line = pad_right(title, w) if subtitle: sub = subtitle[:w - len(title) - 1] header_line = (title + " " * max(w - len(title) - len(sub), 0) + sub) header_line = pad_right(header_line, w) self._safe_addstr(0, 0, header_line, attr | curses.A_BOLD) # Row 1: separator self._hline(1, 0, w) def draw_footer(self, key_labels): h, w = self.get_size() if h < 3: return # Row h-2: separator self._hline(h - 2, 0, w) # Row h-1: key labels attr = self._attr("footer") parts = [f"{k}={d}" for k, d in key_labels] line = " ".join(parts) self._safe_addstr(h - 1, 0, pad_right(line, w), attr) def draw_menu(self, items, selected_index=0, title=''): h, w = self.get_size() start_row = 3 if title: self._safe_addstr(start_row, 2, title, self._attr("title")) start_row += 2 visible = h - start_row - 3 # leave room for footer if visible < 1: return # Scrolling offset 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 row = start_row + drawn # Separator if num == "\u2500" or num == "---": self._hline(row, 2, w - 4) drawn += 1 continue marker = "\u25b8 " if idx == selected_index else " " text = f"{marker}{num}. {label}" style = "highlight" if idx == selected_index else "normal" self._safe_addstr(row, 2, pad_right(text, w - 4), self._attr(style)) drawn += 1 def draw_table(self, headers, rows, widths, page_info=None, selected_row=-1): h, w = self.get_size() start_row = 3 # Header row header_cells = [pad_right(hdr, wd) for hdr, wd in zip(headers, widths)] header_text = " # " + " \u2502 ".join(header_cells) self._safe_addstr(start_row, 0, pad_right(header_text, w), self._attr("title")) # Separator self._hline(start_row + 1, 0, w) visible = h - start_row - 5 # room for header, sep, footer if visible < 1: return for i, row_data in enumerate(rows): if i >= visible: break row_num = start_row + 2 + i row_idx_str = pad_right(str(i + 1), 3) cells = [pad_right(str(v), wd) for v, wd in zip(row_data, widths)] line = f" {row_idx_str}\u2502 " + " \u2502 ".join(cells) style = "highlight" if i == selected_row else "normal" self._safe_addstr(row_num, 0, pad_right(line, w), self._attr(style)) # Page info if page_info: info_row = start_row + 2 + min(len(rows), visible) page = page_info.get("page", 1) total = page_info.get("total_pages", 1) total_rows = page_info.get("total_rows", len(rows)) info_text = (f" Pagina {page}/{total}" f" ({total_rows} registros)") self._safe_addstr(info_row, 0, info_text, self._attr("info")) def draw_detail(self, fields, title=''): h, w = self.get_size() start_row = 3 if title: self._safe_addstr(start_row, 2, title, self._attr("title")) self._hline(start_row + 1, 2, w - 4) start_row += 3 # Determine max label width for alignment max_label = max((len(lbl) for lbl, _ in fields), default=10) dot_total = max_label + 4 # label + dots for i, (label, value) in enumerate(fields): row = start_row + i if row >= h - 3: break dots = "." * (dot_total - len(label)) label_part = f" {label}{dots}: " self._safe_addstr(row, 0, label_part, self._attr("field_label")) self._safe_addstr(row, len(label_part), str(value), self._attr("field_value")) def draw_form(self, fields, focused_index=0, title=''): h, w = self.get_size() start_row = 3 if title: self._safe_addstr(start_row, 2, title, self._attr("title")) self._hline(start_row + 1, 2, w - 4) start_row += 3 max_label = max((len(f.get("label", "")) for f in fields), default=10) dot_total = max_label + 4 for i, field in enumerate(fields): row = start_row + i * 2 # space between fields if row >= h - 3: break 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}. " label_part = f" {num_str}{label}{dots}: " self._safe_addstr(row, 0, label_part, self._attr("field_label")) # Editable field value in brackets style = "field_active" if i == focused_index else "field_value" display_val = pad_right(str(value), fw) field_text = f"[{display_val}]" self._safe_addstr(row, len(label_part), field_text, self._attr(style)) # Optional hint if hint: hint_col = len(label_part) + len(field_text) + 2 self._safe_addstr(row, hint_col, hint, self._attr("info")) def draw_filter_list(self, items, filter_text, selected_index, title=''): h, w = self.get_size() start_row = 3 if title: self._safe_addstr(start_row, 2, title, self._attr("title")) start_row += 1 # Separator self._hline(start_row, 2, w - 4) start_row += 1 # Filter input prompt = "Filtro: " self._safe_addstr(start_row, 2, prompt, self._attr("field_label")) self._safe_addstr(start_row, 2 + len(prompt), filter_text + "_", self._attr("field_active")) start_row += 1 # Separator self._hline(start_row, 2, w - 4) start_row += 1 # Scrollable list visible = h - start_row - 4 if visible < 1: return 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 row = start_row + drawn marker = "\u25b8 " if idx == selected_index else " " text = f"{marker}{num}. {label}" style = "highlight" if idx == selected_index else "normal" self._safe_addstr(row, 2, pad_right(text, w - 4), self._attr(style)) drawn += 1 # Count at bottom count_row = start_row + min(drawn, visible) count_text = f" {len(items)} elementos" self._safe_addstr(count_row, 2, count_text, self._attr("info")) def draw_comparison(self, columns, title=''): h, w = self.get_size() start_row = 3 if title: self._safe_addstr(start_row, 2, title, self._attr("title")) self._hline(start_row + 1, 2, w - 4) start_row += 3 n_cols = len(columns) if n_cols == 0: return # Determine label width from the first column's row labels all_labels = [] for col in columns: for lbl, _ in col.get("rows", []): all_labels.append(lbl) label_w = max((len(l) for l in all_labels), default=8) + 2 # Available width for data columns avail = w - label_w - 4 col_w = max(avail // n_cols, 10) # Header row header_line = pad_right("", label_w) for col in columns: header_line += " \u2502 " + pad_right(col.get("header", ""), col_w) self._safe_addstr(start_row, 2, header_line, self._attr("title")) # Separator self._hline(start_row + 1, 2, w - 4) # Data rows — use the first column's labels as the canonical set if not columns[0].get("rows"): return n_rows = len(columns[0]["rows"]) for i in range(n_rows): row = start_row + 2 + i if row >= h - 3: break lbl = columns[0]["rows"][i][0] if i < len(columns[0]["rows"]) else "" line = pad_right(lbl, label_w) for col in columns: rows_data = col.get("rows", []) val = rows_data[i][1] if i < len(rows_data) else "" line += " \u2502 " + pad_right(str(val), col_w) self._safe_addstr(row, 2, line, self._attr("normal")) # ── Low-level drawing ──────────────────────────────────────────── def draw_text(self, row, col, text, style='normal'): self._safe_addstr(row, col, text, self._attr(style)) def draw_box(self, top, left, height, width, title=''): if height < 2 or width < 2: return attr = self._attr("border") # Top border top_line = _BOX_TL + _BOX_H * (width - 2) + _BOX_TR if title: t = truncate(title, width - 4) top_line = (_BOX_TL + _BOX_H + t + _BOX_H * (width - 3 - len(t)) + _BOX_TR) self._safe_addstr(top, left, top_line, attr) # Side borders for r in range(1, height - 1): self._safe_addstr(top + r, left, _BOX_V, attr) self._safe_addstr(top + r, left + width - 1, _BOX_V, attr) # Bottom border bottom_line = _BOX_BL + _BOX_H * (width - 2) + _BOX_BR self._safe_addstr(top + height - 1, left, bottom_line, attr) # ── 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) style = "error" if msg_type == "error" else "info" self.draw_box(top, left, box_h, box_w) # Fill interior with spaces interior_attr = self._attr(style) for r in range(1, box_h - 1): self._safe_addstr(top + r, left + 1, " " * (box_w - 2), interior_attr) # Draw message lines for i, line in enumerate(lines): x = left + max((box_w - len(line)) // 2, 2) self._safe_addstr(top + 1 + i, x, line, interior_attr) # 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) self._safe_addstr(top + box_h - 2, px, prompt, self._attr("highlight")) self.refresh() # 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"), 27): # 27 = ESC 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 = [] try: curses.curs_set(1) # show cursor during input except curses.error: pass while True: self.draw_box(top, left, box_h, box_w) # Fill interior interior_attr = self._attr("normal") for r in range(1, box_h - 1): self._safe_addstr(top + r, left + 1, " " * (box_w - 2), interior_attr) # Prompt self._safe_addstr(top + 1, left + 2, prompt, self._attr("field_label")) # Input field val = "".join(buf) field_text = "[" + pad_right(val, max_len) + "]" self._safe_addstr(top + 2, left + 2, field_text, self._attr("field_active")) # Hint hint = "ENTER=Aceptar ESC=Cancelar" hx = left + max((box_w - len(hint)) // 2, 2) self._safe_addstr(top + 3, hx, hint, self._attr("info")) self.refresh() key = self.get_key() if key == 27: # ESC try: curses.curs_set(0) except curses.error: pass return None elif key in (10, curses.KEY_ENTER): # ENTER try: curses.curs_set(0) except curses.error: pass return "".join(buf) elif key in (127, curses.KEY_BACKSPACE, 8): # BACKSPACE if buf: buf.pop() elif 32 <= key <= 126: # printable ASCII if len(buf) < max_len: buf.append(chr(key))