Compare commits
4 Commits
5336eb2c16
...
4811dff0ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4811dff0ec | ||
|
|
642f79e1ff | ||
|
|
185962b4af | ||
|
|
02f398cf3a |
206
collatz.py
206
collatz.py
@ -1,209 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import curses
|
import curses
|
||||||
import time
|
|
||||||
import os
|
from collatz_ui import run_collatz
|
||||||
from pathlib import Path
|
|
||||||
from mathstream import StreamNumber, add, mul, div, is_even, clear_logs
|
|
||||||
|
|
||||||
|
|
||||||
LOG_DIR = Path("instance/log")
|
def main() -> None:
|
||||||
|
"""Launch the Collatz curses dashboard."""
|
||||||
|
|
||||||
def collatz_step(n, three, two, one):
|
|
||||||
return div(n, two) if is_even(n) else add(mul(n, three), one)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_header(win, step, elapsed, avg_step, digits_len):
|
|
||||||
"""Render header above graph and panels."""
|
|
||||||
win.erase()
|
|
||||||
cols = curses.COLS - 1
|
|
||||||
bar = "─" * cols
|
|
||||||
lines = [
|
|
||||||
f" Collatz (3n + 1) Streamed Viewer ",
|
|
||||||
f" Step: {step}",
|
|
||||||
f" Elapsed: {elapsed:8.2f}s | Avg/Step: {avg_step:8.5f}s",
|
|
||||||
f" Digits: {digits_len:,} | ↑↓ scroll number | PgUp/PgDn scroll log | q quit",
|
|
||||||
]
|
|
||||||
for i, line in enumerate(lines):
|
|
||||||
win.addstr(i, 0, line[:cols], curses.color_pair(1))
|
|
||||||
win.addstr(len(lines), 0, bar, curses.color_pair(2))
|
|
||||||
win.noutrefresh()
|
|
||||||
|
|
||||||
|
|
||||||
def draw_graph(win, graph_buf, direction_up, width):
|
|
||||||
"""Render a single-line graph: grey ░ for empty, colored █ for data."""
|
|
||||||
win.erase()
|
|
||||||
# Derive the actual width of the window so we never draw past its edge.
|
|
||||||
_, max_x = win.getmaxyx()
|
|
||||||
effective_width = max_x or width
|
|
||||||
padding = 3 # leave space for arrow and a small gap
|
|
||||||
cols = max(0, effective_width - padding)
|
|
||||||
|
|
||||||
# Clamp buffer to visible width
|
|
||||||
visible = graph_buf[-cols:] if len(graph_buf) > cols else graph_buf
|
|
||||||
fill_len = len(visible)
|
|
||||||
|
|
||||||
# arrow first
|
|
||||||
if effective_width > 0:
|
|
||||||
arrow = "▲" if direction_up else "▼"
|
|
||||||
arrow_color = curses.color_pair(6 if direction_up else 5)
|
|
||||||
try:
|
|
||||||
win.addstr(0, 0, arrow, arrow_color)
|
|
||||||
except curses.error:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# draw filled section
|
|
||||||
for i, val in enumerate(visible):
|
|
||||||
col = padding + i
|
|
||||||
if col >= effective_width:
|
|
||||||
break
|
|
||||||
color = curses.color_pair(6 if val > 0 else 5)
|
|
||||||
try:
|
|
||||||
win.addstr(0, col, "█", color)
|
|
||||||
except curses.error:
|
|
||||||
break
|
|
||||||
|
|
||||||
# fill remaining with ░
|
|
||||||
remaining = max(0, cols - fill_len)
|
|
||||||
fill_start = padding + fill_len
|
|
||||||
if remaining > 0 and fill_start < effective_width:
|
|
||||||
run = min(remaining, effective_width - fill_start)
|
|
||||||
if run > 0:
|
|
||||||
try:
|
|
||||||
win.addstr(0, fill_start, "░" * run, curses.color_pair(7))
|
|
||||||
except curses.error:
|
|
||||||
pass
|
|
||||||
|
|
||||||
win.noutrefresh()
|
|
||||||
|
|
||||||
|
|
||||||
def draw_number(win, digits, scroll):
|
|
||||||
win.erase()
|
|
||||||
cols = curses.COLS
|
|
||||||
lines = [digits[i:i + cols - 1] for i in range(0, len(digits), cols - 1)]
|
|
||||||
max_lines = win.getmaxyx()[0]
|
|
||||||
scroll = max(0, min(scroll, max(0, len(lines) - max_lines)))
|
|
||||||
for i, chunk in enumerate(lines[scroll:scroll + max_lines]):
|
|
||||||
try:
|
|
||||||
win.addstr(i, 0, chunk, curses.color_pair(3))
|
|
||||||
except curses.error:
|
|
||||||
pass
|
|
||||||
win.noutrefresh()
|
|
||||||
return scroll
|
|
||||||
|
|
||||||
|
|
||||||
def draw_log_list(win, scroll):
|
|
||||||
win.erase()
|
|
||||||
if not LOG_DIR.exists():
|
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
files = sorted(LOG_DIR.iterdir(), key=os.path.getmtime, reverse=True)
|
|
||||||
names = [f"{f.name}" for f in files]
|
|
||||||
max_lines = win.getmaxyx()[0]
|
|
||||||
scroll = max(0, min(scroll, max(0, len(names) - max_lines)))
|
|
||||||
for i, name in enumerate(names[scroll:scroll + max_lines]):
|
|
||||||
try:
|
|
||||||
win.addstr(i, 0, name[: curses.COLS - 1], curses.color_pair(4))
|
|
||||||
except curses.error:
|
|
||||||
pass
|
|
||||||
win.noutrefresh()
|
|
||||||
return scroll
|
|
||||||
|
|
||||||
|
|
||||||
def run_collatz(stdscr):
|
|
||||||
curses.curs_set(0)
|
|
||||||
curses.start_color()
|
|
||||||
curses.use_default_colors()
|
|
||||||
curses.init_pair(1, curses.COLOR_CYAN, -1) # header text
|
|
||||||
curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_CYAN) # separator
|
|
||||||
curses.init_pair(3, curses.COLOR_WHITE, -1) # number
|
|
||||||
curses.init_pair(4, curses.COLOR_YELLOW, -1) # log list
|
|
||||||
curses.init_pair(5, curses.COLOR_RED, -1)
|
|
||||||
curses.init_pair(6, curses.COLOR_GREEN, -1)
|
|
||||||
curses.init_pair(7, curses.COLOR_WHITE, -1) # grey for empty
|
|
||||||
|
|
||||||
stdscr.nodelay(True)
|
|
||||||
stdscr.timeout(100)
|
|
||||||
|
|
||||||
start_file = Path("start.txt")
|
|
||||||
if not start_file.exists():
|
|
||||||
stdscr.addstr(0, 0, "Missing start.txt — please create one with your starting number.")
|
|
||||||
stdscr.refresh()
|
|
||||||
stdscr.getch()
|
|
||||||
return
|
|
||||||
|
|
||||||
clear_logs()
|
|
||||||
n = StreamNumber(start_file)
|
|
||||||
one, two, three = (StreamNumber(literal=s) for s in ("1", "2", "3"))
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
step = 0
|
|
||||||
num_scroll = 0
|
|
||||||
log_scroll = 0
|
|
||||||
|
|
||||||
header_h = 5
|
|
||||||
graph_h = 1
|
|
||||||
num_h = (curses.LINES - header_h - graph_h) * 3 // 4
|
|
||||||
log_h = curses.LINES - header_h - graph_h - num_h - 1
|
|
||||||
|
|
||||||
num_win = curses.newwin(num_h, curses.COLS, header_h + graph_h + 1, 0)
|
|
||||||
graph_win = curses.newwin(graph_h, curses.COLS, header_h + 1, 0)
|
|
||||||
log_win = curses.newwin(log_h, curses.COLS, header_h + graph_h + num_h + 2, 0)
|
|
||||||
|
|
||||||
last_len = 0
|
|
||||||
graph_buf = []
|
|
||||||
|
|
||||||
while True:
|
|
||||||
step += 1
|
|
||||||
n = collatz_step(n, three, two, one)
|
|
||||||
digits = "".join(n.stream()) or "0"
|
|
||||||
cur_len = len(digits)
|
|
||||||
diff = cur_len - last_len
|
|
||||||
last_len = cur_len
|
|
||||||
|
|
||||||
graph_width = curses.COLS - 4
|
|
||||||
|
|
||||||
# Add new value to graph buffer
|
|
||||||
if diff != 0:
|
|
||||||
graph_buf.append(1 if diff > 0 else -1)
|
|
||||||
else:
|
|
||||||
graph_buf.append(0)
|
|
||||||
|
|
||||||
# Shift left if full
|
|
||||||
if len(graph_buf) > graph_width:
|
|
||||||
graph_buf = graph_buf[-graph_width:]
|
|
||||||
|
|
||||||
direction_up = diff >= 0
|
|
||||||
elapsed = time.time() - start_time
|
|
||||||
avg_step = elapsed / step if step else 0.0
|
|
||||||
|
|
||||||
draw_header(stdscr, step, elapsed, avg_step, len(digits))
|
|
||||||
draw_graph(graph_win, graph_buf, direction_up, curses.COLS)
|
|
||||||
num_scroll = draw_number(num_win, digits, num_scroll)
|
|
||||||
log_scroll = draw_log_list(log_win, log_scroll)
|
|
||||||
|
|
||||||
curses.doupdate()
|
|
||||||
|
|
||||||
ch = stdscr.getch()
|
|
||||||
if ch == ord("q"):
|
|
||||||
break
|
|
||||||
elif ch == curses.KEY_UP:
|
|
||||||
num_scroll = max(0, num_scroll - 1)
|
|
||||||
elif ch == curses.KEY_DOWN:
|
|
||||||
num_scroll += 1
|
|
||||||
elif ch == curses.KEY_PPAGE:
|
|
||||||
log_scroll = max(0, log_scroll - 3)
|
|
||||||
elif ch == curses.KEY_NPAGE:
|
|
||||||
log_scroll += 3
|
|
||||||
|
|
||||||
if digits == "1":
|
|
||||||
stdscr.nodelay(False)
|
|
||||||
stdscr.addstr(curses.LINES - 1, 0, "Reached 1 — press any key to exit.")
|
|
||||||
stdscr.refresh()
|
|
||||||
stdscr.getch()
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
curses.wrapper(run_collatz)
|
curses.wrapper(run_collatz)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
3
collatz_ui/__init__.py
Normal file
3
collatz_ui/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .app import CollatzApp, run_collatz
|
||||||
|
|
||||||
|
__all__ = ["CollatzApp", "run_collatz"]
|
||||||
197
collatz_ui/app.py
Normal file
197
collatz_ui/app.py
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import curses
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from mathstream import StreamNumber, add, mul, div, is_even, clear_logs
|
||||||
|
|
||||||
|
from .colors import UIColors
|
||||||
|
from .dashboard import CollatzDashboard
|
||||||
|
|
||||||
|
|
||||||
|
LOG_DIR = Path("instance/log")
|
||||||
|
|
||||||
|
|
||||||
|
def collatz_step(
|
||||||
|
n: StreamNumber,
|
||||||
|
three: StreamNumber,
|
||||||
|
two: StreamNumber,
|
||||||
|
one: StreamNumber,
|
||||||
|
) -> StreamNumber:
|
||||||
|
"""Compute the next Collatz value."""
|
||||||
|
return div(n, two) if is_even(n) else add(mul(n, three), one)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SharedState:
|
||||||
|
step: int = 0
|
||||||
|
digits: str = "0"
|
||||||
|
delta_sign: int = 0
|
||||||
|
computing: bool = False
|
||||||
|
calc_start_time: Optional[float] = None
|
||||||
|
last_calc_duration: float = 0.0
|
||||||
|
reached_one: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CollatzApp:
|
||||||
|
"""Main application loop driving the Collatz visualization."""
|
||||||
|
|
||||||
|
def __init__(self, stdscr: "curses._CursesWindow") -> None:
|
||||||
|
self.stdscr = stdscr
|
||||||
|
self.dashboard = CollatzDashboard(stdscr, LOG_DIR)
|
||||||
|
self.start_time = time.time()
|
||||||
|
self.state = SharedState()
|
||||||
|
self._state_lock = threading.Lock()
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._worker: Optional[threading.Thread] = None
|
||||||
|
self._error: Optional[BaseException] = None
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
start_file = Path("start.txt")
|
||||||
|
if not start_file.exists():
|
||||||
|
self.dashboard.show_message(
|
||||||
|
"Missing start.txt — create one with your starting number.",
|
||||||
|
UIColors.DOWN,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
clear_logs()
|
||||||
|
n = StreamNumber(start_file)
|
||||||
|
one, two, three = (StreamNumber(literal=s) for s in ("1", "2", "3"))
|
||||||
|
|
||||||
|
initial_digits = "".join(n.stream()) or "0"
|
||||||
|
with self._state_lock:
|
||||||
|
self.state.digits = initial_digits
|
||||||
|
self.state.delta_sign = 0
|
||||||
|
self.state.step = 0
|
||||||
|
|
||||||
|
self.start_time = time.time()
|
||||||
|
self._worker = threading.Thread(
|
||||||
|
target=self._worker_loop,
|
||||||
|
args=(n, one, two, three),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._worker.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._ui_loop()
|
||||||
|
finally:
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._worker:
|
||||||
|
self._worker.join()
|
||||||
|
|
||||||
|
def _ui_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
snapshot = self._snapshot_state()
|
||||||
|
elapsed = time.time() - self.start_time
|
||||||
|
avg_step = elapsed / snapshot.step if snapshot.step else 0.0
|
||||||
|
if snapshot.computing and snapshot.calc_start_time is not None:
|
||||||
|
current_calc = time.time() - snapshot.calc_start_time
|
||||||
|
else:
|
||||||
|
current_calc = 0.0
|
||||||
|
last_calc = snapshot.last_calc_duration
|
||||||
|
|
||||||
|
digits = snapshot.digits or "0"
|
||||||
|
delta = snapshot.delta_sign
|
||||||
|
|
||||||
|
self.dashboard.render(
|
||||||
|
snapshot.step,
|
||||||
|
elapsed,
|
||||||
|
avg_step,
|
||||||
|
digits,
|
||||||
|
delta,
|
||||||
|
current_calc,
|
||||||
|
last_calc,
|
||||||
|
snapshot.computing,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._error:
|
||||||
|
self.dashboard.show_message(
|
||||||
|
f"Error: {self._error}", UIColors.DOWN
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
ch = self.stdscr.getch()
|
||||||
|
if ch == ord("q"):
|
||||||
|
self._stop_event.set()
|
||||||
|
break
|
||||||
|
if ch != -1:
|
||||||
|
self.dashboard.handle_input(ch)
|
||||||
|
|
||||||
|
if snapshot.reached_one:
|
||||||
|
self.dashboard.show_message(
|
||||||
|
"Reached 1 — press any key to exit.", UIColors.UP
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
def _snapshot_state(self) -> SharedState:
|
||||||
|
with self._state_lock:
|
||||||
|
return SharedState(
|
||||||
|
step=self.state.step,
|
||||||
|
digits=self.state.digits,
|
||||||
|
delta_sign=self.state.delta_sign,
|
||||||
|
computing=self.state.computing,
|
||||||
|
calc_start_time=self.state.calc_start_time,
|
||||||
|
last_calc_duration=self.state.last_calc_duration,
|
||||||
|
reached_one=self.state.reached_one,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _worker_loop(
|
||||||
|
self,
|
||||||
|
current: StreamNumber,
|
||||||
|
one: StreamNumber,
|
||||||
|
two: StreamNumber,
|
||||||
|
three: StreamNumber,
|
||||||
|
) -> None:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
with self._state_lock:
|
||||||
|
self.state.computing = True
|
||||||
|
self.state.calc_start_time = time.time()
|
||||||
|
|
||||||
|
even_step = is_even(current)
|
||||||
|
delta_sign = -1 if even_step else 1
|
||||||
|
calc_started = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
next_n = collatz_step(current, three, two, one)
|
||||||
|
digits = "".join(next_n.stream()) or "0"
|
||||||
|
except Exception as exc: # pragma: no cover - safeguard for worker errors
|
||||||
|
self._error = exc
|
||||||
|
with self._state_lock:
|
||||||
|
self.state.computing = False
|
||||||
|
self.state.calc_start_time = None
|
||||||
|
self._stop_event.set()
|
||||||
|
return
|
||||||
|
|
||||||
|
duration = time.time() - calc_started
|
||||||
|
|
||||||
|
with self._state_lock:
|
||||||
|
self.state.step += 1
|
||||||
|
self.state.digits = digits
|
||||||
|
self.state.delta_sign = delta_sign
|
||||||
|
self.state.last_calc_duration = duration
|
||||||
|
self.state.computing = False
|
||||||
|
self.state.calc_start_time = None
|
||||||
|
if digits == "1":
|
||||||
|
self.state.reached_one = True
|
||||||
|
|
||||||
|
current.free(delete_file=False)
|
||||||
|
current = next_n
|
||||||
|
|
||||||
|
if digits == "1":
|
||||||
|
self._stop_event.set()
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def run_collatz(stdscr: "curses._CursesWindow") -> None:
|
||||||
|
"""Entry point expected by curses.wrapper."""
|
||||||
|
CollatzApp(stdscr).run()
|
||||||
42
collatz_ui/colors.py
Normal file
42
collatz_ui/colors.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import curses
|
||||||
|
|
||||||
|
|
||||||
|
class UIColors:
|
||||||
|
"""Color pair identifiers and helpers for the Collatz dashboard."""
|
||||||
|
|
||||||
|
HEADER = 1
|
||||||
|
SEPARATOR = 2
|
||||||
|
NUMBER = 3
|
||||||
|
LOG = 4
|
||||||
|
DOWN = 5
|
||||||
|
UP = 6
|
||||||
|
NEUTRAL = 7
|
||||||
|
FILL = 8
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup(cls) -> None:
|
||||||
|
"""Initialize all color pairs with a neutral background."""
|
||||||
|
if not curses.has_colors():
|
||||||
|
return
|
||||||
|
curses.start_color()
|
||||||
|
curses.use_default_colors()
|
||||||
|
curses.init_pair(cls.HEADER, curses.COLOR_CYAN, -1)
|
||||||
|
curses.init_pair(cls.SEPARATOR, curses.COLOR_BLUE, -1)
|
||||||
|
curses.init_pair(cls.NUMBER, curses.COLOR_WHITE, -1)
|
||||||
|
curses.init_pair(cls.LOG, curses.COLOR_MAGENTA, -1)
|
||||||
|
curses.init_pair(cls.DOWN, curses.COLOR_RED, -1)
|
||||||
|
curses.init_pair(cls.UP, curses.COLOR_GREEN, -1)
|
||||||
|
curses.init_pair(cls.NEUTRAL, curses.COLOR_YELLOW, -1)
|
||||||
|
curses.init_pair(cls.FILL, curses.COLOR_BLUE, -1)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def style(pair: int, *, bold: bool = False, dim: bool = False) -> int:
|
||||||
|
"""Return a curses attribute with the requested style applied."""
|
||||||
|
attr = curses.color_pair(pair)
|
||||||
|
if bold:
|
||||||
|
attr |= curses.A_BOLD
|
||||||
|
if dim:
|
||||||
|
attr |= curses.A_DIM
|
||||||
|
return attr
|
||||||
93
collatz_ui/dashboard.py
Normal file
93
collatz_ui/dashboard.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import curses
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .colors import UIColors
|
||||||
|
from .views import HeaderView, GraphView, NumberView, LogView
|
||||||
|
|
||||||
|
|
||||||
|
class CollatzDashboard:
|
||||||
|
"""Coordinates curses windows and renders the visualization."""
|
||||||
|
|
||||||
|
def __init__(self, stdscr: "curses._CursesWindow", log_dir: Path) -> None:
|
||||||
|
self.stdscr = stdscr
|
||||||
|
self.log_dir = log_dir
|
||||||
|
try:
|
||||||
|
curses.curs_set(0)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
self.stdscr.nodelay(True)
|
||||||
|
self.stdscr.timeout(100)
|
||||||
|
|
||||||
|
UIColors.setup()
|
||||||
|
self.num_scroll = 0
|
||||||
|
self.log_scroll = 0
|
||||||
|
self._last_step = 0
|
||||||
|
self._build_views()
|
||||||
|
|
||||||
|
def _build_views(self) -> None:
|
||||||
|
lines = curses.LINES
|
||||||
|
cols = curses.COLS
|
||||||
|
|
||||||
|
header_h = 6
|
||||||
|
graph_h = 1
|
||||||
|
available = max(2, lines - header_h - graph_h - 2)
|
||||||
|
num_h = max(1, (available * 3) // 4)
|
||||||
|
log_h = max(1, available - num_h)
|
||||||
|
|
||||||
|
self.header = HeaderView(header_h, cols, 0, 0)
|
||||||
|
self.graph = GraphView(graph_h, cols, header_h + 1, 0)
|
||||||
|
self.number = NumberView(num_h, cols, header_h + graph_h + 1, 0)
|
||||||
|
self.log = LogView(log_h, cols, header_h + graph_h + num_h + 2, 0, self.log_dir)
|
||||||
|
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
step: int,
|
||||||
|
elapsed: float,
|
||||||
|
avg_step: float,
|
||||||
|
digits: str,
|
||||||
|
delta_sign: int,
|
||||||
|
current_calc: float,
|
||||||
|
last_calc: float,
|
||||||
|
computing: bool,
|
||||||
|
) -> None:
|
||||||
|
self.header.render(
|
||||||
|
step,
|
||||||
|
elapsed,
|
||||||
|
avg_step,
|
||||||
|
len(digits),
|
||||||
|
current_calc,
|
||||||
|
last_calc,
|
||||||
|
computing,
|
||||||
|
)
|
||||||
|
if step > self._last_step:
|
||||||
|
self.graph.add_delta(delta_sign)
|
||||||
|
self._last_step = step
|
||||||
|
else:
|
||||||
|
self.graph.redraw()
|
||||||
|
self.num_scroll = self.number.render(digits, self.num_scroll)
|
||||||
|
self.log_scroll = self.log.render(self.log_scroll)
|
||||||
|
curses.doupdate()
|
||||||
|
|
||||||
|
def handle_input(self, ch: int) -> None:
|
||||||
|
if ch == curses.KEY_UP:
|
||||||
|
self.num_scroll = max(0, self.num_scroll - 1)
|
||||||
|
elif ch == curses.KEY_DOWN:
|
||||||
|
self.num_scroll += 1
|
||||||
|
elif ch == curses.KEY_PPAGE:
|
||||||
|
self.log_scroll = max(0, self.log_scroll - 3)
|
||||||
|
elif ch == curses.KEY_NPAGE:
|
||||||
|
self.log_scroll += 3
|
||||||
|
|
||||||
|
def show_message(self, message: str, color_pair: int) -> None:
|
||||||
|
self.stdscr.nodelay(False)
|
||||||
|
self.stdscr.erase()
|
||||||
|
try:
|
||||||
|
self.stdscr.addstr(0, 0, message, UIColors.style(color_pair, bold=True))
|
||||||
|
except curses.error:
|
||||||
|
self.stdscr.addstr(0, 0, message)
|
||||||
|
self.stdscr.refresh()
|
||||||
|
self.stdscr.getch()
|
||||||
|
self.stdscr.nodelay(True)
|
||||||
|
self.stdscr.timeout(10)
|
||||||
220
collatz_ui/views.py
Normal file
220
collatz_ui/views.py
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
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
|
||||||
@ -62,6 +62,11 @@ Each arithmetic call writes its result back into `instance/log` (configurable vi
|
|||||||
|
|
||||||
Divide-by-zero scenarios raise the custom `DivideByZeroError` so callers can distinguish mathstream issues from Python’s native exceptions.
|
Divide-by-zero scenarios raise the custom `DivideByZeroError` so callers can distinguish mathstream issues from Python’s native exceptions.
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
- **Reuse literal streams** – `StreamNumber(literal=...)` persists a hashed copy under `LOG_DIR`. Reuse those objects (or their filenames) across operations instead of recreating them every call. Repeated literal construction churns the filesystem: you pay the cost to rewrite identical data, poll the logger database, and spike disk I/O. Hang on to the staged literal or memoize it so it can be streamed repeatedly without rewriting.
|
||||||
|
- **Free aggressively** – When a staged result or literal copy is no longer needed, call `free_stream()` (or use `with StreamNumber(...) as n:`) so the reference count drops immediately. This keeps the cache tidy and reduces the chance that stale literal files pile up between runs.
|
||||||
|
|
||||||
## Example Script
|
## Example Script
|
||||||
|
|
||||||
`test.py` in the repository root demonstrates a minimal workflow:
|
`test.py` in the repository root demonstrates a minimal workflow:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user