From 6283542a7baa9bdad4322e01864f051a4bf0d10c Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 25 Jun 2026 07:00:19 +0000 Subject: [PATCH] feat(tui): add reordering to filter_multiselect Tab switches focus to the selected-order panel; K/J shift the highlighted item up/down; Space/Enter removes it. The filter list dims while the order panel is active. Help line updates per focus mode. --- bot_bottle/cli/tui.py | 151 ++++++++++++++++++++++++++++--------- tests/unit/test_cli_tui.py | 94 ++++++++++++++++++++++- 2 files changed, 208 insertions(+), 37 deletions(-) diff --git a/bot_bottle/cli/tui.py b/bot_bottle/cli/tui.py index 08f14b2..d45f4ca 100644 --- a/bot_bottle/cli/tui.py +++ b/bot_bottle/cli/tui.py @@ -306,6 +306,10 @@ def _multiselect_loop( query = "" cursor = 0 selected: list[str] = [s for s in initial if s in items] + # focus = "filter": navigate + toggle items in the filterable list + # focus = "order": navigate + reorder items in the selected list + focus = "filter" + order_cursor = 0 while True: filtered = _filter_items(items, query) @@ -315,8 +319,19 @@ def _multiselect_loop( elif cursor >= len(filtered): cursor = len(filtered) - 1 + if not selected: + order_cursor = 0 + if focus == "order": + focus = "filter" + elif order_cursor >= len(selected): + order_cursor = len(selected) - 1 + try: - _render_multiselect(screen, filtered, cursor, query=query, title=title, selected=selected) + _render_multiselect( + screen, filtered, cursor, + query=query, title=title, selected=selected, + focus=focus, order_cursor=order_cursor, + ) except curses.error: return None @@ -331,31 +346,70 @@ def _multiselect_loop( if key == _KEY_CTRL_D: return list(selected) - if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r"), _KEY_SPACE): - if filtered: - item = filtered[cursor] - if item in selected: - selected.remove(item) - else: - selected.append(item) + # Tab toggles between filter and order focus. + if key == ord("\t"): + if focus == "filter" and selected: + focus = "order" + order_cursor = 0 + else: + focus = "filter" + continue - elif key in (curses.KEY_UP, ord("k")): - if cursor > 0: - cursor -= 1 + if focus == "filter": + if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r"), _KEY_SPACE): + if filtered: + item = filtered[cursor] + if item in selected: + selected.remove(item) + else: + selected.append(item) - elif key in (curses.KEY_DOWN, ord("j")): - if cursor < len(filtered) - 1: - cursor += 1 + elif key in (curses.KEY_UP, ord("k")): + if cursor > 0: + cursor -= 1 - elif key in (curses.KEY_BACKSPACE, _KEY_BACKSPACE_WIN, 127): - query = query[:-1] - new_filtered = _filter_items(items, query) - if cursor >= len(new_filtered): - cursor = max(0, len(new_filtered) - 1) + elif key in (curses.KEY_DOWN, ord("j")): + if cursor < len(filtered) - 1: + cursor += 1 - elif 32 <= key <= 126 and key != _KEY_SPACE: - query += chr(key) - cursor = 0 + elif key in (curses.KEY_BACKSPACE, _KEY_BACKSPACE_WIN, 127): + query = query[:-1] + new_filtered = _filter_items(items, query) + if cursor >= len(new_filtered): + cursor = max(0, len(new_filtered) - 1) + + elif 32 <= key <= 126 and key != _KEY_SPACE: + query += chr(key) + cursor = 0 + + else: # focus == "order" + if key in (curses.KEY_UP, ord("k")): + if order_cursor > 0: + order_cursor -= 1 + + elif key in (curses.KEY_DOWN, ord("j")): + if order_cursor < len(selected) - 1: + order_cursor += 1 + + elif key == ord("K"): + # Move selected item up (earlier in order). + if order_cursor > 0: + i = order_cursor + selected[i - 1], selected[i] = selected[i], selected[i - 1] + order_cursor -= 1 + + elif key == ord("J"): + # Move selected item down (later in order). + if order_cursor < len(selected) - 1: + i = order_cursor + selected[i], selected[i + 1] = selected[i + 1], selected[i] + order_cursor += 1 + + elif key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r"), _KEY_SPACE): + # Remove item from selection while in order mode. + del selected[order_cursor] + if order_cursor >= len(selected) and order_cursor > 0: + order_cursor -= 1 def _render_multiselect( @@ -366,6 +420,8 @@ def _render_multiselect( query: str, title: str, selected: list[str], + focus: str = "filter", + order_cursor: int = 0, ) -> None: screen.erase() rows, cols = screen.getmaxyx() @@ -374,56 +430,79 @@ def _render_multiselect( if rows < min_rows: raise curses.error("terminal too small") + sep = "─" * min(cols - 1, 40) row = 0 if title and row < rows - 1: _addstr_safe(screen, row, 0, title[:cols - 1], curses.A_BOLD) row += 1 + # Filter line — dim when focus is on the order panel. filter_label = f"Filter: {query}" + filter_hint = " [Tab: reorder]" if focus == "filter" and selected else "" + filter_attr = curses.A_DIM if focus == "order" else curses.A_NORMAL if row < rows - 1: - _addstr_safe(screen, row, 0, filter_label[:cols - 1]) + _addstr_safe(screen, row, 0, (filter_label + filter_hint)[:cols - 1], filter_attr) row += 1 - sep = "─" * min(cols - 1, 40) if row < rows - 1: _addstr_safe(screen, row, 0, sep) row += 1 - # Reserve rows for: sep + selected-line + sep + help-line = 4 + # Compute how many rows the bottom order panel needs. + # Cap the visible selected list to keep the filter list legible. + order_rows = min(len(selected), max(1, (rows - row) // 3)) if selected else 0 + # Bottom reserved: sep + order_rows + sep + help = order_rows + 3 + bottom_reserved = order_rows + 3 + list_start = row - list_rows = rows - list_start - 4 + list_rows = rows - list_start - bottom_reserved if list_rows < 1: - return + list_rows = 1 selected_set = set(selected) + filter_dim = focus == "order" scroll = max(0, cursor - list_rows + 1) visible = filtered[scroll: scroll + list_rows] for idx, item in enumerate(visible): abs_idx = scroll + idx mark = "[*]" if item in selected_set else "[ ]" - prefix = "> " if abs_idx == cursor else " " + prefix = "> " if (abs_idx == cursor and focus == "filter") else " " line = (prefix + mark + " " + item)[:cols - 1] - attr = curses.A_REVERSE if abs_idx == cursor else curses.A_NORMAL - if row < rows - 4: - _addstr_safe(screen, row, 0, line, attr) + item_attr = curses.A_DIM if filter_dim else ( + curses.A_REVERSE if abs_idx == cursor else curses.A_NORMAL + ) + if row < rows - bottom_reserved: + _addstr_safe(screen, row, 0, line, item_attr) row += 1 - if row < rows - 3: + # Separator before the order panel. + if row < rows - (order_rows + 2): _addstr_safe(screen, row, 0, sep) row += 1 - selected_summary = "Selected (in order): " + (", ".join(selected) if selected else "(none)") - if row < rows - 2: - _addstr_safe(screen, row, 0, selected_summary[:cols - 1]) + # Order panel. + order_scroll = max(0, order_cursor - order_rows + 1) + order_visible = selected[order_scroll: order_scroll + order_rows] + for idx, item in enumerate(order_visible): + abs_idx = order_scroll + idx + is_active = focus == "order" and abs_idx == order_cursor + prefix = "> " if is_active else " " + line = (prefix + item)[:cols - 1] + attr = curses.A_REVERSE if is_active else curses.A_NORMAL + if row < rows - 2: + _addstr_safe(screen, row, 0, line, attr) row += 1 if row < rows - 1: _addstr_safe(screen, row, 0, sep) row += 1 - help_line = "[↑↓/jk] move [Space/Enter] toggle [Ctrl-D] done [Esc/q] cancel" + if focus == "filter": + help_line = "[↑↓/jk] move [Space/Enter] toggle [Tab] reorder [Ctrl-D] done [Esc/q] cancel" + else: + help_line = "[↑↓/jk] cursor [K/J] reorder [Space/Enter] remove [Tab] back [Ctrl-D] done" if row < rows: _addstr_safe(screen, min(rows - 1, row), 0, help_line[:cols - 1]) diff --git a/tests/unit/test_cli_tui.py b/tests/unit/test_cli_tui.py index f27fb89..02a51ec 100644 --- a/tests/unit/test_cli_tui.py +++ b/tests/unit/test_cli_tui.py @@ -9,7 +9,7 @@ from __future__ import annotations import unittest -from bot_bottle.cli.tui import _filter_items, filter_multiselect, filter_select +from bot_bottle.cli.tui import _filter_items, _multiselect_loop, filter_multiselect, filter_select class TestFilterItems(unittest.TestCase): @@ -57,5 +57,97 @@ class TestFilterMultiselectEmptyItems(unittest.TestCase): self.assertIsNone(result) +class TestMultiselectLoopReordering(unittest.TestCase): + """Exercise _multiselect_loop key handling without a real curses terminal. + + We drive the loop via a fake screen that feeds a pre-recorded key sequence + and records what was drawn — we only need the return value, so the fake + screen's getch() raises StopIteration after the key list is exhausted, and + the loop is expected to return before that via Ctrl-D. + """ + + def _run(self, keys: list[int], items: list[str], initial: list[str]) -> Optional[list[str]]: + """Run _multiselect_loop with a synthetic screen feeding `keys`.""" + import curses as _curses + + key_iter = iter(keys) + + class FakeScreen: + def erase(self): pass + def getmaxyx(self): return (40, 80) + def refresh(self): pass + def getch(self): + return next(key_iter) + def addstr(self, *a, **kw): pass + def keypad(self, *a): pass + + return _multiselect_loop(FakeScreen(), items, title="", initial=initial) # type: ignore[arg-type] + + def test_ctrl_d_confirms_initial_selection(self): + result = self._run([_KEY_CTRL_D], ["a", "b", "c"], ["a", "b"]) + self.assertEqual(["a", "b"], result) + + def test_esc_cancels(self): + result = self._run([_KEY_ESC], ["a", "b"], ["a"]) + self.assertIsNone(result) + + def test_tab_then_K_moves_item_up(self): + # Start: selected = ["a", "b", "c"] + # Tab → order mode (order_cursor=0 on "a") + # ↓ → order_cursor=1 (on "b") + # K → swap b and a → ["b", "a", "c"], order_cursor=0 + # Ctrl-D → confirm + DOWN = ord("j") + result = self._run( + [ord("\t"), DOWN, ord("K"), _KEY_CTRL_D], + ["a", "b", "c"], + ["a", "b", "c"], + ) + self.assertEqual(["b", "a", "c"], result) + + def test_tab_then_J_moves_item_down(self): + # selected = ["a", "b", "c"], focus order, cursor=0 + # J → swap a and b → ["b", "a", "c"], cursor=1 + # Ctrl-D → confirm + result = self._run( + [ord("\t"), ord("J"), _KEY_CTRL_D], + ["a", "b", "c"], + ["a", "b", "c"], + ) + self.assertEqual(["b", "a", "c"], result) + + def test_K_at_top_is_no_op(self): + # cursor already at 0, K should not change order + result = self._run( + [ord("\t"), ord("K"), _KEY_CTRL_D], + ["a", "b"], + ["a", "b"], + ) + self.assertEqual(["a", "b"], result) + + def test_J_at_bottom_is_no_op(self): + DOWN = ord("j") + result = self._run( + [ord("\t"), DOWN, ord("J"), _KEY_CTRL_D], + ["a", "b"], + ["a", "b"], + ) + self.assertEqual(["a", "b"], result) + + def test_tab_back_to_filter_then_confirm(self): + # Tab → order, Tab → filter, Ctrl-D confirms unchanged + result = self._run( + [ord("\t"), ord("\t"), _KEY_CTRL_D], + ["a", "b"], + ["a", "b"], + ) + self.assertEqual(["a", "b"], result) + + +from typing import Optional +_KEY_ESC = 27 +_KEY_CTRL_D = 4 + + if __name__ == "__main__": unittest.main()