182 lines
6.8 KiB
Python
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()
|
|
|