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:
1 parent
f1b8030055
commit
e266e387e9
4 files changed
+119
-29
No files matched your search
@@ -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),
|
||||
))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)])
|
||||
|
||||
Reference in new issue
Block a user