diff --git a/console/renderers/base.py b/console/renderers/base.py new file mode 100644 index 0000000..c879776 --- /dev/null +++ b/console/renderers/base.py @@ -0,0 +1,152 @@ +""" +Abstract base renderer interface for the AUTOPARTES console application. + +Every renderer (curses VT220, Textual/Rich, etc.) must subclass +:class:`BaseRenderer` and implement all of its methods. Screens call +these methods without knowing which backend is active. +""" + + +class BaseRenderer: + """Abstract interface that all renderers must implement. + + Methods raise :exc:`NotImplementedError` so that missing overrides + are caught immediately at runtime. + """ + + # ── Lifecycle ──────────────────────────────────────────────────── + + def init_screen(self): + """Initialise the terminal / display backend.""" + raise NotImplementedError + + def cleanup(self): + """Restore the terminal to its original state.""" + raise NotImplementedError + + # ── Screen queries ─────────────────────────────────────────────── + + def get_size(self) -> tuple: + """Return ``(height, width)`` of the usable display area.""" + raise NotImplementedError + + # ── Primitive operations ───────────────────────────────────────── + + def clear(self): + """Clear the entire screen buffer.""" + raise NotImplementedError + + def refresh(self): + """Flush the screen buffer to the terminal.""" + raise NotImplementedError + + def get_key(self) -> int: + """Block until a key is pressed and return its key code.""" + raise NotImplementedError + + # ── High-level widgets ─────────────────────────────────────────── + + def draw_header(self, title, subtitle=''): + """Draw the application header bar on the top two rows. + + *title* is left-aligned; *subtitle* is right-aligned. + Row 1 is a horizontal separator. + """ + raise NotImplementedError + + def draw_footer(self, key_labels): + """Draw the footer bar on the bottom two rows. + + *key_labels* is a list of ``(key, description)`` tuples, + e.g. ``[("F1", "Ayuda"), ("ESC", "Atras")]``. + """ + raise NotImplementedError + + def draw_menu(self, items, selected_index=0, title=''): + """Draw a numbered menu list starting at row 3. + + *items* is a list of ``(number, label)`` tuples. + Separator items have ``number == '---'``. + The item at *selected_index* is highlighted. + """ + raise NotImplementedError + + def draw_table(self, headers, rows, widths, page_info=None, + selected_row=-1): + """Draw a columnar data table. + + *headers*: list of column header strings. + *rows*: list of row tuples (each tuple matches *headers*). + *widths*: list of int column widths. + *page_info*: optional dict ``{page, total_pages, total_rows}``. + *selected_row*: index of the highlighted row (-1 = none). + """ + raise NotImplementedError + + def draw_detail(self, fields, title=''): + """Draw a detail view with label-value pairs. + + *fields* is a list of ``(label, value)`` tuples displayed as + ``Label........: Value``. + """ + raise NotImplementedError + + def draw_form(self, fields, focused_index=0, title=''): + """Draw an editable form. + + *fields* is a list of dicts with keys: + ``label``, ``value``, ``width``, ``type``, ``hint``. + The field at *focused_index* uses the active style. + """ + raise NotImplementedError + + def draw_filter_list(self, items, filter_text, selected_index, + title=''): + """Draw a filterable list with a text input at the top. + + *items*: list of ``(number, label)`` tuples. + *filter_text*: current filter string. + *selected_index*: highlighted item index. + """ + raise NotImplementedError + + def draw_comparison(self, columns, title=''): + """Draw a side-by-side comparison view. + + *columns* is a list of dicts, each with: + ``header`` (str) and ``rows`` (list of ``(label, value)``). + """ + raise NotImplementedError + + # ── Low-level drawing ──────────────────────────────────────────── + + def draw_text(self, row, col, text, style='normal'): + """Draw *text* at ``(row, col)`` using the named *style*.""" + raise NotImplementedError + + def draw_box(self, top, left, height, width, title=''): + """Draw a box with Unicode line-drawing characters. + + Optional *title* is rendered in the top border. + """ + raise NotImplementedError + + # ── Dialogs ────────────────────────────────────────────────────── + + def show_message(self, text, msg_type='info') -> bool: + """Show a centred message box. + + *msg_type* is one of ``'info'``, ``'error'``, or ``'confirm'``. + For ``'confirm'`` the user must press S (si) or N (no); + returns ``True`` for S, ``False`` for N. + For other types, waits for any key and returns ``True``. + """ + raise NotImplementedError + + def show_input(self, prompt, max_len=40): + """Show a centred input dialog. + + Returns the entered string, or ``None`` if the user pressed + Escape to cancel. + """ + raise NotImplementedError diff --git a/console/renderers/curses_renderer.py b/console/renderers/curses_renderer.py new file mode 100644 index 0000000..bf8bc5d --- /dev/null +++ b/console/renderers/curses_renderer.py @@ -0,0 +1,537 @@ +""" +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))