Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions src/bmad_loop/tui/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from typing import Any

import pyte
from rich.color import ColorParseError
from rich.style import Style
from rich.text import Text

Expand Down Expand Up @@ -269,8 +270,16 @@ def read_new(self) -> list[dict[str, Any]]:
_PANE_LINES = 50
_HISTORY_LINES = 2000 # matches the dashboard RichLog max_lines

# pyte names SGR 33 "brown"; aixterm brights carry no underscore.
_PYTE_COLOR_FIX = {"brown": "yellow", "brightbrown": "bright_yellow"}
# pyte names SGR 33 "brown"; aixterm brights carry no underscore. Values here
# must stay in pyte's own (underscore-free) namespace: _rich_color re-applies
# the "bright" -> "bright_" transform after this remap, so a "bright_"-prefixed
# value would double up into "bright__yellow" (an invalid rich color).
# "bfightmagenta" is pyte 0.8.2's own BG_AIXTERM[105] typo (SGR 105).
_PYTE_COLOR_FIX = {
"brown": "yellow",
"brightbrown": "brightyellow",
"bfightmagenta": "brightmagenta",
}
_HEX_DIGITS = set("0123456789abcdef")


Expand All @@ -294,15 +303,21 @@ def _char_style(key: tuple) -> Style:
style = _style_cache.get(key)
if style is None:
fg, bg, bold, italics, underscore, strikethrough, reverse = key
style = _style_cache[key] = Style(
color=_rich_color(fg),
bgcolor=_rich_color(bg),
attrs = dict(
bold=bold or None,
italic=italics or None,
underline=underscore or None,
strike=strikethrough or None,
reverse=reverse or None,
)
try:
style = Style(color=_rich_color(fg), bgcolor=_rich_color(bg), **attrs)
except ColorParseError:
# A color name rich can't parse (e.g. a new pyte table entry) must
# degrade to an uncolored run, not kill the poll worker — and with
# it the app (textual workers default to exit_on_error=True).
style = Style(**attrs)
_style_cache[key] = style
return style


Expand Down
52 changes: 52 additions & 0 deletions tests/test_tui_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -888,3 +888,55 @@ def test_run_watcher_state_refreshes_on_same_size_rewrite(tmp_path):
assert after.st_size == pinned.st_size and after.st_mtime_ns == pinned.st_mtime_ns

assert watcher.state() is not first # re-parsed because the inode changed


def test_rich_color_maps_pyte_names_to_valid_rich_colors():
# pyte emits aixterm bright names without an underscore (e.g. "brightbrown"
# for SGR 93). _rich_color remaps pyte's "brown"/"brightbrown" and then
# applies the "bright" -> "bright_" transform; the remap target must stay in
# pyte's underscore-free namespace or the transform doubles the underscore
# into an invalid "bright__yellow" and every log render raises ColorParseError.
from rich.color import Color

cases = {
"default": None,
"brown": "yellow",
"brightbrown": "bright_yellow", # regression: was "bright__yellow"
"bfightmagenta": "bright_magenta", # pyte 0.8.2 BG_AIXTERM[105] typo
"red": "red",
"brightred": "bright_red",
"brightyellow": "bright_yellow",
"ff00aa": "#ff00aa",
}
for pyte_name, expected in cases.items():
got = data._rich_color(pyte_name)
assert got == expected, f"{pyte_name!r} -> {got!r}, expected {expected!r}"
if got is not None:
Color.parse(got) # must be a color rich accepts, else the TUI crashes

# Exhaustive: every name the installed pyte can emit must map to something
# rich parses — a pyte bump that adds/renames table entries fails here, not
# in the dashboard's poll worker.
import pyte.graphics as graphics

every_pyte_name = (
set(graphics.FG.values())
| set(graphics.BG.values())
| set(graphics.FG_AIXTERM.values())
| set(graphics.BG_AIXTERM.values())
)
for pyte_name in sorted(every_pyte_name):
got = data._rich_color(pyte_name)
if got is not None:
Color.parse(got)


def test_char_style_degrades_unparseable_color_instead_of_raising():
# Belt to the sweep test's suspenders: if a color name rich can't parse
# ever slips through _rich_color, the run renders uncolored instead of
# killing the poll worker (and, via exit_on_error, the whole app).
key = ("no_such_color", "default", True, False, True, False, False)
style = data._char_style(key)
assert style.color is None and style.bgcolor is None
assert style.bold and style.underline and not style.italic
assert data._char_style(key) is style # fallback is cached like any other
Loading