2025-09-03 01:51:13 -07:00

182 lines
6.8 KiB
Python

from __future__ import annotations
import shutil
from typing import Deque, Dict, List
# ANSI
RESET = "\x1b[0m"
BOLD = "\x1b[1m"
DIM = "\x1b[2m"
FG_RED = "\x1b[31m"
FG_GREEN = "\x1b[32m"
FG_CYAN = "\x1b[36m"
FG_YELLOW = "\x1b[33m"
ERASE_EOL = "\x1b[K"
def _term_width() -> int:
return shutil.get_terminal_size(fallback=(80, 20)).columns
def _bar(current: int, target: int, width: int, color: str = "", label: str = "", pad_to: int | None = None) -> str:
"""
Draw a bar and show " current/target" with current optionally right-aligned to pad_to digits.
"""
target = max(1, target)
ratio = min(1.0, max(0.0, current / target))
filled = int(ratio * width)
bar = "[" + "#" * filled + "-" * (width - filled) + "]"
label_part = f" {label}" if label else ""
# right-align current to pad_to (defaults to len(str(target)))
w = pad_to if pad_to is not None else len(str(target))
cur_s = str(current).rjust(w)
val = f"{cur_s}/{target}"
return f"{color}{bar}{RESET}{label_part} {DIM}{val}{RESET}"
class ProgressiveRenderer:
"""
Flicker-free renderer that:
- Prints the whole block ONCE (with real newlines) and leaves the cursor
on the LAST line of the block (no extra newline).
- Updates by moving the cursor up to the top of the block, overwriting
ONLY changed lines, then returning to the bottom line (never beyond).
- The initial scaffold reserves blank lines for the constraints to avoid
label vs. label+bar duplication on some terminals.
"""
def __init__(self, mins: Dict[str, int], venue_cap: int, reject_cap: int) -> None:
self.mins = dict(mins)
self.labels = sorted(self.mins.keys())
self.venue_cap = venue_cap
self.reject_cap = reject_cap
# Layout indices within the reserved block
self.recent_count = 5
self.recent_title = 0
self.recent_lines_start = 1 # 5 lines: [1..5]
self.recent_lines_end = self.recent_lines_start + self.recent_count - 1 # 5
self.constraints_header = self.recent_lines_end + 1 + 1 # +1 spacer
self.constraints_start = self.constraints_header + 1
self.constraints_lines = len(self.labels)
self.constraints_end = self.constraints_start + self.constraints_lines - 1
self.totals_header = self.constraints_end + 1 + 1
self.total_admit_line = self.totals_header + 1
self.total_reject_line = self.totals_header + 2
self.total_lines = self.total_reject_line + 1 # number of lines in block
self._cache: Dict[int, str] = {} # last rendered content per line
term = _term_width()
self.bar_inner_width = max(10, min(50, term - 30))
# Keep the cursor "inside" the block; we park it on the last line.
self._cur_line = self.total_lines - 1
self._begun = False
# ---- cursor movement helpers (relative to *current* position) ----
def _move_to_top(self) -> None:
if self._cur_line > 0:
print(f"\x1b[{self._cur_line}F", end="") # up to BOL of top line
self._cur_line = 0
else:
print("\r", end="")
def _move_to_line(self, target: int) -> None:
delta = target - self._cur_line
if delta > 0:
print(f"\x1b[{delta}E", end="") # down to BOL of target
elif delta < 0:
print(f"\x1b[{-delta}F", end="") # up to BOL of target
else:
print("\r", end="")
self._cur_line = target
def _write_line(self, text: str) -> None:
print("\r" + text + ERASE_EOL, end="")
def _return_to_bottom(self) -> None:
if self._cur_line < self.total_lines - 1:
print(f"\x1b[{(self.total_lines - 1) - self._cur_line}E", end="")
self._cur_line = self.total_lines - 1
print("\r", end="", flush=True)
# ---- public API ----
def begin(self) -> None:
if self._begun:
return
# Print the static block once. IMPORTANT: the constraint lines are BLANK here.
lines: List[str] = []
# Recent
lines.append(f"{BOLD}Recent decisions{RESET}")
for _ in range(self.recent_count):
lines.append("")
# spacer
lines.append("")
# Constraints header + blank lines (reserved)
lines.append(f"{BOLD}Constraints{RESET}")
for _ in self.labels:
lines.append("") # reserve a blank line for each constraint
# spacer
lines.append("")
# Totals
lines.append(f"{BOLD}Totals{RESET}")
lines.append("") # admitted bar
lines.append("") # rejected count
for i, txt in enumerate(lines):
end = "" if i == len(lines) - 1 else "\n"
print(txt, end=end)
self._cache[i] = txt
self._cur_line = self.total_lines - 1
print("\r", end="", flush=True)
self._begun = True
def render(self, recent: Deque[str], tallies: Dict[str, int],
admitted: int, rejected: int) -> None:
"""
Update the block with current data, overwriting only changed lines.
Leaves the cursor on the last line of the block (BOL).
"""
desired: Dict[int, str] = {}
# Recent (bottom-aligned) — we now expect `recent` to contain
# human-readable policy reasons, not raw attribute dumps.
pad = max(0, self.recent_count - len(recent))
recent_padded = [""] * pad + list(recent)
desired[self.recent_title] = f"{BOLD}Recent decisions{RESET}"
for i in range(self.recent_count):
desired[self.recent_lines_start + i] = recent_padded[i]
# Constraints (full line: label + bar)
desired[self.constraints_header] = f"{BOLD}Constraints{RESET}"
for i, a in enumerate(self.labels):
have = tallies.get(a, 0)
need = self.mins[a]
color = FG_CYAN if have < need else FG_YELLOW
bar = _bar(have, need, self.bar_inner_width, color, pad_to=len(str(need)))
desired[self.constraints_start + i] = f"{a:>16}: {bar}"
# Totals (right-aligned numbers)
desired[self.totals_header] = f"{BOLD}Totals{RESET}"
desired[self.total_admit_line] = _bar(
admitted, self.venue_cap, self.bar_inner_width,
FG_GREEN, "admitted", pad_to=len(str(self.venue_cap))
)
rej_w = len(str(self.reject_cap))
desired[self.total_reject_line] = f"{FG_RED}rejected{RESET} {DIM}{str(rejected).rjust(rej_w)}{RESET}"
# Minimal redraw
self._move_to_top()
for idx in range(self.total_lines):
new_text = desired.get(idx, "")
if self._cache.get(idx) != new_text:
self._move_to_line(idx)
self._write_line(new_text)
self._cache[idx] = new_text
self._return_to_bottom()