Rows: a row can now size itself, like a separator always could

Choice.title accepts a width-taking callable (arity read once from the
signature; the zero-argument live form is untouched), resolved at the
row's content area each repaint while filtering, h-scroll measurement,
and dialog_width keep reading the natural unbounded form. The two
route-bearing lists use it: trophy rows and Explore-paths candidates
middle-elide through PathLine.ellipsized so both endpoints survive a
narrow terminal — a right truncation amputated the return leg — while
the highlighted row of an hscroll list keeps its natural length so ←→
still slide the full line (housekeeping H-007).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-29 23:19:10 -04:00
1 parent f1b8030055
commit e266e387e9
4 files changed
+119 -29

No files matched your search

+23 -15
View File
@@ -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),
))
+14 -5
View File
@@ -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),
+50 -9
View File
@@ -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.
+32
View File
@@ -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)])