diff --git a/src/bmad_loop/tui/data.py b/src/bmad_loop/tui/data.py index b0beadd..b66c415 100644 --- a/src/bmad_loop/tui/data.py +++ b/src/bmad_loop/tui/data.py @@ -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 @@ -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") @@ -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 diff --git a/tests/test_tui_data.py b/tests/test_tui_data.py index 35e8444..c92f994 100644 --- a/tests/test_tui_data.py +++ b/tests/test_tui_data.py @@ -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