From 96813130d5773e4adea4ac8ce952ffc84744b849 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Fri, 10 Jul 2026 03:32:23 -0400 Subject: [PATCH] Reorder screen slims to glyph+name rows and hugs its content 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 --- meshterm/ui/channels.py | 11 +++++--- meshterm/ui/tui/select.py | 59 ++++++++++++++++++++++++--------------- tests/test_tui.py | 49 ++++++++++++++++---------------- 3 files changed, 68 insertions(+), 51 deletions(-) diff --git a/meshterm/ui/channels.py b/meshterm/ui/channels.py index a137b34..fdb6684 100644 --- a/meshterm/ui/channels.py +++ b/meshterm/ui/channels.py @@ -283,10 +283,13 @@ def _next_free_slot(slots: list[ChannelSlot], capacity: int) -> Optional[int]: def _slot_label(slot: ChannelSlot) -> str: - """Format a channel for a compact row (the reorder screen): name, openness, hash.""" - glyph = channel_glyph(slot.name, slot.secret) # οΌƒ / 🌐 / πŸ”’ - word = "private" if glyph == "πŸ”’" else "public" - return f"{slot.name:<18.18} {glyph} {word:<7} hash {slot.hash}" + """Format a channel for a compact row (the reorder screen): just its glyph and name. + + The reorder screen is about *position*, not vitals β€” the openness, hash, and message + lanes of the manager list would only widen the popup β€” so each row is the channel + exactly as the manager's first lane shows it: glyph, gap, name. + """ + return f"{channel_glyph(slot.name, slot.secret)} {slot.name}" # --- message statistics -------------------------------------------------------- diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index a460b8c..bf7a9eb 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -292,20 +292,25 @@ class SelectScreen(Screen): class ReorderScreen(Screen): """A list whose rows the user rearranges in place with the arrow keys. - Move the cursor with ↑/↓; press Space (or Enter) on a row to *grab* it, then ↑/↓ carry - it up and down the list; press Space (or 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. + 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. @@ -324,24 +329,40 @@ class ReorderScreen(Screen): @property def footer_hint(self) -> str: # type: ignore[override] """Key hint, phrased for whether a row is currently grabbed.""" - if self._grabbed: - return "↑↓ move row Β· Space drop Β· Esc cancel" - return "↑↓ move Β· Space grab Β· Enter select Β· Esc cancel" + 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._APPLY, Text.assemble(("βœ“ ", "ok"), "Apply new order")), - (self._BACK, Text.assemble(("βœ— ", "err"), "Back β€” discard changes")), - ] + return self._dirty_actions() return [(self._BACK, Text("Back"))] def render_body(self, width: int) -> list[str]: @@ -397,15 +418,9 @@ class ReorderScreen(Screen): self._index += 1 elif not self._grabbed and total: self._index = (self._index + 1) % total - elif action == "space" or (action == "text" and data == " "): - # The session delivers the spacebar as printable text; accept the normalized - # "space" action too for symmetry with the scroll screens. Grabbing only - # means anything on a list row. - if self._index < n: - self._grabbed = not self._grabbed elif action == "enter": if self._index < n: - self._grabbed = not self._grabbed # Enter grabs/drops, like Space + 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: diff --git a/tests/test_tui.py b/tests/test_tui.py index 1e9cd89..6314a7d 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -328,35 +328,23 @@ def test_scroll_screen_escape_resolves_none() -> None: def test_reorder_apply_row_commits_new_order() -> None: - """Space grabs/drops (spacebar arrives as printable text); Enter on Apply commits.""" + """Enter grabs and drops a list row; Enter on Apply commits the rearrangement.""" screen = ReorderScreen("order", ["a", "b", "c"]) - screen.handle("text", " ") # grab "a" - assert screen._grabbed and "Space drop" in screen.footer_hint + screen.handle("enter") # grab "a" + assert screen._grabbed and "Enter drop" in screen.footer_hint screen.handle("down") # carry it past "b" - screen.handle("space") # drop (the normalized action form also works) - assert not screen._grabbed and "Space grab" in screen.footer_hint + screen.handle("enter") # drop + assert not screen._grabbed and "Enter grab" in screen.footer_hint screen.handle("down") # cursor from position 1 past "c"… screen.handle("down") # …onto the Apply row assert _run(screen, "enter") == [1, 0, 2] -def test_reorder_enter_grabs_and_drops_a_list_row() -> None: - """Enter on a list row grabs/drops just like Space, so either key drives a move.""" - screen = ReorderScreen("order", ["a", "b", "c"]) - screen.handle("down") # cursor to "b" - screen.handle("enter") # grab - assert screen._grabbed - screen.handle("down") # carry to the end - screen.handle("enter") # drop - assert not screen._grabbed - assert screen._order == [0, 2, 1] - - def test_reorder_actions_follow_the_dirty_state() -> None: """Untouched order offers a lone Back; a change swaps in Apply plus discard-Back.""" screen = ReorderScreen("order", ["a", "b", "c"]) assert [key for key, _ in screen._actions()] == ["back"] - screen.handle("space") + screen.handle("enter") screen.handle("down") # dirty now assert [key for key, _ in screen._actions()] == ["apply", "back"] screen.handle("up") # moved back home β€” clean again @@ -366,14 +354,14 @@ def test_reorder_actions_follow_the_dirty_state() -> None: def test_reorder_back_row_and_escape_cancel_discarding_moves() -> None: """Enter on Back β€” like Esc β€” resolves the sentinel, so the caller keeps the old order.""" screen = ReorderScreen("order", ["a", "b", "c"]) - screen.handle("text", " ") + screen.handle("enter") screen.handle("down") assert _run(screen, "escape") is CANCEL screen = ReorderScreen("order", ["a", "b", "c"]) - screen.handle("space") + screen.handle("enter") screen.handle("down") - screen.handle("space") # drop at position 1; the order is dirty + screen.handle("enter") # drop at position 1; the order is dirty for _ in range(3): # cursor 1 β†’ 2 β†’ Apply β†’ Back screen.handle("down") assert _run(screen, "enter") is CANCEL @@ -384,21 +372,32 @@ def test_reorder_cursor_wraps_through_the_action_rows() -> None: screen = ReorderScreen("order", ["a", "b"]) screen.handle("up") # wrap: onto the lone Back row assert screen._index == 2 - screen.handle("space") # grabbing means nothing on an action row - assert not screen._grabbed screen.handle("down") # wrap forward to the first list row assert screen._index == 0 -def test_reorder_ignores_other_typed_characters() -> None: - """Printable keys other than the spacebar neither grab a row nor resolve the screen.""" +def test_reorder_ignores_typed_characters_including_space() -> None: + """Typed characters β€” the spacebar included β€” neither grab a row nor resolve the screen.""" screen = ReorderScreen("order", ["a", "b"]) screen.future = _Fut() screen.handle("text", "x") + screen.handle("text", " ") # Space no longer grabs; Enter is the grab key + screen.handle("space") assert not screen._grabbed assert not screen.future.done() +def test_reorder_dialog_width_is_stable_across_states() -> None: + """The natural width fits the widest of rows, hints, and dirty actions β€” and never + changes as the user grabs a row or dirties the order, so the popup doesn't resize.""" + screen = ReorderScreen("order", ["πŸ”’ alpha", "οΌƒ b"]) + w = screen.dialog_width + screen.handle("enter") # grab + assert screen.dialog_width == w + screen.handle("down") # dirty: Apply/discard rows appear + assert screen.dialog_width == w + + # --- frame -------------------------------------------------------------------