mathy/collatz_ui/views.py
2025-11-05 11:15:33 +01:00

221 lines
6.8 KiB
Python

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