from __future__ import annotations import curses from pathlib import Path from typing import List, Tuple from .colors import UIColors _DIGIT_UNITS: List[Tuple[int, str]] = [ (10**12, "T digits"), (10**9, "G digits"), (10**6, "M digits"), (10**3, "K digits"), ] class HeaderView: """Top panel containing summary information.""" def __init__(self, height: int, width: int, y: int, x: int) -> None: self.win = curses.newwin(height, width, y, x) def render( self, step: int, elapsed: float, avg_step: float, digits_len: int, current_calc: float, last_calc: float, computing: bool, ) -> None: self.win.erase() _, cols = self.win.getmaxyx() cols = max(1, cols) usable = max(1, cols - 1) bar = "─" * usable digits_line = self._format_digits_line(step, digits_len) lines = [ " Collatz Stream Visualizer ", digits_line, f" Elapsed {elapsed:8.2f}s • Avg {avg_step:8.5f}s", f" Current calc {current_calc:6.2f}s {'⏳' if computing else '✓'} • Last {last_calc:6.2f}s", " ▲ green = 3n+1 • ▼ red = /2 • ↑↓ number scroll • PgUp/PgDn logs • q quit", ] header_style = UIColors.style(UIColors.HEADER, bold=True) for idx, line in enumerate(lines): try: self.win.addstr(idx, 0, line[:usable], header_style) except curses.error: pass try: self.win.addstr(len(lines), 0, bar, UIColors.style(UIColors.SEPARATOR)) except curses.error: pass self.win.noutrefresh() @staticmethod def _format_digits_line(step: int, digits_len: int) -> str: for threshold, label in _DIGIT_UNITS: if digits_len >= threshold: scaled = digits_len / float(threshold) return f" Step {step:,} • {scaled:,.2f} {label}" return f" Step {step:,} • Digits {digits_len:,}" class GraphView: """Single-row sparkline showing how each step transformed the value.""" def __init__(self, height: int, width: int, y: int, x: int, padding: int = 3) -> None: self.win = curses.newwin(height, width, y, x) self.padding = padding self.buffer: List[int] = [] self.last_delta = 0 def add_delta(self, delta: int) -> None: self.last_delta = delta self.buffer.append(delta) self._trim() self._render() def redraw(self) -> None: self._render() def _trim(self) -> None: _, max_x = self.win.getmaxyx() visible = max(0, max_x - self.padding) if visible == 0: self.buffer.clear() elif len(self.buffer) > visible: self.buffer = self.buffer[-visible:] def _arrow_style(self) -> tuple[str, int]: if self.last_delta > 0: return "▲", UIColors.style(UIColors.UP, bold=True) if self.last_delta < 0: return "▼", UIColors.style(UIColors.DOWN, bold=True) return "◆", UIColors.style(UIColors.NEUTRAL, bold=True) def _delta_color(self, delta: int) -> int: if delta > 0: return UIColors.style(UIColors.UP, bold=True) if delta < 0: return UIColors.style(UIColors.DOWN, bold=True) return UIColors.style(UIColors.NEUTRAL) def _render(self) -> None: self.win.erase() height, max_x = self.win.getmaxyx() if height <= 0 or max_x <= 0: return arrow, arrow_attr = self._arrow_style() try: self.win.addstr(0, 0, arrow, arrow_attr) except curses.error: pass if max_x > 2: try: self.win.addstr(0, 2, "│", UIColors.style(UIColors.SEPARATOR)) except curses.error: pass visible_slots = max(0, max_x - self.padding) data = self.buffer[-visible_slots:] if visible_slots else [] for idx, delta in enumerate(data): col = self.padding + idx if col >= max_x: break color_attr = self._delta_color(delta) try: self.win.addstr(0, col, "█", color_attr) except curses.error: break fill_start = self.padding + len(data) remaining = max(0, visible_slots - len(data)) if remaining > 0 and fill_start < max_x: run = min(remaining, max_x - fill_start) if run > 0: try: self.win.addstr( 0, fill_start, "░" * run, UIColors.style(UIColors.FILL, dim=True), ) except curses.error: pass self.win.noutrefresh() class NumberView: """Scrollable view for the current Collatz value.""" def __init__(self, height: int, width: int, y: int, x: int) -> None: self.win = curses.newwin(height, width, y, x) def render(self, digits: str, scroll: int) -> int: self.win.erase() max_y, max_x = self.win.getmaxyx() if max_y <= 0 or max_x <= 0: self.win.noutrefresh() return 0 cols = max(1, max_x - 1) lines = [digits[i:i + cols] for i in range(0, len(digits), cols)] or ["0"] max_scroll = max(0, len(lines) - max_y) scroll = max(0, min(scroll, max_scroll)) style = UIColors.style(UIColors.NUMBER) for idx, chunk in enumerate(lines[scroll:scroll + max_y]): try: self.win.addstr(idx, 0, chunk, style) except curses.error: pass self.win.noutrefresh() return scroll class LogView: """Scrollable listing of generated log files.""" def __init__(self, height: int, width: int, y: int, x: int, log_dir: Path) -> None: self.win = curses.newwin(height, width, y, x) self.log_dir = log_dir def render(self, scroll: int) -> int: self.win.erase() self.log_dir.mkdir(parents=True, exist_ok=True) files = sorted(self.log_dir.iterdir(), key=lambda f: f.stat().st_mtime, reverse=True) names = [f.name for f in files] max_y, max_x = self.win.getmaxyx() if max_y <= 0 or max_x <= 0: self.win.noutrefresh() return 0 max_scroll = max(0, len(names) - max_y) scroll = max(0, min(scroll, max_scroll)) usable = max(1, max_x - 1) style = UIColors.style(UIColors.LOG) for idx, name in enumerate(names[scroll:scroll + max_y]): try: self.win.addstr(idx, 0, name[:usable], style) except curses.error: pass self.win.noutrefresh() return scroll