Enter is now the sole grab/drop key (Space is ignored), the rows drop the openness/hash lanes since reordering is about position, and the screen gains a dialog_width sized for its fullest state so the popup fits its content without resizing mid-interaction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
451 lines
19 KiB
Python
451 lines
19 KiB
Python
"""The selectable-list screen: the reusable replacement for ``questionary.select``.
|
||
|
||
A :class:`SelectScreen` shows grouped, arrow-navigable choices with type-to-filter, bounded
|
||
to the viewport and scrolled to keep the highlighted row visible. Choice values are
|
||
arbitrary objects, so the same screen drives the main menu (tool names), the device picker
|
||
(device objects), and the config editor (setting keys).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Any, Callable, Optional, Union
|
||
|
||
from rich.cells import cell_len
|
||
from rich.text import Text
|
||
|
||
from .render import render_lines, render_to_ansi
|
||
from .screen import Screen
|
||
|
||
|
||
@dataclass
|
||
class Choice:
|
||
"""One selectable row.
|
||
|
||
Attributes:
|
||
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.
|
||
value: Value returned when the row is chosen.
|
||
"""
|
||
|
||
title: Union[str, Text, Callable[[], Union[str, Text]]]
|
||
value: Any
|
||
|
||
@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
|
||
|
||
|
||
def _plain(label: Union[str, Text]) -> str:
|
||
"""The plain-text form of a row label, for filtering (a :class:`Text` keeps its ``plain``)."""
|
||
return label.plain if isinstance(label, Text) else label
|
||
|
||
|
||
@dataclass
|
||
class Separator:
|
||
"""A non-selectable row between choices.
|
||
|
||
Attributes:
|
||
title: The row's text.
|
||
style: Theme style the row is drawn in. Section headings pass ``"accent"`` so
|
||
they read as highlighted landmarks; the default ``"muted"`` fits the
|
||
structural rows (blank spacers, column-header lines, inline notes).
|
||
"""
|
||
|
||
title: str
|
||
style: str = "muted"
|
||
|
||
|
||
Item = "Choice | Separator"
|
||
|
||
|
||
class SelectScreen(Screen):
|
||
"""A grouped, filterable, single-choice list.
|
||
|
||
Resolves with the chosen :class:`Choice` value, or :data:`~meshterm.ui.tui.screen.CANCEL`
|
||
if the user presses Esc.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
title: str,
|
||
items: list,
|
||
*,
|
||
prompt: str = "",
|
||
default: Any = None,
|
||
footer_hint: str = "↑↓ move · type to filter · Enter select · Esc back",
|
||
filterable: bool = True,
|
||
wrap: bool = True,
|
||
) -> None:
|
||
"""Build a select screen.
|
||
|
||
Args:
|
||
title: Short heading shown in the border.
|
||
items: A list of :class:`Choice` and :class:`Separator` in display order.
|
||
prompt: An optional instruction shown inside the box, above the list — so a
|
||
floating select reads like the other dialogs (a prompt above its controls).
|
||
default: A choice value to pre-highlight, if present.
|
||
footer_hint: Footer key hint.
|
||
filterable: Whether typing narrows the list. Off for short, fixed lists (e.g.
|
||
the startup device picker) where type-to-filter would only get in the way.
|
||
wrap: Whether the highlight wraps around the ends (Down from the last row jumps
|
||
to the first, and vice versa). Off for grouped lists where wrapping across the
|
||
section headings reads as a jarring jump rather than continuing to scroll.
|
||
"""
|
||
super().__init__()
|
||
self.title = title
|
||
self.footer_hint = footer_hint
|
||
self._prompt = prompt
|
||
self._filterable = filterable
|
||
self._wrap = wrap
|
||
self._items = items
|
||
self._filter = ""
|
||
# Index into the currently-selectable (filtered) choices.
|
||
self._index = 0
|
||
if default is not None:
|
||
selectable = [it for it in items if isinstance(it, Choice)]
|
||
for i, choice in enumerate(selectable):
|
||
if choice.value == default:
|
||
self._index = i
|
||
break
|
||
|
||
# --- filtering -----------------------------------------------------------
|
||
|
||
def _rows(self) -> list:
|
||
"""Return the items to display given the active filter.
|
||
|
||
Without a filter, groups and headings show as authored. With a filter, only the
|
||
matching choices are shown — but every separator stays, so the list keeps its
|
||
section landmarks (and column headers) while it narrows.
|
||
"""
|
||
if not self._filter:
|
||
return self._items
|
||
needle = self._filter.lower()
|
||
return [
|
||
it
|
||
for it in self._items
|
||
if isinstance(it, Separator) or needle in _plain(it.label).lower()
|
||
]
|
||
|
||
def _choices(self, rows: Optional[list] = None) -> list:
|
||
"""Return just the selectable choices among ``rows`` (or the current rows)."""
|
||
rows = self._rows() if rows is None else rows
|
||
return [it for it in rows if isinstance(it, Choice)]
|
||
|
||
def _section_starts(self) -> list[int]:
|
||
"""Choice indices that begin a section — the first choice after each run of separators.
|
||
|
||
Drives Ctrl+PageUp/PageDown section jumps. Computed over the *displayed* rows, so
|
||
the jumps keep working while a filter narrows the list (the headings stay — see
|
||
:meth:`_rows` — and empty sections simply yield no stop).
|
||
"""
|
||
starts: list[int] = []
|
||
idx = 0
|
||
fresh = True # the next choice opens a section (top of the list, or just past a heading)
|
||
for item in self._rows():
|
||
if isinstance(item, Separator):
|
||
fresh = True
|
||
elif isinstance(item, Choice):
|
||
if fresh:
|
||
starts.append(idx)
|
||
fresh = False
|
||
idx += 1
|
||
return starts
|
||
|
||
def _jump_section(self, direction: int) -> None:
|
||
"""Move the highlight to the next section (``+1``) or the current/previous one (``-1``).
|
||
|
||
Mirrors the scroll-based section jump of read-only screens, but in choice space: down
|
||
lands on the first choice of the following section; up lands on the first choice of the
|
||
section we're in, or the previous section's when already at a section start.
|
||
"""
|
||
starts = self._section_starts()
|
||
if not starts:
|
||
return
|
||
if direction > 0:
|
||
nxt = next((s for s in starts if s > self._index), None)
|
||
if nxt is not None:
|
||
self._index = nxt
|
||
else:
|
||
at_or_before = [s for s in starts if s <= self._index]
|
||
if not at_or_before:
|
||
self._index = 0
|
||
elif at_or_before[-1] < self._index:
|
||
self._index = at_or_before[-1]
|
||
else:
|
||
self._index = at_or_before[-2] if len(at_or_before) >= 2 else 0
|
||
|
||
# --- rendering -----------------------------------------------------------
|
||
|
||
@property
|
||
def dialog_width(self) -> int:
|
||
"""Natural outer width so a floating select hugs its widest row, not the terminal.
|
||
|
||
The widest of the prompt, title, footer, and every row (with room for the pointer),
|
||
so a short menu is a tidy popup rather than a full-width banner. The compositor still
|
||
caps this to the space available, and this is only read for a *floating* select — the
|
||
full-screen base menu is laid out by ``compose_base`` and ignores it.
|
||
"""
|
||
widths = [cell_len(self.title), cell_len(self.footer_hint), cell_len(self._prompt)]
|
||
for item in self._items:
|
||
label = item.title if isinstance(item, Separator) else _plain(item.label)
|
||
widths.append(cell_len(label) + 2) # + the "❯ " / " " pointer column
|
||
return max(widths, default=20) + 8
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
"""Render the optional prompt then each row as one ANSI line, the choice marked."""
|
||
rows = self._rows()
|
||
choices = self._choices(rows)
|
||
self._index = max(0, min(self._index, len(choices) - 1)) if choices else 0
|
||
selected = choices[self._index] if choices else None
|
||
|
||
lines: list[str] = []
|
||
# A prompt (when set) sits above the list, offsetting every row below it; the cursor
|
||
# line and sticky-header indices below are shifted by exactly this many lines.
|
||
prefix = 0
|
||
if self._prompt:
|
||
plines = render_lines(Text(self._prompt), width)
|
||
lines.extend(plines)
|
||
lines.append("")
|
||
prefix = len(plines) + 1
|
||
# Record each section heading as a sticky-header candidate, so one that scrolls off is
|
||
# re-pinned to the top row by the base Screen.sticky_header. Separators survive
|
||
# filtering (see _rows), so the pinning keeps working while the list narrows.
|
||
self._sticky_headers = []
|
||
if self._filter:
|
||
lines.append(render_to_ansi(Text(f"/{self._filter}", style="warn"), width))
|
||
for item in rows:
|
||
if isinstance(item, Separator):
|
||
sep = render_to_ansi(Text(item.title, style=item.style), width)
|
||
self._sticky_headers.append((len(lines), sep))
|
||
lines.append(sep)
|
||
continue
|
||
is_sel = item is selected
|
||
pointer = "❯ " if is_sel else " "
|
||
style = "brand" if is_sel else ""
|
||
label = item.label
|
||
# 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.
|
||
text = Text(pointer, style=style)
|
||
text.append_text(label if isinstance(label, Text) else Text(label))
|
||
text.style = style
|
||
text.no_wrap = True
|
||
text.overflow = "ellipsis"
|
||
text.truncate(width)
|
||
lines.append(render_to_ansi(text, width))
|
||
if not choices:
|
||
lines.append(render_to_ansi(Text("(no matches)", style="muted"), width))
|
||
# Remember where the highlighted row landed so the session can keep it in view,
|
||
# shifted past any prompt lines drawn above the list.
|
||
base_cursor = _cursor_line(rows, selected, bool(self._filter))
|
||
self._cursor = None if base_cursor is None else base_cursor + prefix
|
||
return lines
|
||
|
||
def cursor_line(self) -> Optional[int]:
|
||
"""Return the body line index of the highlighted row."""
|
||
return getattr(self, "_cursor", None)
|
||
|
||
# --- input ---------------------------------------------------------------
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
"""Move the highlight, edit the filter, or commit/cancel the selection."""
|
||
choices = self._choices()
|
||
if action == "up":
|
||
if choices:
|
||
self._index = (self._index - 1) % len(choices) if self._wrap else max(
|
||
0, self._index - 1
|
||
)
|
||
elif action == "down":
|
||
if choices:
|
||
self._index = (self._index + 1) % len(choices) if self._wrap else min(
|
||
len(choices) - 1, self._index + 1
|
||
)
|
||
elif action == "pageup":
|
||
self._index = max(0, self._index - self._page_step)
|
||
elif action == "pagedown":
|
||
self._index = min(len(choices) - 1, self._index + self._page_step) if choices else 0
|
||
elif action in ("home", "ctrl_home"):
|
||
self._index = 0
|
||
elif action in ("end", "ctrl_end"):
|
||
self._index = max(0, len(choices) - 1)
|
||
elif action == "ctrl_pagedown":
|
||
self._jump_section(1)
|
||
elif action == "ctrl_pageup":
|
||
self._jump_section(-1)
|
||
elif action == "enter":
|
||
if choices:
|
||
self.resolve(choices[self._index].value)
|
||
elif action == "escape":
|
||
super().handle("escape")
|
||
elif action == "backspace" and self._filterable:
|
||
self._filter = self._filter[:-1]
|
||
self._index = 0
|
||
elif action == "text" and self._filterable and data.isprintable():
|
||
self._filter += data
|
||
self._index = 0
|
||
|
||
|
||
class ReorderScreen(Screen):
|
||
"""A list whose rows the user rearranges in place with the arrow keys.
|
||
|
||
Move the cursor with ↑/↓; press Enter on a row to *grab* it, then ↑/↓ carry it up and
|
||
down the list; press Enter again to *drop* it. Below the list sit the action rows,
|
||
following the config editor's pattern: once the order has actually changed, an
|
||
ok-tinted *Apply* joins an err-tinted *Back — discard*; while it is untouched there is
|
||
only a plain *Back*. Enter on Apply commits, resolving with the final order as a list
|
||
of the original row indices (so ``[2, 0, 1]`` means "the row that started third is now
|
||
first"); Enter on Back — like Esc anywhere — resolves :data:`CANCEL` so the caller
|
||
keeps the original order.
|
||
"""
|
||
|
||
#: Action-row sentinels (kept distinct from list positions, which are ints).
|
||
_APPLY = "apply"
|
||
_BACK = "back"
|
||
|
||
#: Footer hints for both grab states — dialog_width sizes to the longer one, so the
|
||
#: box never resizes when a grab starts.
|
||
_HINT_IDLE = "↑↓ move · Enter grab / select · Esc cancel"
|
||
_HINT_GRABBED = "↑↓ move row · Enter drop · Esc cancel"
|
||
|
||
def __init__(self, title: str, labels: list[str]) -> None:
|
||
"""Build a reorder screen.
|
||
|
||
Args:
|
||
title: Heading shown above the list.
|
||
labels: The row labels, in their current order.
|
||
"""
|
||
super().__init__()
|
||
self.title = title
|
||
self._labels = list(labels)
|
||
# order[position] == the label's original index; a moved row carries its index along.
|
||
self._order = list(range(len(labels)))
|
||
self._index = 0
|
||
self._grabbed = False
|
||
|
||
@property
|
||
def footer_hint(self) -> str: # type: ignore[override]
|
||
"""Key hint, phrased for whether a row is currently grabbed."""
|
||
return self._HINT_GRABBED if self._grabbed else self._HINT_IDLE
|
||
|
||
@property
|
||
def dialog_width(self) -> int:
|
||
"""Natural outer width so a floating reorder hugs its widest row (see SelectScreen).
|
||
|
||
Sized for the fullest the box can get — both footer hints and the dirty-state
|
||
action rows — so it never widens mid-interaction when a grab starts or Apply
|
||
appears. The compositor still caps this to the terminal.
|
||
"""
|
||
widths = [cell_len(self.title), cell_len(self._HINT_IDLE), cell_len(self._HINT_GRABBED)]
|
||
rows = self._labels + [label.plain for _key, label in self._dirty_actions()]
|
||
widths += [cell_len(row) + 2 for row in rows] # + the "❯ " / " " pointer column
|
||
return max(widths, default=20) + 8
|
||
|
||
def _dirty(self) -> bool:
|
||
"""Whether the rows have actually left their original order."""
|
||
return self._order != list(range(len(self._order)))
|
||
|
||
@staticmethod
|
||
def _dirty_actions() -> list[tuple[str, Text]]:
|
||
"""The full exit group shown once the order has changed (also sizes dialog_width)."""
|
||
return [
|
||
(ReorderScreen._APPLY, Text.assemble(("✓ ", "ok"), "Apply new order")),
|
||
(ReorderScreen._BACK, Text.assemble(("✗ ", "err"), "Back — discard changes")),
|
||
]
|
||
|
||
def _actions(self) -> list[tuple[str, Text]]:
|
||
"""The action rows below the list, matching the config editor's exit group:
|
||
Apply joins Back only once there is a change to apply, and Back then spells
|
||
out the consequence of leaving.
|
||
"""
|
||
if self._dirty():
|
||
return self._dirty_actions()
|
||
return [(self._BACK, Text("Back"))]
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
"""Render the rows, a blank spacer, then the action group, marking the cursor."""
|
||
n = len(self._order)
|
||
lines: list[str] = []
|
||
for pos, orig in enumerate(self._order):
|
||
is_cursor = pos == self._index
|
||
if is_cursor and self._grabbed:
|
||
pointer, style = "▸ ", "brand"
|
||
elif is_cursor:
|
||
pointer, style = "❯ ", "brand"
|
||
else:
|
||
pointer, style = " ", ""
|
||
text = Text(pointer + self._labels[orig], style=style, no_wrap=True,
|
||
overflow="ellipsis")
|
||
text.truncate(width)
|
||
lines.append(render_to_ansi(text, width))
|
||
lines.append("")
|
||
for i, (_key, label) in enumerate(self._actions()):
|
||
if n + i == self._index:
|
||
# The cursor row goes full-brand like the list rows above it,
|
||
# trading the ✓/✗ tint for the highlight.
|
||
text = Text("❯ " + label.plain, style="brand", no_wrap=True)
|
||
else:
|
||
text = Text(" ", no_wrap=True)
|
||
text.append_text(label)
|
||
text.truncate(width, overflow="ellipsis")
|
||
lines.append(render_to_ansi(text, width))
|
||
# The spacer line offsets every action row by one on screen.
|
||
self._cursor = self._index if self._index < n else self._index + 1
|
||
return lines
|
||
|
||
def cursor_line(self) -> Optional[int]:
|
||
"""Return the body line index of the cursor row, so the session keeps it in view."""
|
||
return getattr(self, "_cursor", None)
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
"""Move the cursor, carry a grabbed row, grab/drop, run an action, or cancel on Esc."""
|
||
n = len(self._order)
|
||
total = n + len(self._actions())
|
||
if action == "up":
|
||
if self._grabbed and self._index > 0:
|
||
self._order[self._index - 1], self._order[self._index] = (
|
||
self._order[self._index], self._order[self._index - 1])
|
||
self._index -= 1
|
||
elif not self._grabbed and total:
|
||
self._index = (self._index - 1) % total
|
||
elif action == "down":
|
||
if self._grabbed and self._index < n - 1:
|
||
self._order[self._index + 1], self._order[self._index] = (
|
||
self._order[self._index], self._order[self._index + 1])
|
||
self._index += 1
|
||
elif not self._grabbed and total:
|
||
self._index = (self._index + 1) % total
|
||
elif action == "enter":
|
||
if self._index < n:
|
||
self._grabbed = not self._grabbed # Enter grabs a row, Enter again drops it
|
||
elif self._actions()[self._index - n][0] == self._APPLY:
|
||
self.resolve(list(self._order))
|
||
else:
|
||
super().handle("escape") # Back resolves CANCEL, same as Esc
|
||
elif action == "escape":
|
||
super().handle("escape")
|
||
|
||
|
||
def _cursor_line(rows: list, selected: Optional[Choice], filtered: bool) -> Optional[int]:
|
||
"""Compute the rendered body line index of the selected row.
|
||
|
||
Args:
|
||
rows: The displayed rows (choices and separators).
|
||
selected: The currently highlighted choice, if any.
|
||
filtered: Whether a filter line precedes the rows (offsetting every row by one).
|
||
|
||
Returns:
|
||
The zero-based line index of the highlighted row, or ``None`` if nothing is
|
||
selected.
|
||
"""
|
||
if selected is None:
|
||
return None
|
||
offset = 1 if filtered else 0
|
||
for i, item in enumerate(rows):
|
||
if item is selected:
|
||
return i + offset
|
||
return None
|