diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index 72902ca..138f72f 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -910,19 +910,22 @@ def node_type_legend(indent: str = "") -> Text: def tab_strip(labels: Sequence[str], active: int, width: int) -> Group: - """A boxed tab strip: the active view drawn as a notebook tab, the rest sat beside it muted. + """A boxed tab strip: every tab always boxed, the active one lit on top of the rest. THE navigation header for a screen that pages one full-height view at a time between a handful of named views (the node detail page's ``Info`` / ``Routes``) instead of stacking - them. A blank line of air above the strip plus the strip's own line was already the going - rate, so the strip spends a third line on an actual tab shape instead of a plain heading: - the active label sits boxed in accent-coloured rounded corners, its bottom edge opening - into a full-width rule that reads as the seam between chrome and page. The inactive tabs - sit beside the box muted and unboxed, so the alternative view stays visible without - competing. A lone tab collapses to the plain accent heading - (:func:`~meshterm.ui.menus.section_heading`'s ``── Label ──`` form) since there is nothing - to switch between. The owning screen switches the active index (``←→``, with - ``Tab``/``Shift+Tab`` as a backup); the strip itself is pure presentation. + them. Every tab is always drawn as a full box — muted by default, accent when active — + instead of only the active one; this keeps every tab's width fixed regardless of which + is selected, so switching tabs never shifts the labels that come after it. Tabs share a + vertical between neighbours (one glyph, not two), and each shared corner is drawn as if + the tab to the left sits on top of the tab to the right: the corner "opens" (rounds + toward the tab starting there) only at the very first tab and at the active tab's own + left edge, and "closes" (rounds back, ceding the position to the tab on its left) + everywhere else — so the active tab is the one exception that always wins both of its + corners, reading as lifted in front of its neighbours. A lone tab collapses to the plain + accent heading (:func:`~meshterm.ui.menus.section_heading`'s ``── Label ──`` form) since + there is nothing to switch between or layer. The owning screen switches the active index + (``←→``, with ``Tab``/``Shift+Tab`` as a backup); the strip itself is pure presentation. Args: labels: The tab names in display order. @@ -930,31 +933,41 @@ def tab_strip(labels: Sequence[str], active: int, width: int) -> Group: width: The strip's render width — the closing rule fills out to it. Returns: - A :class:`~rich.console.Group` of one line (a lone tab, or none) or three (the box's - top border, the boxed + muted labels, and the closing rule). + A :class:`~rich.console.Group` of one line (a lone tab, or none) or three (the shared + top border, the boxed labels, and the shared bottom border). """ if not labels: return Group(Text("")) if len(labels) == 1: return Group(Text(f"── {labels[0]} ──", style="accent", no_wrap=True)) + n = len(labels) + inner = [f" {label} " for label in labels] + + def owner(junction: int) -> int: + """Which tab's corner glyph sits at this junction — see the docstring's layering rule.""" + return junction if junction == 0 or junction == active else junction - 1 + + def corner_style(junction: int) -> str: + return "accent" if owner(junction) == active else "muted" + top = Text(no_wrap=True) mid = Text(no_wrap=True) bot = Text(no_wrap=True) for i, label in enumerate(labels): - if i: - top.append(" ") - mid.append(" ") - bot.append("──", style="muted") - if i == active: - inner = f" {label} " - top.append("╭" + "─" * len(inner) + "╮", style="accent") - mid.append("│" + inner + "│", style="accent") - bot.append(("╰" if i == 0 else "┴") + "─" * len(inner) + "┴", style="accent") - else: - top.append(" " * len(label)) - mid.append(label, style="muted") - bot.append("─" * len(label), style="muted") + opens = i == 0 or i == active + style = corner_style(i) + top.append("╭" if opens else "╮", style=style) + mid.append("│", style=style) + bot.append("╰" if opens else "╯", style=style) + tab_style = "accent" if i == active else "muted" + top.append("─" * len(inner[i]), style=tab_style) + mid.append(inner[i], style=tab_style) + bot.append("─" * len(inner[i]), style=tab_style) + last_style = corner_style(n) + top.append("╮", style=last_style) + mid.append("│", style=last_style) + bot.append("╯", style=last_style) bot.append("─" * max(0, width - len(bot.plain)), style="muted") return Group(top, mid, bot) diff --git a/tests/test_node_detail.py b/tests/test_node_detail.py index d8aed1e..d4d16d8 100644 --- a/tests/test_node_detail.py +++ b/tests/test_node_detail.py @@ -124,21 +124,30 @@ def test_signal_row_summarizes_snr_and_rssi() -> None: # --- the tab strip -------------------------------------------------------------- -def test_tab_strip_boxes_the_active_tab_and_mutes_the_rest() -> None: - """The active view reads as a boxed notebook tab; the others sit beside it bare.""" - group = tab_strip(["Map", "Routes"], 1, width=17) +def test_tab_strip_boxes_every_tab_and_lights_the_active_one() -> None: + """Every tab is always boxed; only the active tab's corners and fill read as lit.""" + group = tab_strip(["Map", "Routes"], 1, width=20) top, mid, bot = group.renderables - assert mid.plain == "Map │ Routes │" # active tab boxed, inactive bare - assert top.plain == " ╭──────────╮" # box top over "Routes" only - assert bot.plain == "─────┴──────────┴" # rule joins the box's open bottom - assert len(top.plain) == len(mid.plain) == len(bot.plain) == 17 # no fill needed yet + assert mid.plain == "│ Map │ Routes │" # both tabs boxed, sharing one vertical + assert top.plain == "╭───────╭──────────╮" # active tab's left corner opens (wins the seam) + assert bot.plain == "╰───────╰──────────╯" # mirrored on the bottom border + assert len(top.plain) == len(mid.plain) == len(bot.plain) == 20 # no fill needed yet + + +def test_tab_strip_labels_hold_position_across_the_active_index() -> None: + """Selecting a different tab only relights corners — no label ever shifts column.""" + mid_first = tab_strip(["Map", "Routes"], 0, width=20).renderables[1] + mid_second = tab_strip(["Map", "Routes"], 1, width=20).renderables[1] + assert mid_first.plain == mid_second.plain == "│ Map │ Routes │" + top_first = tab_strip(["Map", "Routes"], 0, width=20).renderables[0] + assert top_first.plain == "╭───────╮──────────╮" # first tab keeps the plain leading corner def test_tab_strip_rule_fills_out_to_the_render_width() -> None: """The closing rule under the strip extends to the full render width.""" - _, _, bot = tab_strip(["Map", "Routes"], 1, width=20).renderables - assert bot.plain == "─────┴──────────┴───" # 17 chars of box/rule, then 3 chars filler - assert len(bot.plain) == 20 + _, _, bot = tab_strip(["Map", "Routes"], 1, width=25).renderables + assert bot.plain == "╰───────╰──────────╯─────" # 20 chars of box/rule, then 5 chars filler + assert len(bot.plain) == 25 def test_tab_strip_collapses_a_lone_tab_to_a_plain_heading() -> None: