Add Birdcage TUI: 5-screen Textual interface for Carryout G2

F1 Position (compass rose, motor control, sparklines),
F2 Signal (RSSI gauge with sub-char precision, DVB/ADC sparklines, LNA toggle),
F3 Scan (AZ/EL grid sweep with heatmap and CSV export),
F4 System (NVS table, A3981 diagnostics, motor dynamics),
F5 Console (raw serial terminal with prompt detection and safety gates).

Includes SerialBridge (thread-safe protocol wrapper), DemoDevice
(synthetic simulation for --demo mode), dark RF theme with rounded
borders and teal accents, and send_raw() on CarryoutG2Protocol.
This commit is contained in:
Ryan Malloy 2026-02-13 08:53:03 -07:00
parent a70b9b0a29
commit 7271b53c63
23 changed files with 4160 additions and 0 deletions

View file

@ -0,0 +1,21 @@
"""Custom widgets for the Birdcage TUI."""
from birdcage_tui.widgets.compass_rose import CompassRose
from birdcage_tui.widgets.device_status_bar import DeviceStatusBar
from birdcage_tui.widgets.motor_status import MotorStatus
from birdcage_tui.widgets.nvs_table import NvsTable
from birdcage_tui.widgets.serial_log import SerialLog
from birdcage_tui.widgets.signal_gauge import SignalGauge
from birdcage_tui.widgets.sky_heatmap import SkyHeatmap
from birdcage_tui.widgets.sparkline_widget import SparklineWidget
__all__ = [
"CompassRose",
"DeviceStatusBar",
"MotorStatus",
"NvsTable",
"SerialLog",
"SignalGauge",
"SkyHeatmap",
"SparklineWidget",
]

View file

@ -0,0 +1,183 @@
"""Compass rose widget — visual AZ/EL position display with Unicode compass dial."""
from rich.text import Text
from textual.reactive import reactive
from textual.widgets import Static
# Compass grid layout: 11 columns x 7 rows.
# Positions indexed [row][col] where (0,0) is top-left.
# Cardinal/intercardinal markers are placed at fixed positions.
# The pointer occupies one of 16 perimeter slots based on azimuth.
# 16-slot perimeter positions (clockwise from N=0):
# Each entry is (row, col) on the 11x7 grid.
_POINTER_SLOTS: list[tuple[int, int]] = [
(0, 5), # 0: N
(0, 7), # 1: NNE
(1, 9), # 2: NE
(2, 10), # 3: ENE
(3, 10), # 4: E
(4, 10), # 5: ESE
(5, 9), # 6: SE
(6, 7), # 7: SSE
(6, 5), # 8: S
(6, 3), # 9: SSW
(5, 1), # 10: SW
(4, 0), # 11: WSW
(3, 0), # 12: W
(2, 0), # 13: WNW
(1, 1), # 14: NW
(0, 3), # 15: NNW
]
# Fixed cardinal/intercardinal label positions: (row, col, label)
_LABELS: list[tuple[int, int, str]] = [
(0, 5, "N"),
(3, 10, "E"),
(6, 5, "S"),
(3, 0, "W"),
]
# Ring structure characters for the compass dial.
_RING_CHARS: dict[tuple[int, int], str] = {
# Top arc
(0, 3): ".",
(0, 4): "\u2500",
(0, 6): "\u2500",
(0, 7): ".",
# Upper sides
(1, 1): "/",
(1, 9): "\\",
# Mid-upper sides
(2, 0): "\u2502",
(2, 10): "\u2502",
# Center sides (cardinals placed separately)
# (3, 0) and (3, 10) reserved for W/E labels
# Lower-mid sides
(4, 0): "\u2502",
(4, 10): "\u2502",
# Lower sides
(5, 1): "\\",
(5, 9): "/",
# Bottom arc
(6, 3): "'",
(6, 4): "\u2500",
(6, 6): "\u2500",
(6, 7): "'",
}
def _azimuth_to_slot(az: float) -> int:
"""Map azimuth (0-360, 0=N clockwise) to one of 16 perimeter slots."""
normalized = az % 360.0
slot = round(normalized / 22.5) % 16
return slot
class CompassRose(Static):
"""Visual compass display showing azimuth/elevation position."""
azimuth: reactive[float] = reactive(180.0)
elevation: reactive[float] = reactive(45.0)
def render(self) -> Text:
result = Text()
# Large numeric readout
az_label = Text("AZ ", style="#506878 bold")
az_value = Text(f"{self.azimuth:7.2f}\u00b0", style="#00d4aa bold")
el_label = Text(" EL ", style="#506878 bold")
el_value = Text(f"{self.elevation:6.2f}\u00b0", style="#00d4aa bold")
result.append(az_label)
result.append(az_value)
result.append(el_label)
result.append(el_value)
result.append("\n\n")
# Build the 7x11 compass grid
grid: list[list[tuple[str, str]]] = [
[(" ", "#0e1420") for _ in range(11)] for _ in range(7)
]
# Place ring structure
for (r, c), ch in _RING_CHARS.items():
grid[r][c] = (ch, "#c8d0d8")
# Place cardinal labels
for r, c, label in _LABELS:
grid[r][c] = (label, "#506878 bold")
# Center crosshair
grid[3][5] = ("\u253c", "#1a2a38")
grid[3][4] = ("\u2500", "#1a2a38")
grid[3][6] = ("\u2500", "#1a2a38")
grid[2][5] = ("\u2502", "#1a2a38")
grid[4][5] = ("\u2502", "#1a2a38")
# Place pointer at azimuth position
slot = _azimuth_to_slot(self.azimuth)
pr, pc = _POINTER_SLOTS[slot]
# Use a filled diamond for the pointer
grid[pr][pc] = ("\u25c6", "#00d4aa bold")
# Compute a line from center toward the pointer direction for visual clarity
# Place a dot at an intermediate position between center (3,5) and pointer
cr, cc = 3, 5
dr = pr - cr
dc = pc - cc
if abs(dr) > 1 or abs(dc) > 1:
mr = cr + (1 if dr > 0 else (-1 if dr < 0 else 0))
mc = cc + (1 if dc > 0 else (-1 if dc < 0 else 0))
# Only place intermediate dot if it doesn't overwrite a label
existing_ch = grid[mr][mc][0]
if existing_ch in (" ", "\u2500", "\u2502", "\u253c"):
grid[mr][mc] = ("\u2022", "#00d4aa")
# Render grid to text
for row_idx, row in enumerate(grid):
for _col_idx, (ch, style) in enumerate(row):
result.append(ch, style=style)
if row_idx < 6:
result.append("\n")
# Bearing line below compass
bearing = self.azimuth % 360.0
if bearing < 0:
bearing += 360.0
cardinal = _bearing_to_cardinal(bearing)
result.append("\n")
result.append(f" {cardinal:>5s}", style="#506878")
result.append(f" {bearing:05.1f}\u00b0", style="#c8d0d8")
return result
def watch_azimuth(self, _value: float) -> None:
self.refresh()
def watch_elevation(self, _value: float) -> None:
self.refresh()
def _bearing_to_cardinal(bearing: float) -> str:
"""Convert bearing in degrees to 16-point cardinal abbreviation."""
directions = [
"N",
"NNE",
"NE",
"ENE",
"E",
"ESE",
"SE",
"SSE",
"S",
"SSW",
"SW",
"WSW",
"W",
"WNW",
"NW",
"NNW",
]
idx = round(bearing / 22.5) % 16
return directions[idx]

View file

@ -0,0 +1,92 @@
"""Device status bar widget — sidebar display of connection state and firmware info."""
from rich.text import Text
from textual.widgets import Static
class DeviceStatusBar(Static):
"""Sidebar status display showing connection state and firmware info."""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._connected: bool = False
self._demo: bool = False
self._firmware: str = "---"
self._submenu: str = "---"
self._port: str = "---"
def set_device(self, device: object) -> None:
"""Accept a device reference and update the status display."""
is_demo = hasattr(device, "demo_mode") or type(device).__name__ == "DemoDevice"
fw = getattr(device, "firmware_id", "02.02.48") if device else "---"
port = getattr(self.app, "serial_port", "/dev/ttyUSB0") if self.app else "---"
submenu = getattr(device, "current_menu", "TRK>") if device else "---"
connected = device is not None and not is_demo
self.update_status(
connected=connected,
demo=is_demo,
firmware=str(fw),
submenu=str(submenu),
port=str(port),
)
def update_status(
self,
connected: bool,
demo: bool,
firmware: str,
submenu: str,
port: str,
) -> None:
"""Update all status fields and refresh the display."""
self._connected = connected
self._demo = demo
self._firmware = firmware
self._submenu = submenu
self._port = port
self.refresh()
def render(self) -> Text:
result = Text()
label_w = 8
# Status row
result.append("Status".ljust(label_w), style="#506878")
if self._connected:
result.append("Connected", style="#00e060 bold")
elif self._demo:
result.append("Demo", style="#e8a020 italic")
else:
result.append("Offline", style="#e04040")
result.append("\n")
# Port row
result.append("Port".ljust(label_w), style="#506878")
result.append(self._port, style="#c8d0d8")
result.append("\n")
# Firmware row
result.append("FW".ljust(label_w), style="#506878")
result.append(self._firmware, style="#c8d0d8")
result.append("\n")
# Menu row
result.append("Menu".ljust(label_w), style="#506878")
# Color the menu prompt with its matching prompt color
submenu_colors: dict[str, str] = {
"TRK>": "#00d4aa",
"MOT>": "#00e060",
"DVB>": "#2080d0",
"NVS>": "#e8a020",
"A3981>": "#00b8c8",
"STEP>": "#40c0a0",
"EE>": "#e8a020",
"OS>": "#8090a0",
"ADC>": "#00b8c8",
"GPIO>": "#40c0a0",
"PEAK>": "#e8c020",
}
menu_color = submenu_colors.get(self._submenu, "#c8d0d8")
result.append(self._submenu, style=f"{menu_color} bold")
return result

View file

@ -0,0 +1,76 @@
"""Motor status widget — engagement state, torque, step counts, and EL range."""
from rich.text import Text
from textual.reactive import reactive
from textual.widgets import Static
class MotorStatus(Static):
"""Panel showing motor engagement state, torque, and step counts."""
engaged: reactive[bool] = reactive(False)
az_torque: reactive[str] = reactive("LOW")
el_torque: reactive[str] = reactive("LOW")
az_steps: reactive[int] = reactive(0)
el_steps: reactive[int] = reactive(0)
el_min: reactive[float] = reactive(18.0)
el_max: reactive[float] = reactive(65.0)
def render(self) -> Text:
result = Text()
label_w = 10
# Engaged row
result.append("Engaged".ljust(label_w), style="#506878")
if self.engaged:
result.append("YES", style="#00e060 bold")
else:
result.append("NO", style="#e04040")
result.append("\n")
# Torque row
result.append("Torque".ljust(label_w), style="#506878")
result.append("AZ: ", style="#506878")
az_style = "#e8c020 bold" if self.az_torque == "HIGH" else "#c8d0d8"
result.append(f"{self.az_torque}", style=az_style)
result.append(" EL: ", style="#506878")
el_style = "#e8c020 bold" if self.el_torque == "HIGH" else "#c8d0d8"
result.append(f"{self.el_torque}", style=el_style)
result.append("\n")
# Steps row
result.append("Steps".ljust(label_w), style="#506878")
result.append("AZ: ", style="#506878")
result.append(f"{self.az_steps}", style="#c8d0d8")
result.append(" EL: ", style="#506878")
result.append(f"{self.el_steps}", style="#c8d0d8")
result.append("\n")
# EL Range row
result.append("EL Range".ljust(label_w), style="#506878")
result.append(f"{self.el_min:.1f}\u00b0", style="#c8d0d8")
result.append(" \u2013 ", style="#506878")
result.append(f"{self.el_max:.1f}\u00b0", style="#c8d0d8")
return result
def watch_engaged(self, _value: bool) -> None:
self.refresh()
def watch_az_torque(self, _value: str) -> None:
self.refresh()
def watch_el_torque(self, _value: str) -> None:
self.refresh()
def watch_az_steps(self, _value: int) -> None:
self.refresh()
def watch_el_steps(self, _value: int) -> None:
self.refresh()
def watch_el_min(self, _value: float) -> None:
self.refresh()
def watch_el_max(self, _value: float) -> None:
self.refresh()

View file

@ -0,0 +1,93 @@
"""NVS table widget — DataTable wrapper for non-volatile storage dump display."""
import re
from textual.widgets import DataTable
# Regex to parse NVS dump lines.
# Examples:
# 0) Log ID's 0x00000007 0x00000007 0x00000007
# 20) Disable Tracker Proc? TRUE TRUE FALSE
# 101) Minimum Elevation Angle 18.00 18.00 18.00
_NVS_LINE_RE = re.compile(
r"^\s*(\d+)\)\s+" # index with closing paren
r"(.+?)\s{2,}" # name (greedy until 2+ spaces)
r"(\S+)\s+" # current value
r"(\S+)\s+" # saved value
r"(\S+)\s*$" # default value
)
class NvsTable(DataTable):
"""DataTable displaying NVS (non-volatile storage) dump data."""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._parsed_rows: list[dict[str, str]] = []
self._columns_added = False
def on_mount(self) -> None:
"""Add columns when the widget is mounted."""
if not self._columns_added:
self.add_columns("Idx", "Name", "Current", "Saved", "Default")
self._columns_added = True
def load_nvs(self, text: str) -> list[dict[str, str]]:
"""Parse NVS dump text and populate the table.
Returns a list of dicts with keys: idx, name, current, saved, default.
Rows where current != default are marked for the screen to highlight.
"""
self.clear_table()
self._parsed_rows = []
for line in text.splitlines():
line = line.rstrip()
if not line:
continue
match = _NVS_LINE_RE.match(line)
if not match:
continue
idx = match.group(1)
name = match.group(2).strip()
current = match.group(3)
saved = match.group(4)
default = match.group(5)
row_data = {
"idx": idx,
"name": name,
"current": current,
"saved": saved,
"default": default,
}
self._parsed_rows.append(row_data)
# Add row to the DataTable
modified = current != default
# Prefix the index cell to signal modification to the screen.
# The screen's CSS rule .nvs-modified handles styling.
label = f"*{idx}" if modified else idx
self.add_row(label, name, current, saved, default, key=f"nvs-{idx}")
return self._parsed_rows
def clear_table(self) -> None:
"""Remove all rows from the table."""
self.clear()
self._parsed_rows = []
@property
def parsed_rows(self) -> list[dict[str, str]]:
"""Access the most recently parsed NVS data."""
return list(self._parsed_rows)
@property
def modified_indices(self) -> list[str]:
"""Return indices where current value differs from default."""
return [
row["idx"] for row in self._parsed_rows if row["current"] != row["default"]
]

View file

@ -0,0 +1,84 @@
"""Serial log widget — RichLog with color-coded firmware console prompts."""
import re
from rich.text import Text
from textual.widgets import RichLog
# Prompt patterns and their colors, ordered by specificity (longest match first).
_PROMPT_STYLES: list[tuple[str, str]] = [
("A3981>", "#00b8c8"),
("STEP>", "#40c0a0"),
("TRK>", "#00d4aa"),
("MOT>", "#00e060"),
("DVB>", "#2080d0"),
("NVS>", "#e8a020"),
("EE>", "#e8a020"),
("OS>", "#8090a0"),
("ADC>", "#00b8c8"),
("GPIO>", "#40c0a0"),
("PEAK>", "#e8c020"),
("LATLON>", "#506878"),
("DIPSWITCH>", "#506878"),
]
# Build a regex that matches any known prompt at any position in the text.
_PROMPT_PATTERN = re.compile(
r"(" + "|".join(re.escape(p) for p, _ in _PROMPT_STYLES) + r")"
)
# Lookup dict for color by prompt string
_PROMPT_COLOR: dict[str, str] = {p: c for p, c in _PROMPT_STYLES}
class SerialLog(RichLog):
"""RichLog that color-codes Winegard firmware console prompts."""
def __init__(self, **kwargs) -> None:
super().__init__(markup=False, wrap=True, **kwargs)
def append_output(self, text: str) -> None:
"""Append firmware output with color-coded prompts.
Each line is scanned for known prompt strings (TRK>, MOT>, etc.)
which are rendered in their assigned color. All other text uses
the default terminal color.
"""
for line in text.splitlines():
if not line:
continue
styled = _colorize_line(line)
self.write(styled)
def append_command(self, cmd: str) -> None:
"""Append a user-issued command, formatted with a prompt indicator."""
styled = Text()
styled.append("> ", style="#00d4aa bold")
styled.append(cmd, style="#00d4aa")
self.write(styled)
def _colorize_line(line: str) -> Text:
"""Parse a single line and return a Rich Text with colored prompt spans."""
result = Text()
last_end = 0
for match in _PROMPT_PATTERN.finditer(line):
start, end = match.span()
prompt_str = match.group(1)
color = _PROMPT_COLOR[prompt_str]
# Text before the prompt
if start > last_end:
result.append(line[last_end:start], style="#c8d0d8")
# The prompt itself
result.append(prompt_str, style=f"{color} bold")
last_end = end
# Remaining text after last prompt
if last_end < len(line):
result.append(line[last_end:], style="#c8d0d8")
return result

View file

@ -0,0 +1,90 @@
"""Signal gauge widget — horizontal RSSI bar with color-coded thresholds."""
from rich.text import Text
from textual.reactive import reactive
from textual.widgets import Static
# RSSI color thresholds (upper bound, color)
_THRESHOLDS: list[tuple[int, str]] = [
(500, "#2080d0"), # cold — noise floor
(1000, "#00b8c8"), # cool — weak signal
(2000, "#00e060"), # mid — usable
(3000, "#e8c020"), # warm — strong
(4096, "#e04040"), # hot — saturating
]
BAR_WIDTH = 40
MAX_RSSI = 4096
# Sub-character bar fragments for smooth rendering (8 levels per cell)
_BAR_CHARS = " ▏▎▍▌▋▊▉"
_FULL = "\u2588" # █
def _rssi_color(rssi: int) -> str:
"""Return the color string for a given RSSI value."""
for threshold, color in _THRESHOLDS:
if rssi <= threshold:
return color
return _THRESHOLDS[-1][1]
class SignalGauge(Static):
"""Horizontal RSSI signal strength bar gauge."""
rssi_avg: reactive[int] = reactive(0)
rssi_cur: reactive[int] = reactive(0)
reads: reactive[int] = reactive(0)
def render(self) -> Text:
result = Text()
# Title
result.append("RSSI", style="#506878 bold")
result.append("\n")
# Compute fill with sub-character precision (8 levels per cell = 320 positions)
clamped = max(0, min(self.rssi_cur, MAX_RSSI))
fill_frac = clamped / MAX_RSSI * BAR_WIDTH
full_cells = int(fill_frac)
partial = fill_frac - full_cells
partial_idx = int(partial * 8)
# Build the bar with per-character color based on position thresholds
for i in range(full_cells):
pos_rssi = round((i + 0.5) / BAR_WIDTH * MAX_RSSI)
color = _rssi_color(pos_rssi)
result.append(_FULL, style=color)
# Partial sub-character cell
remaining = BAR_WIDTH - full_cells
if remaining > 0 and partial_idx > 0:
pos_rssi = round((full_cells + 0.5) / BAR_WIDTH * MAX_RSSI)
color = _rssi_color(pos_rssi)
result.append(_BAR_CHARS[partial_idx], style=color)
remaining -= 1
result.append("\u2591" * remaining, style="#1a2a38")
# Numeric value at end of bar
result.append(f" {self.rssi_cur}", style=_rssi_color(self.rssi_cur))
result.append("\n")
# Label line
result.append("avg: ", style="#506878")
result.append(f"{self.rssi_avg}", style="#c8d0d8")
result.append(" cur: ", style="#506878")
result.append(f"{self.rssi_cur}", style="#c8d0d8")
result.append(" reads: ", style="#506878")
result.append(f"{self.reads}", style="#c8d0d8")
return result
def watch_rssi_avg(self, _value: int) -> None:
self.refresh()
def watch_rssi_cur(self, _value: int) -> None:
self.refresh()
def watch_reads(self, _value: int) -> None:
self.refresh()

View file

@ -0,0 +1,123 @@
"""Sky heatmap widget — 2D AZ x EL grid colored by RSSI for sky scan visualization."""
from rich.text import Text
from textual.widgets import Static
# RSSI color thresholds matching signal_gauge.py
_THRESHOLDS: list[tuple[float, str]] = [
(500.0, "#2080d0"),
(1000.0, "#00b8c8"),
(2000.0, "#00e060"),
(3000.0, "#e8c020"),
(4096.0, "#e04040"),
]
_ZERO_COLOR = "#0e1420"
def _rssi_color(rssi: float) -> str:
"""Return the color string for a given RSSI value."""
if rssi <= 0:
return _ZERO_COLOR
for threshold, color in _THRESHOLDS:
if rssi <= threshold:
return color
return _THRESHOLDS[-1][1]
class SkyHeatmap(Static):
"""2D azimuth x elevation grid colored by RSSI for sky scan visualization."""
def __init__(
self,
az_bins: int = 40,
el_bins: int = 10,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._az_bins = az_bins
self._el_bins = el_bins
self._grid: list[list[float]] = [[0.0] * az_bins for _ in range(el_bins)]
self._active_az: int | None = None
self._active_el: int | None = None
def set_point(self, az_idx: int, el_idx: int, rssi: float) -> None:
"""Set RSSI value at a grid cell. Does not refresh — call refresh() explicitly
or batch updates and refresh once."""
if 0 <= el_idx < self._el_bins and 0 <= az_idx < self._az_bins:
self._grid[el_idx][az_idx] = rssi
def set_active(self, az_idx: int, el_idx: int) -> None:
"""Highlight the current scan position and refresh."""
self._active_az = az_idx
self._active_el = el_idx
self.refresh()
def clear(self) -> None:
"""Reset all RSSI values to zero and clear active position."""
for row in self._grid:
for i in range(len(row)):
row[i] = 0.0
self._active_az = None
self._active_el = None
self.refresh()
def render(self) -> Text:
result = Text()
# Column header: AZ labels (every 5 bins)
# Left gutter for EL labels
gutter = 5
result.append(" " * gutter, style="#0e1420")
for az in range(self._az_bins):
if az % 5 == 0:
label = str(az)
result.append(label, style="#506878")
# Pad to maintain 1-char-per-bin spacing
pad = 1 - len(label)
if pad > 0:
result.append(" " * pad)
else:
result.append(" ")
result.append("\n")
# Grid rows: highest EL at top
for el_idx in range(self._el_bins - 1, -1, -1):
# EL label
el_label = f"{el_idx:>3d} "
result.append(el_label, style="#506878")
result.append("\u2502", style="#1a2a38")
for az_idx in range(self._az_bins):
rssi = self._grid[el_idx][az_idx]
is_active = az_idx == self._active_az and el_idx == self._active_el
if is_active:
# Active scan position: bright white on dark background
result.append("\u2588", style="bold #ffffff on #1a2a38")
elif rssi <= 0:
# Empty cell
result.append("\u2591", style="#0e1420")
else:
color = _rssi_color(rssi)
# Use denser block for higher RSSI
ch = "\u2593" if rssi < 500 else "\u2588"
result.append(ch, style=color)
if el_idx > 0:
result.append("\n")
# Bottom border
result.append("\n")
result.append(" " * gutter, style="#0e1420")
result.append("\u2500" * self._az_bins, style="#1a2a38")
return result
@property
def az_bins(self) -> int:
return self._az_bins
@property
def el_bins(self) -> int:
return self._el_bins

View file

@ -0,0 +1,74 @@
"""Sparkline widget — rolling time series using Unicode block characters."""
from collections import deque
from rich.text import Text
from textual.widgets import Static
# 8-level vertical block characters for sparkline rendering.
# Index 0 = lowest bar, index 7 = tallest bar.
_BLOCKS = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
class SparklineWidget(Static):
"""Rolling sparkline time series display."""
def __init__(
self,
max_points: int = 60,
label: str = "",
color: str = "#00d4aa",
**kwargs,
) -> None:
super().__init__(**kwargs)
self._max_points = max_points
self._label = label
self._color = color
self._buffer: deque[float] = deque(maxlen=max_points)
def push(self, value: float) -> None:
"""Add a data point to the sparkline buffer and refresh."""
self._buffer.append(value)
self.refresh()
def render(self) -> Text:
result = Text()
# Label prefix
if self._label:
result.append(f"{self._label} ", style="#506878")
if not self._buffer:
result.append("\u2581" * self._max_points, style="#1a2a38")
return result
values = list(self._buffer)
lo = min(values)
hi = max(values)
span = hi - lo
for v in values:
if span <= 0:
# All values identical — render as mid-level
idx = 3
else:
normalized = (v - lo) / span
idx = min(int(normalized * 7.999), 7)
result.append(_BLOCKS[idx], style=self._color)
# Pad remaining width with low blocks if buffer not full
remaining = self._max_points - len(values)
if remaining > 0:
result.append(_BLOCKS[0] * remaining, style="#1a2a38")
# Min/max annotation
result.append("\n")
if self._label:
result.append(" " * (len(self._label) + 1))
result.append(f"{lo:.0f}", style="#506878")
gap = self._max_points - len(f"{lo:.0f}") - len(f"{hi:.0f}")
if gap > 0:
result.append(" " * gap)
result.append(f"{hi:.0f}", style="#506878")
return result