better collatz

This commit is contained in:
Dominik Krenn 2025-11-05 10:38:16 +01:00
parent 5336eb2c16
commit 02f398cf3a
6 changed files with 380 additions and 202 deletions

View File

@ -1,209 +1,11 @@
#!/usr/bin/env python3
import curses
import time
import os
from pathlib import Path
from mathstream import StreamNumber, add, mul, div, is_even, clear_logs
from collatz_ui import run_collatz
LOG_DIR = Path("instance/log")
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():
def main() -> None:
"""Launch the Collatz curses dashboard."""
curses.wrapper(run_collatz)

3
collatz_ui/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .app import CollatzApp, run_collatz
__all__ = ["CollatzApp", "run_collatz"]

71
collatz_ui/app.py Normal file
View File

@ -0,0 +1,71 @@
from __future__ import annotations
import curses
import time
from pathlib import Path
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)
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.step = 0
self.start_time = time.time()
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"))
while True:
even_step = is_even(n)
delta_sign = -1 if even_step else 1
self.step += 1
n = collatz_step(n, three, two, one)
digits = "".join(n.stream()) or "0"
elapsed = time.time() - self.start_time
avg_step = elapsed / self.step if self.step else 0.0
self.dashboard.render(self.step, elapsed, avg_step, digits, delta_sign)
ch = self.stdscr.getch()
if ch == ord("q"):
break
if ch != -1:
self.dashboard.handle_input(ch)
if digits == "1":
self.dashboard.show_message(
"Reached 1 — press any key to exit.", UIColors.UP
)
break
def run_collatz(stdscr: "curses._CursesWindow") -> None:
"""Entry point expected by curses.wrapper."""
CollatzApp(stdscr).run()

42
collatz_ui/colors.py Normal file
View 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

70
collatz_ui/dashboard.py Normal file
View File

@ -0,0 +1,70 @@
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._build_views()
def _build_views(self) -> None:
lines = curses.LINES
cols = curses.COLS
header_h = 5
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) -> None:
self.header.render(step, elapsed, avg_step, len(digits))
self.graph.add_delta(delta_sign)
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)

190
collatz_ui/views.py Normal file
View File

@ -0,0 +1,190 @@
from __future__ import annotations
import curses
from pathlib import Path
from typing import List
from .colors import UIColors
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) -> None:
self.win.erase()
_, cols = self.win.getmaxyx()
cols = max(1, cols)
usable = max(1, cols - 1)
bar = "" * usable
lines = [
" Collatz Stream Visualizer ",
f" Step {step:,} • Digits {digits_len:,}",
f" Elapsed {elapsed:8.2f}s • Avg {avg_step:8.5f}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()
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 _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