221 lines
6.8 KiB
Python
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
|