Status output reworked using a new progress indicator that can share the terminal with other outputs.

This commit is contained in:
2026-01-05 16:20:43 +00:00
parent 7b483153af
commit e62d26e4f8
4 changed files with 397 additions and 156 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ authors = [
] ]
keywords = ["random", "CSPRNG", "AEGIS", "shred", "benchmark"] keywords = ["random", "CSPRNG", "AEGIS", "shred", "benchmark"]
classifiers = [ classifiers = [
"Development Status :: 4 - Beta", "Development Status :: 5 - Production/Stable",
"Environment :: Console", "Environment :: Console",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"Intended Audience :: System Administrators", "Intended Audience :: System Administrators",
+375 -145
View File
@@ -1,4 +1,4 @@
"""Full-screen progress display with speed graph.""" """Progress display with speed graph at bottom of terminal."""
import math import math
import os import os
@@ -6,20 +6,26 @@ import sys
import threading import threading
import time import time
from randquik.stats import format_time from randquik.stats import format_size, format_time
__all__ = ["ProgressDisplay"] __all__ = ["ProgressDisplay"]
# Unicode block characters for graph (8 levels per cell) # Unicode block characters for graph (8 levels per cell)
GRAPH_BLOCKS = " ▁▂▃▄▅▆▇█" GRAPH_BLOCKS = " ▁▂▃▄▅▆▇█"
# Maximum height of progress display in terminal rows
MAX_HEIGHT = 10
class ProgressDisplay: class ProgressDisplay:
"""Full-screen progress display with speed graph, updated every 100ms. """Progress display with speed graph at bottom of terminal, updated every 100ms.
Only active when stderr is a tty. Reads progress from a shared state dict Only active when stderr is a tty. Reads progress from a shared state dict
with a single 'written' key. All display logic is encapsulated here. with a single 'written' key. All display logic is encapsulated here.
Uses the bottom portion of the terminal with a scrolling region preserved
at the top, allowing normal output to scroll above the progress display.
The graph fills from left to right as progress advances, doubling as both The graph fills from left to right as progress advances, doubling as both
a progress bar and a speed-over-time visualization. a progress bar and a speed-over-time visualization.
""" """
@@ -30,11 +36,15 @@ class ProgressDisplay:
start_time: float, start_time: float,
state: dict, state: dict,
infinite: bool | None = None, infinite: bool | None = None,
output_name: str | None = None,
oseek: int = 0,
): ):
self.total_bytes = total_bytes self.total_bytes = total_bytes
self.start_time = start_time self.start_time = start_time
self.state = state # Must have 'written' key self.state = state # Must have 'written' key
self.infinite = infinite if infinite is not None else total_bytes is None self.infinite = infinite if infinite is not None else total_bytes is None
self.output_name = output_name or "<stdout>"
self.oseek = oseek
self.active = sys.stderr.isatty() self.active = sys.stderr.isatty()
self._stop = threading.Event() self._stop = threading.Event()
self._thread = None self._thread = None
@@ -43,14 +53,20 @@ class ProgressDisplay:
# Speed history for graph - fixed size, filled from left as progress advances # Speed history for graph - fixed size, filled from left as progress advances
self._graph_width = 80 # Will be updated on first render self._graph_width = 80 # Will be updated on first render
self._speed_history: list[float] = [] # Stores GB/s values, one per column self._speed_history: list[float] = [] # Stores GB/s values, one per column
self._max_speed: float = 0.1 # Start with small value to avoid div by zero self._max_speed: float = 0.01 # Start with small value to avoid div by zero
# For infinite mode: track time of each speed sample # For infinite mode: track time of each speed sample
self._time_history: list[float] = [] self._time_history: list[float] = []
# X-axis scale smoothing (hysteresis for estimated total time)
self._smoothed_scale_time: float | None = None
# Terminal handling
self._current_scroll_bottom: int | None = None
self._hidden_cursor = False
self._first_draw = True
def start(self): def start(self):
if not self.active: if not self.active:
return return
self._save_terminal_state() self._setup_terminal_state()
self._thread = threading.Thread(target=self._run, daemon=True) self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start() self._thread.start()
@@ -59,18 +75,58 @@ class ProgressDisplay:
return return
self._stop.set() self._stop.set()
self._thread.join(timeout=0.5) self._thread.join(timeout=0.5)
# Final render so the finished state stays on screen
if self.active:
cols, rows, lines, overlay = self._render_frame()
self._draw_frame(cols, rows, lines, overlay)
self._restore_terminal_state() self._restore_terminal_state()
def _save_terminal_state(self): def _setup_terminal_state(self):
"""Save terminal state and enter alternate screen buffer.""" """Prepare terminal: hide cursor and start using bottom reserved block."""
sys.stderr.write("\x1b[?1049h") # Enter alternate screen buffer sys.stderr.write("\x1b[?25l") # Hide cursor to reduce flicker
sys.stderr.write("\x1b[?25l") # Hide cursor
sys.stderr.flush() sys.stderr.flush()
self._hidden_cursor = True
def _get_smoothed_speed(self, window_secs: float = 1.0) -> float:
"""Calculate average speed over the last window_secs seconds.
Returns speed in bytes/sec, averaged from recent samples in _speed_history.
Falls back to the most recent sample if not enough history.
"""
if not self._speed_history or not self._time_history:
return 0.0
current_time = self._time_history[-1]
cutoff_time = current_time - window_secs
# Find samples within the window
total_speed = 0.0
count = 0
for idx in range(len(self._time_history) - 1, -1, -1):
if self._time_history[idx] < cutoff_time:
break
total_speed += self._speed_history[idx]
count += 1
if count == 0:
return self._speed_history[-1] * 1_000_000_000
# Return average in bytes/sec (speed_history stores GB/s)
return (total_speed / count) * 1_000_000_000
def _restore_terminal_state(self): def _restore_terminal_state(self):
"""Restore terminal state and exit alternate screen buffer.""" """Restore terminal scrolling and cursor after progress is done."""
sys.stderr.write("\x1b[?25h") # Show cursor # Reset scrolling region to full screen
sys.stderr.write("\x1b[?1049l") # Exit alternate screen buffer sys.stderr.write("\x1b[r")
self._current_scroll_bottom = None
# Move cursor to a fresh line under the progress block
cols, rows = self._get_terminal_size()
sys.stderr.write(f"\x1b[{rows};1H\n")
if self._hidden_cursor:
sys.stderr.write("\x1b[?25h")
self._hidden_cursor = False
sys.stderr.flush() sys.stderr.flush()
def _get_terminal_size(self) -> tuple[int, int]: def _get_terminal_size(self) -> tuple[int, int]:
@@ -113,7 +169,7 @@ class ProgressDisplay:
elif normalized >= row_top: elif normalized >= row_top:
filled_chars.append("") filled_chars.append("")
else: else:
level = int(normalized - row_bottom) level = math.ceil(normalized - row_bottom)
filled_chars.append(GRAPH_BLOCKS[min(level, 8)]) filled_chars.append(GRAPH_BLOCKS[min(level, 8)])
# Build unfilled portion (dim grey at avg_speed level) # Build unfilled portion (dim grey at avg_speed level)
@@ -132,24 +188,103 @@ class ProgressDisplay:
filled_str = "".join(filled_chars) filled_str = "".join(filled_chars)
unfilled_str = "".join(unfilled_chars) unfilled_str = "".join(unfilled_chars)
if unfilled_str: if unfilled_str:
return f"{filled_str}\x1b[0m\x1b[38;5;234m{unfilled_str}\x1b[0m\x1b[33m" return f"{filled_str}\x1b[0m\x1b[38;5;235m{unfilled_str}\x1b[0m\x1b[33m"
return filled_str return filled_str
def _render_full_screen(self) -> str: def _build_header(
"""Render the full screen display.""" self,
if self.infinite: cols: int,
return self._render_infinite_screen() written: int,
return self._render_progress_screen() speed: float,
elapsed: float,
eta: float | None = None,
total_bytes: int | None = None,
) -> str:
"""Build the header line with stats, output name, and position.
def _render_infinite_screen(self) -> str: Args:
"""Render screen for infinite mode (no known total).""" cols: Terminal width
cols, rows = self._get_terminal_size() written: Bytes written so far
speed: Current speed in bytes/sec
elapsed: Elapsed time in seconds
eta: Estimated time remaining (None for infinite mode)
total_bytes: Total bytes to write (None for infinite mode)
"""
spinner = "\u25d0\u25d3\u25d1\u25d2"[int(elapsed * 4) % 4]
written_gb = written / 1_000_000_000
speed_gbs = speed / 1_000_000_000
# Build the fixed stats portion
if total_bytes is not None:
# Finite mode: show progress and ETA
total_gb = total_bytes / 1_000_000_000
eta_str = format_time(eta) if eta is not None else "--"
stats = (
f"\x1b[1;36mRandQuik {spinner}\x1b[0m "
f"{written_gb:6.2f}\x1b[2m/\x1b[0m{total_gb:.2f} GB "
)
stats += (
f"\x1b[2m@\x1b[0m {speed_gbs:5.2f} GB/s \x1b[2mest.\x1b[0m {eta_str:<8}"
if written < total_bytes
else f"\x1b[2m{'done':>27}\x1b[0m"
)
# Visible: "RandQuik X " (14) + "XXXX.XX/XXXX.XX GB " (20) + "@ XX.XX GB/s " (15) + "est. XXXXXXXX" (13) = 62
stats_len = 62
else:
# Infinite mode: show written and elapsed
stats = (
f" \x1b[1;36mRandQuik {spinner}\x1b[0m "
f"{written_gb:6.2f} GB \x1b[2m\u221e\x1b[0m "
f"\x1b[2m@\x1b[0m {speed_gbs:5.2f} GB/s "
f"\x1b[2m\u2502\x1b[0m {format_time(elapsed):>8}"
)
# Visible: " RandQuik X " (16) + "XXXX.XX GB \u221e " (14) + "@ XX.XX GB/s " (15) + "\u2502 XXXXXXXX" (11) = 56
stats_len = 56
# Build position suffix if oseek was used
if self.oseek > 0:
file_pos = self.oseek + written
pos_str = format_size(file_pos).replace(" ", "")
if total_bytes is not None:
pos_suffix = f" \x1b[2m[\x1b[0m{pos_str}\x1b[2m]\x1b[0m"
pos_suffix_len = 3 + len(pos_str) # " []" + size
else:
pos_suffix = f" \x1b[2m@\x1b[0m{pos_str}"
pos_suffix_len = 2 + len(pos_str) # " @" + size
else:
pos_suffix = ""
pos_suffix_len = 0
# Calculate available space for filename
# Format: {stats} > {name}{pos_suffix}
available = cols - stats_len - 4 - pos_suffix_len # 4 for " > "
name = self.output_name
if len(name) > available > 3:
name = "\u2026" + name[-(available - 1) :]
elif available <= 3:
name = ""
if name:
return f"{stats} \x1b[2m>\x1b[0m {name}{pos_suffix}"
return stats
def _render_progress_block(
self, cols: int, rows: int, max_height: int
) -> tuple[list[str], tuple[int, int, str] | None]:
"""Render the progress block constrained to max_height lines."""
if self.infinite:
return self._render_infinite_block(cols, rows, max_height), None
return self._render_finite_block(cols, rows, max_height)
def _render_infinite_block(self, cols: int, rows: int, max_height: int) -> list[str]:
"""Render progress block for infinite mode (no known total)."""
written = self.state.get("written", 0) written = self.state.get("written", 0)
now = time.perf_counter() now = time.perf_counter()
elapsed = now - self.start_time elapsed = now - self.start_time
# Calculate graph width (leave room for Y-axis labels) # Calculate graph width (leave room for Y-axis labels)
graph_width = cols - 8 graph_width = max(10, cols - 8)
self._graph_width = graph_width self._graph_width = graph_width
# Calculate speeds # Calculate speeds
@@ -169,27 +304,23 @@ class ProgressDisplay:
self._max_speed = speed_gbs self._max_speed = speed_gbs
scale_max = self._nice_scale(self._max_speed) scale_max = self._nice_scale(self._max_speed)
# Use smoothed speed for header display
display_speed = self._get_smoothed_speed()
# Build output # Build output
lines = [] lines: list[str] = []
lines.append("\x1b[2J\x1b[H") header = self._build_header(cols, written, display_speed, elapsed)
# Compact header with stats
spinner = "◐◓◑◒"[int(elapsed * 4) % 4]
written_gb = written / 1_000_000_000
current_speed = instant_speed / 1_000_000_000
header = (
f" \x1b[1;36mRandQuik {spinner}\x1b[0m "
f"\x1b[2m│\x1b[0m {written_gb:6.2f} GB \x1b[2m∞\x1b[0m "
f"\x1b[2m@\x1b[0m {current_speed:5.2f} GB/s "
f"\x1b[2m│\x1b[0m {format_time(elapsed):>8}"
)
lines.append(header) lines.append(header)
lines.append("")
# Calculate graph dimensions (footer_lines includes GB/s label, time axis, and footer) # Calculate graph dimensions based on remaining height
header_lines = len(lines) # Layout: [header][GB/s label][graph rows][time axis]
footer_lines = 4 remaining_after_label = max_height - len(lines) - 2
graph_rows = max(3, rows - header_lines - footer_lines)
if remaining_after_label < 1:
# Terminal is too short; fall back to header-only view
return lines
graph_rows = max(1, remaining_after_label)
# Downsample speed history for display - average samples within each column's time range # Downsample speed history for display - average samples within each column's time range
min_scale_time = 10.0 min_scale_time = 10.0
@@ -224,12 +355,14 @@ class ProgressDisplay:
avg_speed_gbs = overall_speed / 1_000_000_000 avg_speed_gbs = overall_speed / 1_000_000_000
# Add GB/s label above the graph # Use MB/s scale if max speed < 1 GB/s
lines.append(" \x1b[36mGB/s\x1b[0m") use_mb = scale_max < 1
unit_label = "MB/s" if use_mb else "GB/s"
lines.append(f" \x1b[36m{unit_label}\x1b[0m")
# Compute nice Y-axis tick values and map each to its best row # Compute nice Y-axis tick values and map each to its best row
nice_ticks = self._nice_y_ticks(scale_max, graph_rows) nice_ticks = self._nice_y_ticks(scale_max, graph_rows)
row_labels = self._assign_ticks_to_rows(nice_ticks, scale_max, graph_rows) row_labels = self._assign_ticks_to_rows(nice_ticks, scale_max, graph_rows, use_mb)
for row in range(graph_rows): for row in range(graph_rows):
graph_line = self._render_graph_row( graph_line = self._render_graph_row(
@@ -250,15 +383,12 @@ class ProgressDisplay:
time_axis = self._build_infinite_time_axis(graph_width, scale_time) time_axis = self._build_infinite_time_axis(graph_width, scale_time)
lines.append(f" {''.join(time_axis)}") lines.append(f" {''.join(time_axis)}")
# Footer return lines
lines.append(f"\x1b[2m{'[Ctrl+C to stop]':^{cols}}\x1b[0m")
return "\n".join(lines)
def _nice_scale(self, max_speed: float) -> float: def _nice_scale(self, max_speed: float) -> float:
"""Round up to next nice number for scale.""" """Round up to next nice number for scale."""
if max_speed <= 0.1: if max_speed <= 0.01:
return 0.1 return 0.01
log_val = math.log10(max_speed) log_val = math.log10(max_speed)
power = math.floor(log_val) power = math.floor(log_val)
mantissa = max_speed / (10**power) mantissa = max_speed / (10**power)
@@ -268,8 +398,15 @@ class ProgressDisplay:
power += 1 power += 1
return nice_mantissa * (10**power) return nice_mantissa * (10**power)
def _format_label(self, val: float) -> str: def _format_label(self, val: float, use_mb: bool = False) -> str:
"""Format Y-axis label.""" """Format Y-axis label.
Args:
val: Value in GB/s (will be converted to MB/s if use_mb is True)
use_mb: If True, multiply by 1000 and format as MB/s values
"""
if use_mb:
val = val * 1000 # Convert GB/s to MB/s
if val == 0: if val == 0:
return "0" return "0"
elif val >= 1: elif val >= 1:
@@ -313,16 +450,22 @@ class ProgressDisplay:
return ticks return ticks
def _assign_ticks_to_rows( def _assign_ticks_to_rows(
self, ticks: list[float], scale_max: float, graph_rows: int self, ticks: list[float], scale_max: float, graph_rows: int, use_mb: bool = False
) -> dict[int, str]: ) -> dict[int, str]:
"""Assign each tick to the row closest to its value. """Assign each tick to the row closest to its value.
Returns a dict mapping row index to formatted label string. Returns a dict mapping row index to formatted label string.
Each tick is assigned to exactly one row. Each tick is assigned to exactly one row.
Args:
ticks: List of tick values in GB/s
scale_max: Maximum scale value in GB/s
graph_rows: Number of rows in the graph
use_mb: If True, format labels as MB/s instead of GB/s
""" """
row_labels: dict[int, str] = {} row_labels: dict[int, str] = {}
if graph_rows <= 1 or scale_max <= 0: if graph_rows <= 1 or scale_max <= 0:
return {0: f"{self._format_label(ticks[0] if ticks else 0):>4}"} return {0: f"{self._format_label(ticks[0] if ticks else 0, use_mb):>4}"}
for tick in ticks: for tick in ticks:
# Calculate which row this tick value corresponds to # Calculate which row this tick value corresponds to
@@ -333,7 +476,7 @@ class ProgressDisplay:
# Only assign if row is not already taken (first tick wins) # Only assign if row is not already taken (first tick wins)
if best_row not in row_labels: if best_row not in row_labels:
row_labels[best_row] = f"{self._format_label(tick):>4}" row_labels[best_row] = f"{self._format_label(tick, use_mb):>4}"
return row_labels return row_labels
@@ -368,14 +511,20 @@ class ProgressDisplay:
"""Format time for axis label.""" """Format time for axis label."""
if secs == 0: if secs == 0:
return "0" return "0"
elif secs < 60: elif secs < 120:
return f"{int(secs)}s" return f"{int(secs)}s"
elif secs < 3600: elif secs < 3600:
m = int(secs // 60) m = int(secs // 60)
return f"{m}m" s = int(secs % 60)
if s == 0:
return f"{m}m"
return f"{m}m{s}s"
else: else:
h = int(secs // 3600) h = int(secs // 3600)
return f"{h}h" m = int((secs % 3600) // 60)
if m == 0:
return f"{h}h"
return f"{h}h{m}m"
# Find a nice interval that gives us ~4-8 labels # Find a nice interval that gives us ~4-8 labels
interval = nice_intervals[-1] interval = nice_intervals[-1]
@@ -400,15 +549,16 @@ class ProgressDisplay:
return time_axis return time_axis
def _render_progress_screen(self) -> str: def _render_finite_block(
"""Render the full screen display for finite progress.""" self, cols: int, rows: int, max_height: int
cols, rows = self._get_terminal_size() ) -> tuple[list[str], tuple[int, int, str] | None]:
"""Render the progress block for finite progress."""
written = self.state.get("written", 0) written = self.state.get("written", 0)
now = time.perf_counter() now = time.perf_counter()
elapsed = now - self.start_time elapsed = now - self.start_time
# Calculate graph width (leave room for Y-axis labels) # Calculate graph width (leave room for Y-axis labels)
graph_width = cols - 8 graph_width = max(10, cols - 8)
self._graph_width = graph_width self._graph_width = graph_width
# Calculate speeds # Calculate speeds
@@ -418,110 +568,131 @@ class ProgressDisplay:
self._last_written = written self._last_written = written
self._last_time = now self._last_time = now
# Collect time-based speed samples (like infinite mode)
speed_gbs = instant_speed / 1_000_000_000
self._speed_history.append(speed_gbs)
self._time_history.append(elapsed)
# Use smoothed speed for header display and ETA
display_speed = self._get_smoothed_speed()
# ETA and total estimated time # ETA and total estimated time
remaining = self.total_bytes - written remaining = self.total_bytes - written
# ETA based on current instant speed for responsiveness # ETA based on smoothed speed for stability
eta = remaining / instant_speed if instant_speed > 0 else -1 eta = remaining / display_speed if display_speed > 0 else -1
# Graph X-axis scaling based on average speed for stability # Graph X-axis scaling based on overall average speed for stability
avg_eta = remaining / overall_speed if overall_speed > 0 else -1 avg_eta = remaining / overall_speed if overall_speed > 0 else -1
estimated_total_time = elapsed + avg_eta if avg_eta > 0 else elapsed estimated_total_time = elapsed + avg_eta if avg_eta > 0 else elapsed
# Apply hysteresis to scale_time to prevent jumping
# Only update if change is significant (>20%) or if new estimate is larger
raw_scale_time = max(estimated_total_time, 1.0)
if self._smoothed_scale_time is None:
self._smoothed_scale_time = raw_scale_time
else:
# Always grow immediately, shrink only gradually
if raw_scale_time > self._smoothed_scale_time:
self._smoothed_scale_time = raw_scale_time
else:
# Shrink slowly: blend 90% old, 10% new
self._smoothed_scale_time = 0.9 * self._smoothed_scale_time + 0.1 * raw_scale_time
scale_time = self._smoothed_scale_time
# Progress percentage (for display) # Progress percentage (for display)
pct = min(100, written * 100 / self.total_bytes) if self.total_bytes > 0 else 0 pct = min(100, written * 100 / self.total_bytes) if self.total_bytes > 0 else 0
# Time-based progress: columns represent time, not percentage # Update max speed
# Graph fills based on elapsed / estimated_total_time if speed_gbs > self._max_speed:
# Use ceiling so column appears when we've started it, not when complete self._max_speed = speed_gbs
if estimated_total_time > 0: scale_max = self._nice_scale(self._max_speed)
time_pct = min(100, elapsed * 100 / estimated_total_time)
# Build output
lines: list[str] = []
header = self._build_header(
cols, written, display_speed, elapsed, eta=eta, total_bytes=self.total_bytes
)
lines.append(header)
# Calculate graph dimensions based on remaining height
# Layout: [header][GB/s label][graph rows][time axis]
remaining_after_label = max_height - len(lines) - 2
if remaining_after_label < 1:
# Terminal is too short; fall back to a compact header-only view
return lines, None
graph_rows = max(1, remaining_after_label)
# Downsample speed history for display - average samples within each column's time range
# Graph fills based on elapsed / scale_time (smoothed)
col_width_time = scale_time / (graph_width - 1) if graph_width > 1 else scale_time
# Calculate how many columns should be filled based on elapsed time
if scale_time > 0:
time_pct = min(100, elapsed * 100 / scale_time)
else: else:
time_pct = 100 time_pct = 100
target_cols = min(graph_width, int(graph_width * time_pct / 100) + 1) if time_pct > 0 else 0 target_cols = min(graph_width, int(graph_width * time_pct / 100) + 1) if time_pct > 0 else 0
# Update speed history - add new sample if we've advanced to a new column display_values = []
speed_gbs = instant_speed / 1_000_000_000 for col in range(target_cols):
if len(self._speed_history) < target_cols: col_time = col / (graph_width - 1) * scale_time if graph_width > 1 else 0
# Fill in any skipped columns with the current speed if col_time > elapsed:
while len(self._speed_history) < target_cols: break
self._speed_history.append(speed_gbs) # Find all samples within this column's time range
elif len(self._speed_history) > 0 and target_cols > 0: col_start = col_time - col_width_time / 2
# Update the current column with latest speed (smoothing) col_end = col_time + col_width_time / 2
self._speed_history[-1] = (self._speed_history[-1] + speed_gbs) / 2 samples = [
self._speed_history[idx]
# Update max speed - use "nice" scale values (1, 2, 3, ..., 9 × 10^N), minimum 0.1 GB/s for idx, t in enumerate(self._time_history)
if speed_gbs > self._max_speed: if col_start <= t <= col_end
self._max_speed = speed_gbs ]
# Round up to next "nice" number: 0.1, 0.2, ..., 0.9, 1, 2, ..., 9, 10, 20, ... if samples:
if self._max_speed <= 0.1: display_values.append(sum(samples) / len(samples))
scale_max = 0.1 elif self._speed_history:
else: # Fallback to closest if no samples in range
# Find the power of 10 just below max_speed best_idx = 0
log_val = math.log10(self._max_speed) best_diff = float("inf")
power = math.floor(log_val) for idx, t in enumerate(self._time_history):
# Get the leading digit and round up diff = abs(t - col_time)
mantissa = self._max_speed / (10**power) if diff < best_diff:
nice_mantissa = math.ceil(mantissa) best_diff = diff
if nice_mantissa > 9: best_idx = idx
nice_mantissa = 1 display_values.append(self._speed_history[best_idx])
power += 1
scale_max = nice_mantissa * (10**power)
# Build output
lines = []
lines.append("\x1b[2J\x1b[H")
# Compact header with stats
spinner = "◐◓◑◒"[int(elapsed * 4) % 4]
current_speed = instant_speed / 1_000_000_000
written_gb = written / 1_000_000_000
total_gb = self.total_bytes / 1_000_000_000
header = (
f" \x1b[1;36mRandQuik {spinner}\x1b[0m "
f"\x1b[2m│\x1b[0m {written_gb:6.2f}\x1b[2m/\x1b[0m{total_gb:.2f} GB "
f"\x1b[2m@\x1b[0m {current_speed:5.2f} GB/s "
f"ETA {format_time(eta):>8}"
)
lines.append(header)
lines.append("")
# Calculate graph dimensions (footer_lines includes GB/s label, time axis, and footer)
header_lines = len(lines)
footer_lines = 4
graph_rows = max(3, rows - header_lines - footer_lines)
# Render graph rows with Y-axis # Render graph rows with Y-axis
avg_speed_gbs = overall_speed / 1_000_000_000 avg_speed_gbs = overall_speed / 1_000_000_000
# Add GB/s label above the graph # Use MB/s scale if max speed < 1 GB/s
lines.append(" \x1b[36mGB/s\x1b[0m") use_mb = scale_max < 1
unit_label = "MB/s" if use_mb else "GB/s"
lines.append(f" \x1b[36m{unit_label}\x1b[0m")
# Compute nice Y-axis tick values and map each to its best row # Compute nice Y-axis tick values and map each to its best row
nice_ticks = self._nice_y_ticks(scale_max, graph_rows) nice_ticks = self._nice_y_ticks(scale_max, graph_rows)
row_labels = self._assign_ticks_to_rows(nice_ticks, scale_max, graph_rows) row_labels = self._assign_ticks_to_rows(nice_ticks, scale_max, graph_rows, use_mb)
for row in range(graph_rows): for row in range(graph_rows):
graph_line = self._render_graph_row( graph_line = self._render_graph_row(
self._speed_history, display_values,
scale_max, scale_max,
row, row,
graph_rows, graph_rows,
graph_width, graph_width,
avg_speed_gbs, avg_speed_gbs,
) )
graph_line = graph_line.ljust(graph_width)
# Y-axis label from pre-computed mapping # Y-axis label from pre-computed mapping
label = row_labels.get(row, " ") label = row_labels.get(row, " ")
lines.append(f" \x1b[36m{label}\x1b[0m \x1b[33m{graph_line}\x1b[0m") lines.append(f" \x1b[36m{label}\x1b[0m \x1b[33m{graph_line}\x1b[0m")
# Time labels on X-axis with nice intervals # Time labels on X-axis with nice intervals
time_axis = self._build_time_axis(graph_width, estimated_total_time) time_axis = self._build_time_axis(graph_width, scale_time)
lines.append(f" {''.join(time_axis)}") lines.append(f" {''.join(time_axis)}")
# Footer
lines.append(f"\x1b[2m{'[Ctrl+C to abort]':^{cols}}\x1b[0m")
# Position percentage at top of bar at current progress point # Position percentage at top of bar at current progress point
# Find the height of the bar at current progress (last value in speed_history) # Find the height of the bar at current progress (last value in display_values)
current_speed_gbs = self._speed_history[-1] if self._speed_history else 0 current_speed_gbs = display_values[-1] if display_values else 0
# Normalize to find which row the top of the bar is at # Normalize to find which row the top of the bar is at
# Bar fills from bottom up; row 0 is top, graph_rows-1 is bottom # Bar fills from bottom up; row 0 is top, graph_rows-1 is bottom
if scale_max > 0 and current_speed_gbs > 0: if scale_max > 0 and current_speed_gbs > 0:
@@ -533,17 +704,15 @@ class ProgressDisplay:
else: else:
bar_top_row = graph_rows - 1 # At bottom if no speed bar_top_row = graph_rows - 1 # At bottom if no speed
# Graph starts at row 4 (1-indexed: header=1, empty=2, GB/s=3, first graph row=4) # Overlay position inside the progress block
# Column offset is 6 (space + 4 char Y-label + space) progress_col = int(pct / 100 * (graph_width - 1)) + 7
progress_col = int(pct / 100 * (graph_width - 1)) + 7 # +7 for Y-axis offset (1-indexed)
pct_label = f"{int(pct)}%" pct_label = f"{int(pct)}%"
# Center the label on the progress point pct_col = max(7, min(cols, progress_col - len(pct_label) // 2))
pct_col = max(7, progress_col - len(pct_label) // 2) first_graph_row = len(lines) - (graph_rows + 2)
# Calculate screen row (1-indexed for ANSI) pct_row_offset = first_graph_row + bar_top_row
pct_row = 4 + bar_top_row pct_position = (pct_row_offset, pct_col, f"\x1b[1;37m{pct_label}\x1b[0m")
pct_position = f"\x1b[{pct_row};{pct_col}H\x1b[1;37m{pct_label}\x1b[0m"
return "\n".join(lines) + pct_position return lines, pct_position
def _build_time_axis(self, graph_width: int, estimated_total_time: float) -> list[str]: def _build_time_axis(self, graph_width: int, estimated_total_time: float) -> list[str]:
"""Build time axis with nice interval labels.""" """Build time axis with nice interval labels."""
@@ -575,12 +744,22 @@ class ProgressDisplay:
def format_time_short(secs): def format_time_short(secs):
"""Format time for axis label.""" """Format time for axis label."""
if secs < 60: if secs == 0:
return "0s"
elif secs < 120:
return f"{int(secs)}s" return f"{int(secs)}s"
elif secs < 3600: elif secs < 3600:
return f"{int(secs // 60)}m" m = int(secs // 60)
s = int(secs % 60)
if s == 0:
return f"{m}m"
return f"{m}m{s}s"
else: else:
return f"{int(secs // 3600)}h" h = int(secs // 3600)
m = int((secs % 3600) // 60)
if m == 0:
return f"{h}h"
return f"{h}h{m}m"
time_axis = [" "] * graph_width time_axis = [" "] * graph_width
if estimated_total_time > 0: if estimated_total_time > 0:
@@ -606,9 +785,60 @@ class ProgressDisplay:
return time_axis return time_axis
def _render_frame(self) -> tuple[int, int, list[str], tuple[int, int, str] | None]:
cols, rows = self._get_terminal_size()
max_height = min(MAX_HEIGHT, rows) if rows > 0 else MAX_HEIGHT
lines, overlay = self._render_progress_block(cols, rows, max_height)
# Guard against empty renders
if not lines:
lines = [""]
return cols, rows, lines, overlay
def _draw_frame(
self, cols: int, rows: int, lines: list[str], overlay: tuple[int, int, str] | None
):
"""Draw the progress block at the bottom of the terminal."""
height = min(len(lines), max(1, rows))
progress_top = max(1, rows - height + 1)
# Build entire frame as a single string
buf: list[str] = []
# On first draw, scroll terminal up to make room for progress block
if self._first_draw:
self._first_draw = False
buf.append("\n" * height)
# Update scrolling region so other output scrolls above the progress block
top = 1
bottom = max(1, rows - height)
if self._current_scroll_bottom != bottom:
buf.append(f"\x1b[{top};{bottom}r")
self._current_scroll_bottom = bottom
# Paint each progress line
for idx in range(height):
row = progress_top + idx
line = lines[idx]
buf.append(f"\x1b[{row};1H\x1b[2K{line}")
# Overlay (e.g., percent marker)
if overlay:
row_offset, col, text = overlay
abs_row = progress_top + row_offset
abs_col = max(1, min(cols, col))
buf.append(f"\x1b[{abs_row};{abs_col}H{text}")
# Place cursor back at the bottom of the scrolling region
anchor_row = max(1, progress_top - 1)
buf.append(f"\x1b[{anchor_row};1H")
# Single atomic write
sys.stderr.write("".join(buf))
sys.stderr.flush()
def _run(self): def _run(self):
"""Background thread: update display every 100ms.""" """Background thread: update display every 100ms."""
while not self._stop.wait(0.1): while not self._stop.wait(0.1):
screen = self._render_full_screen() cols, rows, lines, overlay = self._render_frame()
sys.stderr.write(screen) self._draw_frame(cols, rows, lines, overlay)
sys.stderr.flush()
+19 -10
View File
@@ -40,18 +40,27 @@ def format_time(seconds: float) -> str:
if seconds < 0: if seconds < 0:
return "--" return "--"
if seconds < 1: if seconds < 1:
return f"{seconds * 1000:.0f} ms" return f"{seconds * 1000:.0f}ms"
if seconds < 90: if seconds < 120:
return f"{seconds:.0f} s" return f"{int(seconds)}s"
elif seconds < 5400: # 90 minutes elif seconds < 3600:
m = int(seconds / 60) m = int(seconds // 60)
return f"{m} m" s = int(seconds % 60)
if s == 0:
return f"{m}m"
return f"{m}m{s}s"
elif seconds < 172800: # 48 hours elif seconds < 172800: # 48 hours
h = int(seconds / 3600) h = int(seconds // 3600)
return f"{h} h" m = int((seconds % 3600) // 60)
if m == 0:
return f"{h}h"
return f"{h}h{m}m"
else: else:
d = int(seconds / 86400) d = int(seconds // 86400)
return f"{d} d" h = int((seconds % 86400) // 3600)
if h == 0:
return f"{d}d"
return f"{d}d{h}h"
@dataclass @dataclass
+2
View File
@@ -294,6 +294,8 @@ def run(
start_time, start_time,
progress_state, progress_state,
infinite=infinite, infinite=infinite,
output_name=output,
oseek=oseek,
) )
if not quiet: if not quiet:
progress.start() progress.start()