diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index d84b2c6..839f4c5 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -914,26 +914,30 @@ def tab_strip(labels: Sequence[str], active: int, width: int) -> Group: 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. Every tab is always drawn as a full-topped 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 *top* corner is - drawn as if the tab to the left sits on top of the tab to the right: it "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 top - corners, reading as lifted in front of its neighbours. + them. Every tab is always drawn as a full-topped box — a dim ``faint`` outline by + default, lit ``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 *top* corner is drawn as if the tab to the left sits on top of the + tab to the right: it "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 top corners, reading as lifted in front of its neighbours. - The *bottom* border is a single continuous rule the inactive tabs sit flush against — - they get no corners or seams of their own down there, just plain muted line passing - behind them — broken only under the active tab, which is why it reads as merged into the - page below it: the rule steps up into ``╰``, leaves the active tab's width open (no - line — that gap *is* the page starting), then steps back down through ``╯`` and carries - on flat. 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. When there's slack in ``width``, the whole strip - sits two columns in from the left rather than flush against it. The owning screen - switches the active index (``Tab``/``Shift+Tab``); the strip itself is pure presentation. + The *bottom* border is a single continuous accent-coloured rule — it's one line, so it + carries one colour throughout, the active tab's — that the inactive tabs sit flush + against: they get no corners or seams of their own down there, just the rule passing + behind them, broken only under the active tab, which is why it reads as merged into the + page below it: the rule turns up into ``╯`` (closing off the flat run from the west, + turning north into the tab's wall), leaves the active tab's width open (no line — that + gap *is* the page starting), then turns back down through ``╰`` (the wall meeting the + flat run continuing east) and carries on flat. 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. When there's slack in ``width``, the tab boxes + (top and label rows) sit two columns in from the left; the bottom rule is one continuous + line regardless, so it fills that margin rather than being indented with them. The + owning screen switches the active index (``Tab``/``Shift+Tab``); the strip itself is + pure presentation. Args: labels: The tab names in display order. @@ -960,35 +964,33 @@ def tab_strip(labels: Sequence[str], active: int, width: int) -> Group: return junction if junction == 0 or junction == active else junction - 1 def top_style(junction: int) -> str: - return "accent" if top_owner(junction) == active else "muted" + return "accent" if top_owner(junction) == active else "faint" - def bot_glyph(junction: int) -> tuple[str, str]: - """The bottom rule's glyph at this junction: an accent step around the active tab, - else a flat muted pass-through — see the docstring's continuous-rule rule.""" + def bot_glyph(junction: int) -> str: + """The bottom rule's glyph at this junction — a corner turning into/out of the + active tab's wall, else a flat pass-through. Always accent: see the docstring.""" if junction == active: - return "╰", "accent" + return "╯" if junction == active + 1: - return "╯", "accent" - return "─", "muted" + return "╰" + return "─" top = Text(pad, no_wrap=True) mid = Text(pad, no_wrap=True) - bot = Text(pad, no_wrap=True) + bot = Text("─" * margin, style="accent", no_wrap=True) for i, label in enumerate(labels): opens = i == 0 or i == active top.append("╭" if opens else "╮", style=top_style(i)) mid.append("│", style=top_style(i)) - glyph, style = bot_glyph(i) - bot.append(glyph, style=style) - tab_style = "accent" if i == active else "muted" + bot.append(bot_glyph(i), style="accent") + tab_style = "accent" if i == active else "faint" top.append("─" * len(inner[i]), style=tab_style) mid.append(inner[i], style=tab_style) - bot.append(" " * len(inner[i]) if i == active else "─" * len(inner[i]), style=tab_style) + bot.append(" " * len(inner[i]) if i == active else "─" * len(inner[i]), style="accent") top.append("╮", style=top_style(n)) mid.append("│", style=top_style(n)) - glyph, style = bot_glyph(n) - bot.append(glyph, style=style) - bot.append("─" * max(0, width - len(bot.plain)), style="muted") + bot.append(bot_glyph(n), style="accent") + bot.append("─" * max(0, width - len(bot.plain)), style="accent") return Group(top, mid, bot) diff --git a/tests/test_node_detail.py b/tests/test_node_detail.py index abf4701..d141a41 100644 --- a/tests/test_node_detail.py +++ b/tests/test_node_detail.py @@ -136,13 +136,27 @@ def test_tab_strip_boxes_every_tab_on_top_and_lights_the_active_one() -> None: def test_tab_strip_bottom_is_a_continuous_rule_notched_at_the_active_tab() -> None: """The bottom border is flat under inactive tabs and opens only under the active one.""" group = tab_strip(["Map", "Routes"], 1, width=20) - _, _, bot = group.renderables - # flat rule under "Map" (no corner of its own), then the notch opens/closes around - # "Routes" — up into the box, blank across its width (open into the page), back down. - assert bot.plain == "────────╰ ╯" + top, mid, bot = group.renderables + # flat rule under "Map" (no corner of its own), then the notch turns up (west+north, + # ╯) into "Routes", blank across its width (open into the page), back down (north+east, + # ╰) and on. + assert bot.plain == "────────╯ ╰" + # One continuous line: every cell reads accent, the active tab's colour, not a mix. + assert {r.style for r in bot.spans} == {"accent"} _, _, bot_first = tab_strip(["Map", "Routes"], 0, width=20).renderables - assert bot_first.plain == "╰ ╯───────────" # notch now sits under the first tab + assert bot_first.plain == "╯ ╰───────────" # notch now sits under the first tab + + +def test_tab_strip_unselected_tabs_read_darker_than_muted() -> None: + """Inactive tab outlines use the dim ``faint`` shade, not the everyday ``muted`` one.""" + top, mid, _ = tab_strip(["Map", "Routes"], 1, width=20).renderables + # "Map"'s own left corner and fill (columns 0-7); its right corner at column 8 is + # shared with the active "Routes" tab and reads accent, so it's excluded here. + map_styles = {s.style for s in top.spans if s.start < 8} | { + s.style for s in mid.spans if s.start < 8 + } + assert map_styles == {"faint"} def test_tab_strip_labels_hold_position_across_the_active_index() -> None: @@ -155,10 +169,11 @@ def test_tab_strip_labels_hold_position_across_the_active_index() -> None: def test_tab_strip_rule_fills_out_to_the_render_width() -> None: - """The bottom rule extends to the full render width, margin included.""" - _, _, bot = tab_strip(["Map", "Routes"], 1, width=25).renderables - # 2-column left margin (there's room), then the 20-cell strip, then 3 cells of filler. - assert bot.plain == " ────────╰ ╯───" + """The bottom rule extends to the full render width; unlike the tabs, it isn't indented + by the left margin — that margin is itself part of the one continuous line.""" + top, _, bot = tab_strip(["Map", "Routes"], 1, width=25).renderables + assert top.plain.startswith(" ╭") # the tab boxes sit indented... + assert bot.plain == "──────────╯ ╰───" # ...but the rule fills straight through assert len(bot.plain) == 25