added find pi
This commit is contained in:
parent
f5cc02a08c
commit
88ee579114
140
find_my.py
Normal file
140
find_my.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Pi explorer powered by mathstream.
|
||||||
|
|
||||||
|
Computes π using the Nilakantha series with streamed big-int arithmetic and
|
||||||
|
optionally renders the progress in a curses dashboard (`--ui`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
from mathstream import (
|
||||||
|
clear_logs,
|
||||||
|
manual_free_only_enabled,
|
||||||
|
set_manual_free_only,
|
||||||
|
)
|
||||||
|
|
||||||
|
from pi_finder import iterate_nilakantha
|
||||||
|
|
||||||
|
|
||||||
|
def run_cli_mode(
|
||||||
|
*,
|
||||||
|
digits: int,
|
||||||
|
iterations: int,
|
||||||
|
extra_precision: int,
|
||||||
|
show_steps: bool,
|
||||||
|
) -> tuple[str, int]:
|
||||||
|
previous_manual = manual_free_only_enabled()
|
||||||
|
set_manual_free_only(True)
|
||||||
|
clear_logs()
|
||||||
|
|
||||||
|
final_value: Optional[str] = None
|
||||||
|
last_iteration = 0
|
||||||
|
try:
|
||||||
|
for state in iterate_nilakantha(
|
||||||
|
iterations,
|
||||||
|
digits=digits,
|
||||||
|
extra_precision=extra_precision,
|
||||||
|
):
|
||||||
|
final_value = state.digits
|
||||||
|
last_iteration = state.iteration
|
||||||
|
if show_steps:
|
||||||
|
print(f"[iter {state.iteration:02d}] {state.digits}")
|
||||||
|
|
||||||
|
if final_value is None:
|
||||||
|
final_value = "0"
|
||||||
|
last_iteration = 0
|
||||||
|
if show_steps:
|
||||||
|
print(final_value)
|
||||||
|
finally:
|
||||||
|
set_manual_free_only(previous_manual)
|
||||||
|
|
||||||
|
return final_value, last_iteration
|
||||||
|
|
||||||
|
|
||||||
|
def run_ui_mode(max_digits: Optional[int]) -> None:
|
||||||
|
try:
|
||||||
|
from pi_finder.ui import launch_pi_ui
|
||||||
|
except ImportError as exc: # pragma: no cover - import guard
|
||||||
|
raise SystemExit(f"pi UI is unavailable: {exc}") from exc
|
||||||
|
launch_pi_ui(max_digits=max_digits)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Nilakantha-based π explorer using mathstream streamed arithmetic."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--digits",
|
||||||
|
type=int,
|
||||||
|
default=25,
|
||||||
|
help="Digits of π to keep after the decimal point (default: 25).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--iterations",
|
||||||
|
type=int,
|
||||||
|
default=10,
|
||||||
|
help="Nilakantha iterations to run (each adds roughly two decimal digits).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--extra",
|
||||||
|
type=int,
|
||||||
|
default=4,
|
||||||
|
help="Extra guard digits maintained during calculation for stability (default: 4).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--show-steps",
|
||||||
|
action="store_true",
|
||||||
|
help="Print every intermediate approximation instead of just the final value.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=Path("found.pi"),
|
||||||
|
help="Optional path to write the final approximation (default: found.pi).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ui",
|
||||||
|
action="store_true",
|
||||||
|
help="Launch the curses dashboard instead of printing digits.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-digits",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Limit digits in UI mode (defaults to --digits).",
|
||||||
|
)
|
||||||
|
return parser.parse_args(list(argv) if argv is not None else None)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[Iterable[str]] = None) -> None:
|
||||||
|
args = parse_args(argv)
|
||||||
|
if args.ui:
|
||||||
|
precision = args.max_digits if args.max_digits is not None else max(10, args.digits)
|
||||||
|
run_ui_mode(precision)
|
||||||
|
else:
|
||||||
|
digits = max(0, args.digits)
|
||||||
|
iterations = max(0, args.iterations)
|
||||||
|
extra = max(0, args.extra)
|
||||||
|
result, last_iter = run_cli_mode(
|
||||||
|
digits=digits,
|
||||||
|
iterations=iterations,
|
||||||
|
extra_precision=extra,
|
||||||
|
show_steps=args.show_steps,
|
||||||
|
)
|
||||||
|
output_path: Path = args.output
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = result if result.endswith("\n") else f"{result}\n"
|
||||||
|
output_path.write_text(data, encoding="utf-8")
|
||||||
|
digits_written = sum(ch.isdigit() for ch in result)
|
||||||
|
print(
|
||||||
|
f"π approximation (iteration {last_iter}, {digits_written} digits) written to {output_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
9
pi_finder/__init__.py
Normal file
9
pi_finder/__init__.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .engine import PiApproximation, compute_pi_text, iterate_nilakantha
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PiApproximation",
|
||||||
|
"iterate_nilakantha",
|
||||||
|
"compute_pi_text",
|
||||||
|
]
|
||||||
142
pi_finder/engine.py
Normal file
142
pi_finder/engine.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
|
from mathstream import StreamNumber, add, sub, mul, div
|
||||||
|
from mathstream.number import LOG_DIR
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PiApproximation:
|
||||||
|
iteration: int
|
||||||
|
digits: str
|
||||||
|
|
||||||
|
|
||||||
|
def _literal(value: int | str) -> StreamNumber:
|
||||||
|
return StreamNumber(literal=str(value))
|
||||||
|
|
||||||
|
|
||||||
|
_SCALE_CACHE: dict[int, Path] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _scaled_one(scale_power: int) -> StreamNumber:
|
||||||
|
if scale_power < 0:
|
||||||
|
raise ValueError("scale_power must be non-negative")
|
||||||
|
cached = _SCALE_CACHE.get(scale_power)
|
||||||
|
if cached and cached.exists():
|
||||||
|
return StreamNumber(cached)
|
||||||
|
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = LOG_DIR / f"scale_1e{scale_power}.txt"
|
||||||
|
if not path.exists():
|
||||||
|
with open(path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("1")
|
||||||
|
remaining = scale_power
|
||||||
|
if remaining > 0:
|
||||||
|
chunk_size = 1_000_000
|
||||||
|
zero_chunk = "0" * chunk_size
|
||||||
|
while remaining > 0:
|
||||||
|
write_size = min(chunk_size, remaining)
|
||||||
|
handle.write(zero_chunk[:write_size])
|
||||||
|
remaining -= write_size
|
||||||
|
_SCALE_CACHE[scale_power] = path
|
||||||
|
return StreamNumber(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_scaled(value: StreamNumber, scale_power: int, digits: int) -> str:
|
||||||
|
raw = "".join(value.stream())
|
||||||
|
negative = raw.startswith("-")
|
||||||
|
if negative:
|
||||||
|
raw = raw[1:]
|
||||||
|
if len(raw) <= scale_power:
|
||||||
|
raw = raw.zfill(scale_power + 1)
|
||||||
|
integer_part = raw[:-scale_power] or "0"
|
||||||
|
fractional_part = raw[-scale_power:] if scale_power else ""
|
||||||
|
if digits >= 0 and fractional_part:
|
||||||
|
fractional_part = fractional_part[:digits]
|
||||||
|
text = integer_part
|
||||||
|
if digits != 0:
|
||||||
|
text = f"{integer_part}.{fractional_part or '0'}"
|
||||||
|
if negative:
|
||||||
|
text = f"-{text}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def iterate_nilakantha(
|
||||||
|
iterations: int | None,
|
||||||
|
*,
|
||||||
|
digits: int,
|
||||||
|
extra_precision: int = 4,
|
||||||
|
) -> Iterator[PiApproximation]:
|
||||||
|
"""Yield successive Nilakantha approximations of π using mathstream primitives."""
|
||||||
|
if iterations is not None and iterations < 0:
|
||||||
|
raise ValueError("iterations must be non-negative")
|
||||||
|
if digits < 0:
|
||||||
|
raise ValueError("digits must be non-negative")
|
||||||
|
|
||||||
|
scale_power = digits + extra_precision
|
||||||
|
scale = _scaled_one(scale_power)
|
||||||
|
three = _literal(3)
|
||||||
|
four = _literal(4)
|
||||||
|
|
||||||
|
total = mul(three, scale)
|
||||||
|
numerator = mul(four, scale)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield PiApproximation(0, _format_scaled(total, scale_power, digits))
|
||||||
|
|
||||||
|
counter: Iterable[int]
|
||||||
|
if iterations is None:
|
||||||
|
counter = itertools.count(1)
|
||||||
|
else:
|
||||||
|
counter = range(1, iterations + 1)
|
||||||
|
|
||||||
|
for idx in counter:
|
||||||
|
two_n = _literal(2 * idx)
|
||||||
|
two_n_plus_one = _literal(2 * idx + 1)
|
||||||
|
two_n_plus_two = _literal(2 * idx + 2)
|
||||||
|
|
||||||
|
product = mul(two_n, two_n_plus_one)
|
||||||
|
denominator = mul(product, two_n_plus_two)
|
||||||
|
term = div(numerator, denominator)
|
||||||
|
|
||||||
|
updated = add(total, term) if idx % 2 else sub(total, term)
|
||||||
|
|
||||||
|
total.free()
|
||||||
|
total = updated
|
||||||
|
|
||||||
|
yield PiApproximation(idx, _format_scaled(total, scale_power, digits))
|
||||||
|
|
||||||
|
for number in (
|
||||||
|
two_n,
|
||||||
|
two_n_plus_one,
|
||||||
|
two_n_plus_two,
|
||||||
|
product,
|
||||||
|
denominator,
|
||||||
|
term,
|
||||||
|
):
|
||||||
|
number.free()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
for number in (total, numerator, scale, three, four):
|
||||||
|
number.free()
|
||||||
|
|
||||||
|
|
||||||
|
def compute_pi_text(
|
||||||
|
iterations: int,
|
||||||
|
*,
|
||||||
|
digits: int,
|
||||||
|
extra_precision: int = 4,
|
||||||
|
) -> str:
|
||||||
|
"""Return the final Nilakantha approximation after the requested iterations."""
|
||||||
|
approximation: str = "0"
|
||||||
|
for state in iterate_nilakantha(
|
||||||
|
iterations,
|
||||||
|
digits=digits,
|
||||||
|
extra_precision=extra_precision,
|
||||||
|
):
|
||||||
|
approximation = state.digits
|
||||||
|
return approximation
|
||||||
18
pi_finder/ui/__init__.py
Normal file
18
pi_finder/ui/__init__.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import curses
|
||||||
|
from functools import partial
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .app import PiApp
|
||||||
|
|
||||||
|
|
||||||
|
def launch_pi_ui(max_digits: Optional[int] = None) -> None:
|
||||||
|
"""Launch the curses dashboard for streaming π digits."""
|
||||||
|
wrapper = partial(_run_curses_app, max_digits=max_digits)
|
||||||
|
curses.wrapper(wrapper) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_curses_app(stdscr: "curses._CursesWindow", max_digits: Optional[int]) -> None:
|
||||||
|
app = PiApp(stdscr, max_digits=max_digits)
|
||||||
|
app.run()
|
||||||
132
pi_finder/ui/app.py
Normal file
132
pi_finder/ui/app.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import curses
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from mathstream import clear_logs, manual_free_only_enabled, set_manual_free_only
|
||||||
|
|
||||||
|
from pi_finder.engine import iterate_nilakantha
|
||||||
|
|
||||||
|
from .dashboard import PiDashboard
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SharedState:
|
||||||
|
digits_text: str = "0"
|
||||||
|
digits_count: int = 0
|
||||||
|
last_digit: Optional[str] = None
|
||||||
|
computing: bool = False
|
||||||
|
iteration: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class PiApp:
|
||||||
|
"""Controller coordinating the π spigot worker and curses dashboard."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
stdscr: "curses._CursesWindow",
|
||||||
|
*,
|
||||||
|
max_digits: Optional[int],
|
||||||
|
) -> None:
|
||||||
|
self.stdscr = stdscr
|
||||||
|
self.dashboard = PiDashboard(stdscr)
|
||||||
|
requested = max_digits if max_digits is not None else 50
|
||||||
|
self.precision = max(1, requested)
|
||||||
|
|
||||||
|
self.state = SharedState()
|
||||||
|
self._state_lock = threading.Lock()
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._worker: Optional[threading.Thread] = None
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
self._start_time = time.time()
|
||||||
|
self._worker = threading.Thread(target=self._worker_loop, 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()
|
||||||
|
elapsed = time.time() - self._start_time
|
||||||
|
rate = snapshot.digits_count / elapsed if elapsed > 0 else 0.0
|
||||||
|
self.dashboard.render(
|
||||||
|
snapshot.digits_text,
|
||||||
|
snapshot.digits_count,
|
||||||
|
elapsed,
|
||||||
|
rate,
|
||||||
|
snapshot.last_digit,
|
||||||
|
snapshot.computing,
|
||||||
|
snapshot.iteration,
|
||||||
|
)
|
||||||
|
|
||||||
|
ch = self.stdscr.getch()
|
||||||
|
if ch == ord("q"):
|
||||||
|
self._stop_event.set()
|
||||||
|
break
|
||||||
|
if ch != -1:
|
||||||
|
self.dashboard.handle_input(ch)
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
def _worker_loop(self) -> None:
|
||||||
|
previous_manual = manual_free_only_enabled()
|
||||||
|
set_manual_free_only(True)
|
||||||
|
clear_logs()
|
||||||
|
generator = None
|
||||||
|
try:
|
||||||
|
generator = iterate_nilakantha(
|
||||||
|
iterations=None,
|
||||||
|
digits=self.precision,
|
||||||
|
extra_precision=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
for state in generator:
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
with self._state_lock:
|
||||||
|
self.state.computing = True
|
||||||
|
|
||||||
|
calc_start = time.time()
|
||||||
|
digits_text = state.digits
|
||||||
|
calc_duration = time.time() - calc_start
|
||||||
|
|
||||||
|
numeric_chars = [ch for ch in digits_text if ch.isdigit()]
|
||||||
|
last_digit = numeric_chars[-1] if numeric_chars else None
|
||||||
|
|
||||||
|
with self._state_lock:
|
||||||
|
self.state.digits_text = digits_text
|
||||||
|
self.state.digits_count = len(numeric_chars)
|
||||||
|
self.state.last_digit = last_digit
|
||||||
|
self.state.iteration = state.iteration
|
||||||
|
self.state.computing = False
|
||||||
|
|
||||||
|
if calc_duration < 0.05:
|
||||||
|
time.sleep(0.05)
|
||||||
|
finally:
|
||||||
|
if generator is not None:
|
||||||
|
generator.close()
|
||||||
|
set_manual_free_only(previous_manual)
|
||||||
|
|
||||||
|
with self._state_lock:
|
||||||
|
self.state.computing = False
|
||||||
|
|
||||||
|
def _snapshot(self) -> SharedState:
|
||||||
|
with self._state_lock:
|
||||||
|
return SharedState(
|
||||||
|
digits_text=self.state.digits_text,
|
||||||
|
digits_count=self.state.digits_count,
|
||||||
|
last_digit=self.state.last_digit,
|
||||||
|
computing=self.state.computing,
|
||||||
|
iteration=self.state.iteration,
|
||||||
|
)
|
||||||
38
pi_finder/ui/colors.py
Normal file
38
pi_finder/ui/colors.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import curses
|
||||||
|
|
||||||
|
|
||||||
|
class UIColors:
|
||||||
|
"""Shared color palette for the π dashboard."""
|
||||||
|
|
||||||
|
HEADER = 1
|
||||||
|
SEPARATOR = 2
|
||||||
|
NUMBER = 3
|
||||||
|
STATUS = 4
|
||||||
|
WARNING = 5
|
||||||
|
SUCCESS = 6
|
||||||
|
METRIC = 7
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup(cls) -> None:
|
||||||
|
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.STATUS, curses.COLOR_MAGENTA, -1)
|
||||||
|
curses.init_pair(cls.WARNING, curses.COLOR_RED, -1)
|
||||||
|
curses.init_pair(cls.SUCCESS, curses.COLOR_GREEN, -1)
|
||||||
|
curses.init_pair(cls.METRIC, curses.COLOR_YELLOW, -1)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def style(pair: int, *, bold: bool = False, dim: bool = False) -> int:
|
||||||
|
attr = curses.color_pair(pair)
|
||||||
|
if bold:
|
||||||
|
attr |= curses.A_BOLD
|
||||||
|
if dim:
|
||||||
|
attr |= curses.A_DIM
|
||||||
|
return attr
|
||||||
58
pi_finder/ui/dashboard.py
Normal file
58
pi_finder/ui/dashboard.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import curses
|
||||||
|
|
||||||
|
from .colors import UIColors
|
||||||
|
from .views import DigitsView, HeaderView, HelpView
|
||||||
|
|
||||||
|
|
||||||
|
class PiDashboard:
|
||||||
|
"""Coordinates curses windows for the π streaming UI."""
|
||||||
|
|
||||||
|
def __init__(self, stdscr: "curses._CursesWindow") -> None:
|
||||||
|
self.stdscr = stdscr
|
||||||
|
try:
|
||||||
|
curses.curs_set(0)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
self.stdscr.nodelay(True)
|
||||||
|
self.stdscr.timeout(100)
|
||||||
|
UIColors.setup()
|
||||||
|
|
||||||
|
lines, cols = curses.LINES, curses.COLS
|
||||||
|
header_h = 6
|
||||||
|
help_h = 1
|
||||||
|
digits_h = max(1, lines - header_h - help_h - 1)
|
||||||
|
|
||||||
|
self.header = HeaderView(header_h, cols, 0, 0)
|
||||||
|
self.digits = DigitsView(digits_h, cols, header_h, 0)
|
||||||
|
self.help = HelpView(header_h + digits_h, 0, cols)
|
||||||
|
self.scroll = 0
|
||||||
|
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
digits_text: str,
|
||||||
|
digits_count: int,
|
||||||
|
elapsed: float,
|
||||||
|
rate: float,
|
||||||
|
last_digit: str | None,
|
||||||
|
computing: bool,
|
||||||
|
iteration: int,
|
||||||
|
) -> None:
|
||||||
|
self.header.render(
|
||||||
|
iteration,
|
||||||
|
digits_count,
|
||||||
|
elapsed,
|
||||||
|
rate,
|
||||||
|
last_digit,
|
||||||
|
computing,
|
||||||
|
)
|
||||||
|
self.scroll = self.digits.render(digits_text, self.scroll)
|
||||||
|
self.help.render()
|
||||||
|
curses.doupdate()
|
||||||
|
|
||||||
|
def handle_input(self, ch: int) -> None:
|
||||||
|
if ch == curses.KEY_UP:
|
||||||
|
self.scroll = max(0, self.scroll - 1)
|
||||||
|
elif ch == curses.KEY_DOWN:
|
||||||
|
self.scroll += 1
|
||||||
95
pi_finder/ui/views.py
Normal file
95
pi_finder/ui/views.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import curses
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .colors import UIColors
|
||||||
|
|
||||||
|
|
||||||
|
class HeaderView:
|
||||||
|
"""Top panel with live metrics."""
|
||||||
|
|
||||||
|
def __init__(self, height: int, width: int, y: int, x: int) -> None:
|
||||||
|
self.win = curses.newwin(height, width, y, x)
|
||||||
|
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
iteration: int,
|
||||||
|
digits: int,
|
||||||
|
elapsed: float,
|
||||||
|
rate: float,
|
||||||
|
last_digit: Optional[str],
|
||||||
|
computing: bool,
|
||||||
|
) -> None:
|
||||||
|
self.win.erase()
|
||||||
|
style = UIColors.style(UIColors.HEADER, bold=True)
|
||||||
|
metric_style = UIColors.style(UIColors.METRIC, bold=True)
|
||||||
|
status_style = UIColors.style(UIColors.STATUS)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
" π Stream Dashboard ",
|
||||||
|
f" Iteration: {iteration:,}",
|
||||||
|
f" Digits: {digits:,}",
|
||||||
|
f" Elapsed: {elapsed:8.2f}s • Rate: {rate:8.2f} digits/s",
|
||||||
|
]
|
||||||
|
status = " Generating…" if computing else " Idle"
|
||||||
|
digit_line = f" Last digit: {last_digit if last_digit is not None else '—'}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.win.addstr(0, 0, lines[0], style)
|
||||||
|
self.win.addstr(1, 0, lines[1], metric_style)
|
||||||
|
self.win.addstr(2, 0, lines[2], metric_style)
|
||||||
|
self.win.addstr(3, 0, lines[3], metric_style)
|
||||||
|
self.win.addstr(4, 0, digit_line, metric_style)
|
||||||
|
self.win.addstr(5, 0, status, status_style)
|
||||||
|
width = max(1, self.win.getmaxyx()[1] - 1)
|
||||||
|
self.win.addstr(6, 0, "─" * width, UIColors.style(UIColors.SEPARATOR))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.win.noutrefresh()
|
||||||
|
|
||||||
|
|
||||||
|
class DigitsView:
|
||||||
|
"""Scrollable window for the π digits."""
|
||||||
|
|
||||||
|
def __init__(self, height: int, width: int, y: int, x: int) -> None:
|
||||||
|
self.win = curses.newwin(height, width, y, x)
|
||||||
|
|
||||||
|
def render(self, digits_text: 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)
|
||||||
|
chunks = [digits_text[i : i + cols] for i in range(0, len(digits_text), cols)] or ["0"]
|
||||||
|
|
||||||
|
max_scroll = max(0, len(chunks) - max_y)
|
||||||
|
scroll = max(0, min(scroll, max_scroll))
|
||||||
|
style = UIColors.style(UIColors.NUMBER)
|
||||||
|
|
||||||
|
for idx, chunk in enumerate(chunks[scroll : scroll + max_y]):
|
||||||
|
try:
|
||||||
|
self.win.addstr(idx, 0, chunk, style)
|
||||||
|
except curses.error:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.win.noutrefresh()
|
||||||
|
return scroll
|
||||||
|
|
||||||
|
|
||||||
|
class HelpView:
|
||||||
|
"""Bottom row with key hints."""
|
||||||
|
|
||||||
|
def __init__(self, y: int, x: int, width: int) -> None:
|
||||||
|
self.win = curses.newwin(1, width, y, x)
|
||||||
|
|
||||||
|
def render(self) -> None:
|
||||||
|
message = " ↑↓ scroll • q quit"
|
||||||
|
try:
|
||||||
|
self.win.addstr(0, 0, message, UIColors.style(UIColors.STATUS, dim=True))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
self.win.noutrefresh()
|
||||||
Loading…
x
Reference in New Issue
Block a user