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()