""" Ncurses UI primitives for Prole Installer. Provides curses-based equivalents to the Tk/Canvas UI helpers in screen.py. """ from __future__ import annotations import curses import threading from typing import Callable, Optional class CursesWindow: """Wrapper for curses window with helper methods.""" def __init__(self, win): self.win = win self.height, self.width = win.getmaxyx() def clear(self): """Clear the window.""" self.win.clear() def refresh(self): """Refresh the window.""" try: self.win.refresh() except curses.error: pass def render_title(self, text: str, y: int = 2, x: int = 4): """Render a section title.""" try: self.win.addstr(y, x, text, curses.A_BOLD) except curses.error: pass def render_paragraph(self, text: str, y: int, x: int = 4, wrap: int = 70): """Render a paragraph with basic wrapping.""" lines = self._wrap_text(text, wrap) for i, line in enumerate(lines): try: self.win.addstr(y + i, x, line) except curses.error: pass def render_text(self, y: int, x: int, text: str, attr: int = curses.A_NORMAL): """Render text at specific position.""" try: self.win.addstr(y, x, text, attr) except curses.error: pass def render_line(self, y: int, char: str = '─'): """Render a horizontal line.""" try: self.win.addstr(y, 0, char * self.width) except curses.error: pass def render_box(self, y1: int, x1: int, y2: int, x2: int): """Render a box outline.""" try: # Top and bottom for x in range(x1 + 1, x2): self.win.addch(y1, x, curses.ACS_HLINE) self.win.addch(y2, x, curses.ACS_HLINE) # Sides for y in range(y1 + 1, y2): self.win.addch(y, x1, curses.ACS_VLINE) self.win.addch(y, x2, curses.ACS_VLINE) # Corners self.win.addch(y1, x1, curses.ACS_ULCORNER) self.win.addch(y1, x2, curses.ACS_URCORNER) self.win.addch(y2, x1, curses.ACS_LLCORNER) self.win.addch(y2, x2, curses.ACS_LRCORNER) except curses.error: pass def _wrap_text(self, text: str, width: int) -> list[str]: """Basic text wrapping.""" words = text.split() lines = [] current_line = [] current_length = 0 for word in words: word_length = len(word) if current_length + word_length + len(current_line) > width: if current_line: lines.append(' '.join(current_line)) current_line = [word] current_length = word_length else: lines.append(word[:width]) if len(word) > width: current_line = [word[width:]] current_length = len(word[width:]) else: current_line.append(word) current_length += word_length if current_line: lines.append(' '.join(current_line)) return lines class TerminalConsole: """Scrollable terminal console for ncurses.""" def __init__(self, win, height: int, width: int, y: int, x: int): self.win = win self.height = height self.width = width self.y = y self.x = x self.lines = [] self.scroll_offset = 0 self.lock = threading.Lock() def write(self, content: str): """Append text to console.""" with self.lock: for line in content.split('\n'): if line: self.lines.append(line) # Auto-scroll to bottom if len(self.lines) > self.height: self.scroll_offset = len(self.lines) - self.height def clear(self): """Clear console.""" with self.lock: self.lines = [] self.scroll_offset = 0 def render(self): """Render the console to the window.""" with self.lock: start_idx = self.scroll_offset end_idx = min(start_idx + self.height, len(self.lines)) for i, line in enumerate(self.lines[start_idx:end_idx]): try: # Truncate line if too long display_line = line[:self.width - 2] self.win.addstr(self.y + i, self.x, display_line) except curses.error: pass def scroll_up(self): """Scroll console up.""" with self.lock: if self.scroll_offset > 0: self.scroll_offset -= 1 def scroll_down(self): """Scroll console down.""" with self.lock: max_offset = max(0, len(self.lines) - self.height) if self.scroll_offset < max_offset: self.scroll_offset += 1 class NavFooter: """Navigation footer for ncurses UI.""" def __init__(self, win, height: int, width: int, y: int): self.win = win self.height = height self.width = width self.y = y self.buttons = [] # List of (label, callback, enabled) self.selected = 0 def set_buttons(self, buttons: list[tuple[str, Optional[Callable], bool]]): """Set button configuration: [(label, callback, enabled), ...]""" self.buttons = buttons self.selected = 0 def render(self): """Render the footer.""" try: # Draw separator line self.win.addstr(self.y, 0, '─' * self.width) # Render buttons right-aligned x_pos = self.width - 4 for i, (label, _, enabled) in enumerate(reversed(self.buttons)): if not enabled: continue btn_text = f"[ {label} ]" x_pos -= len(btn_text) + 2 attr = curses.A_REVERSE if (len(self.buttons) - 1 - i) == self.selected else curses.A_NORMAL if not enabled: attr |= curses.A_DIM self.win.addstr(self.y + 1, max(0, x_pos), btn_text, attr) except curses.error: pass def move_selection(self, delta: int): """Move button selection left or right.""" enabled_indices = [i for i, (_, _, enabled) in enumerate(self.buttons) if enabled] if not enabled_indices: return current_pos = enabled_indices.index(self.selected) if self.selected in enabled_indices else 0 new_pos = (current_pos + delta) % len(enabled_indices) self.selected = enabled_indices[new_pos] def activate_selected(self): """Activate the currently selected button.""" if 0 <= self.selected < len(self.buttons): label, callback, enabled = self.buttons[self.selected] if enabled and callback: callback() class InputField: """Text input field for ncurses.""" def __init__(self, win, y: int, x: int, width: int, password: bool = False): self.win = win self.y = y self.x = x self.width = width self.password = password self.value = "" self.cursor = 0 def render(self): """Render the input field.""" try: display = '*' * len(self.value) if self.password else self.value # Pad to width display = display[:self.width].ljust(self.width) self.win.addstr(self.y, self.x, display, curses.A_REVERSE) except curses.error: pass def handle_key(self, key: int) -> bool: """Handle keyboard input. Returns True if key was handled.""" if key == curses.KEY_BACKSPACE or key == 127 or key == 8: if self.cursor > 0: self.value = self.value[:self.cursor-1] + self.value[self.cursor:] self.cursor -= 1 return True elif key == curses.KEY_DC: # Delete if self.cursor < len(self.value): self.value = self.value[:self.cursor] + self.value[self.cursor+1:] return True elif key == curses.KEY_LEFT: if self.cursor > 0: self.cursor -= 1 return True elif key == curses.KEY_RIGHT: if self.cursor < len(self.value): self.cursor += 1 return True elif key == curses.KEY_HOME: self.cursor = 0 return True elif key == curses.KEY_END: self.cursor = len(self.value) return True elif 32 <= key <= 126: # Printable characters if len(self.value) < self.width: self.value = self.value[:self.cursor] + chr(key) + self.value[self.cursor:] self.cursor += 1 return True return False def get_value(self) -> str: """Get current input value.""" return self.value def set_value(self, value: str): """Set input value.""" self.value = value[:self.width] self.cursor = len(self.value) class Checkbox: """Checkbox for ncurses.""" def __init__(self, win, y: int, x: int, label: str, checked: bool = False): self.win = win self.y = y self.x = x self.label = label self.checked = checked def render(self, selected: bool = False): """Render the checkbox.""" try: checkbox = "[X]" if self.checked else "[ ]" attr = curses.A_REVERSE if selected else curses.A_NORMAL self.win.addstr(self.y, self.x, f"{checkbox} {self.label}", attr) except curses.error: pass def toggle(self): """Toggle checkbox state.""" self.checked = not self.checked def is_checked(self) -> bool: """Get checkbox state.""" return self.checked