diff --git a/meshterm/ui/records_screen.py b/meshterm/ui/records_screen.py index f72e7a9..8dfc81b 100644 --- a/meshterm/ui/records_screen.py +++ b/meshterm/ui/records_screen.py @@ -671,6 +671,7 @@ async def open_records(ctx: "AppContext") -> dict: *, show_width: bool, score_w: int, + width: int, ) -> Text: """One record row: rank, date, score (width when it disambiguates), then the walk. @@ -682,9 +683,11 @@ async def open_records(ctx: "AppContext") -> dict: so naming ourselves twice per row would cost more cells than the whole score lane and tell the reader what every other row already told them. Those stars keep the ``you`` white (``dim_self=False``) — the fade means "not yours to compose", and nothing on a - board of walks already made is being composed. Overflow stays the list's job — the - browser h-scrolls the highlighted row, so a long walk is read by sliding it, not by - eliding it here. + board of walks already made is being composed. A walk wider than ``width`` (the + row's render budget) middle-elides through ``PathLine.ellipsized`` so both endpoints + survive — a right truncation would amputate the return leg — while the *highlighted* + row keeps its natural length and h-scrolls under ←→, so the elided middle is read by + sliding it. """ row = Text(f"#{rank} ", style="muted") row.append(record.discovered_at.astimezone().strftime("%b %d"), style="muted") @@ -693,17 +696,16 @@ async def open_records(ctx: "AppContext") -> dict: if show_width: row.append(f" {record.width_bytes} B", style="muted") row.append(" ") - row.append_text( - path_line( - [None, *record.route, None], - resolve, - prefix_bytes=record.width_bytes, - hash_bytes=record.width_bytes, - self_name=device_label, - bare_self=True, - dim_self=False, - ).text() + walk = path_line( + [None, *record.route, None], + resolve, + prefix_bytes=record.width_bytes, + hash_bytes=record.width_bytes, + self_name=device_label, + bare_self=True, + dim_self=False, ) + row.append_text(walk.ellipsized(max(8, width - row.cell_len))) return row async def delete_category_flow() -> None: @@ -755,8 +757,14 @@ async def open_records(ctx: "AppContext") -> dict: score_w = max((cell_len(scored(category, r)) for r in board), default=0) for rank, record in enumerate(board, start=1): items.append(Choice( - title=browser_row( - rank, category, record, show_width=show_width, score_w=score_w + # Width-aware (see Choice.title): the row re-fits its walk to each + # render width, middle-eliding rather than dying at the right edge. + title=( + lambda width, rank=rank, category=category, record=record, + show_width=show_width, score_w=score_w: browser_row( + rank, category, record, + show_width=show_width, score_w=score_w, width=width, + ) ), value=("open", category, rank, record), )) diff --git a/meshterm/ui/trace_screen.py b/meshterm/ui/trace_screen.py index 5a93d72..c9701fb 100644 --- a/meshterm/ui/trace_screen.py +++ b/meshterm/ui/trace_screen.py @@ -1213,7 +1213,8 @@ def _best_observed(topo: Any, target_hash: str) -> Optional[tuple[tuple[str, ... def _scenario_path( - scenario: Any, topo: Any, target_id: str, *, device_label: str, width_bytes: int + scenario: Any, topo: Any, target_id: str, *, device_label: str, width_bytes: int, + width: Optional[int] = None, ) -> Text: """A scenario's pathline: us, the candidate hops, and the target — one line. @@ -1224,6 +1225,8 @@ def _scenario_path( unnamed hop its prefix-lit hash) instead of the old single-colour smear — and, as in the route lane above it, our own end goes bare: every candidate starts from us, so the word would be the same on every row and the cells are the candidates'. + With a ``width`` budget the line middle-elides (``PathLine.ellipsized``) so both + endpoints survive a narrow terminal; ``None`` returns the full line. """ route = path_line( [None, *scenario.hops, target_id], @@ -1232,7 +1235,7 @@ def _scenario_path( self_name=device_label, bare_self=True, ) - return route.text() + return route.text() if width is None else route.ellipsized(width) def _scenario_detail(scenario: Any) -> Text: @@ -1875,9 +1878,15 @@ async def _open_session( for scenario in scenarios: items.append( Choice( - title=_scenario_path( - scenario, topo, target_id, - device_label=device_label, width_bytes=width_bytes, + # Width-aware (see Choice.title): a long candidate middle-elides to + # the terminal so both endpoints survive; the highlighted row keeps + # its natural length and slides under ←→ instead. + title=( + lambda width, scenario=scenario: _scenario_path( + scenario, topo, target_id, + device_label=device_label, width_bytes=width_bytes, + width=width, + ) ), detail=_scenario_detail(scenario), value=("use", scenario), diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index e50f5c8..7ea3e6e 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -8,6 +8,7 @@ arbitrary objects, so the same screen drives the main menu (tool names), the dev from __future__ import annotations +import inspect from dataclasses import dataclass from typing import Any, Callable, Optional, Union @@ -18,6 +19,25 @@ from .render import crop_cells, render_lines, render_to_ansi from .screen import Screen +def _wants_width(fn: Callable) -> bool: + """Whether a callable :attr:`Choice.title` takes the render width. + + A title callable with at least one *required* positional parameter is the width-aware + form; one with none (including defaults-only signatures) stays the zero-argument + live-repaint form. Decided from the signature once at construction — never by trying + the call, so a ``TypeError`` raised *inside* a title can't be mistaken for arity. + """ + try: + parameters = inspect.signature(fn).parameters.values() + except (TypeError, ValueError): # a builtin without an introspectable signature + return False + return any( + p.default is p.empty + and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) + for p in parameters + ) + + @dataclass class Choice: """One selectable row. @@ -26,7 +46,10 @@ class Choice: title: Text shown for the row — a plain string or a Rich :class:`~rich.text.Text` (for a coloured segment such as an unread badge). Either may instead be a zero-argument callable resolved fresh on every repaint, so a row can track state - that changes while the list is open. + that changes while the list is open — or a callable taking the **render width** + (one required positional argument), the :class:`Separator` power extended to + rows: a route-bearing row can then middle-elide itself to the terminal + (``PathLine.ellipsized``) instead of being amputated at the right edge. value: Value returned when the row is chosen. deletable: Whether pressing Delete on this row asks to remove it. When set, Delete resolves the list with a :class:`DeleteRequest` wrapping this row's value instead @@ -40,15 +63,25 @@ class Choice: ``title``. """ - title: Union[str, Text, Callable[[], Union[str, Text]]] + title: Union[str, Text, Callable[[], Union[str, Text]], Callable[[int], Union[str, Text]]] value: Any deletable: bool = False detail: Union[str, Text, Callable[[], Union[str, Text]], None] = None + def __post_init__(self) -> None: + # The callable form's arity, read once — see _wants_width. + self._title_wants_width = callable(self.title) and _wants_width(self.title) + + def text(self, width: int) -> Union[str, Text]: + """The row's content at ``width`` cells, resolving either callable form.""" + if not callable(self.title): + return self.title + return self.title(width) if self._title_wants_width else self.title() + @property def label(self) -> Union[str, Text]: - """The row's current text, resolving a callable title on each read.""" - return self.title() if callable(self.title) else self.title + """The row's *natural* (unbounded) text — what filtering and measuring read.""" + return self.text(_UNBOUNDED) @property def detail_label(self) -> Optional[Union[str, Text]]: @@ -148,9 +181,10 @@ class Separator: Item = "Choice | Separator" -#: The width handed to a width-aware :class:`Separator` when a *natural* width is being -#: measured rather than a real terminal one (:attr:`SelectScreen.dialog_width`) — wide -#: enough that a self-fitting column header returns its fullest form. +#: The width handed to a width-aware :class:`Separator` or :class:`Choice` when a +#: *natural* width is being measured rather than a real terminal one +#: (:attr:`SelectScreen.dialog_width`, :attr:`Choice.label`) — wide enough that a +#: self-fitting column header or self-eliding row returns its fullest form. _UNBOUNDED = 10_000 #: The navigation actions that move the highlight to another row, so an ``hscroll`` list @@ -386,7 +420,7 @@ class SelectScreen(Screen): else self._footer_base widths = [cell_len(self.title), cell_len(footer), cell_len(self._prompt)] for item in self._items: - label = item.text(_UNBOUNDED) if isinstance(item, Separator) else item.label + label = item.text(_UNBOUNDED) # both row kinds measure at their fullest form widths.append(cell_len(_plain(label)) + 2) # + the "❯ " / " " pointer column if isinstance(item, Choice) and item.detail_label is not None: widths.append(cell_len(_plain(item.detail_label)) + 2) # same hanging indent @@ -472,7 +506,14 @@ class SelectScreen(Screen): cursor_at = len(lines) pointer = "❯ " if is_sel else " " style = "brand" if is_sel else "" - label = item.label + # A width-aware title fits itself to the row's content area (the width less + # the 2-cell pointer). The exception is the highlighted row of an ``hscroll`` + # list, which keeps its natural form: ←→ slide the full line, and a row that + # pre-elided itself would have nothing left to slide over. + if self._hscroll and is_sel: + label = item.label + else: + label = item.text(max(1, width - 2)) # A Text label carries its own spans (e.g. a red badge); keep them and lay the # row's base style underneath, so the highlight tints the row while the badge # keeps its colour. A plain string is styled uniformly as before. diff --git a/tests/test_tui.py b/tests/test_tui.py index 2b300c1..c041645 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -189,6 +189,38 @@ def test_select_callable_title_re_renders_live() -> None: assert "chan ● 3" in "\n".join(screen.render_body(40)) +def test_select_width_aware_title_fits_itself_to_the_row() -> None: + """A title taking the render width fits itself (a route middle-elides); the natural + form still feeds filtering and the dialog's own width measure.""" + seen: list[int] = [] + + def fitted(width: int) -> str: + seen.append(width) + return "you → hub → far" if width >= 20 else "you ⋯ far" + + screen = SelectScreen("pick", [Choice(fitted, 1)]) + assert "you ⋯ far" in "\n".join(screen.render_body(12)) + assert seen[-1] == 10 # the row's content area: the width less the 2-cell pointer + assert "you → hub → far" in "\n".join(screen.render_body(40)) + assert screen.dialog_width > cell_len("you → hub → far") # measured at its fullest + screen.handle("text", "hub") # the filter reads the natural (unbounded) form + assert screen._rows() == screen._items + + +def test_select_hscroll_highlight_keeps_the_natural_row() -> None: + """In an hscroll list the highlighted row skips self-fitting — ←→ slide the full + line — while every other row still elides itself to the width.""" + + def fitted(width: int) -> str: + return "start middle end" if width >= 20 else "start ⋯ end" + + items = [Choice(fitted, 1), Choice(fitted, 2)] + screen = SelectScreen("pick", items, hscroll=True) + body = "\n".join(screen.render_body(14)) + assert "start middl" in body # the highlighted row: natural form, cropped by the screen + assert "start ⋯ end" in body # the unhighlighted row fitted itself + + def test_select_filter_matches_callable_title() -> None: """Type-to-filter matches against a callable title's current text.""" screen = SelectScreen("pick", [Choice(lambda: "alpha", 1), Choice("beta", 2)])