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.
This commit is contained in:
2026-06-25 07:00:19 +00:00
committed by didericis
parent dc8978a309
commit 1ca00d8f30
2 changed files with 208 additions and 37 deletions
+115 -36
View File
@@ -306,6 +306,10 @@ def _multiselect_loop(
query = "" query = ""
cursor = 0 cursor = 0
selected: list[str] = [s for s in initial if s in items] 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: while True:
filtered = _filter_items(items, query) filtered = _filter_items(items, query)
@@ -315,8 +319,19 @@ def _multiselect_loop(
elif cursor >= len(filtered): elif cursor >= len(filtered):
cursor = len(filtered) - 1 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: 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: except curses.error:
return None return None
@@ -331,31 +346,70 @@ def _multiselect_loop(
if key == _KEY_CTRL_D: if key == _KEY_CTRL_D:
return list(selected) return list(selected)
if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r"), _KEY_SPACE): # Tab toggles between filter and order focus.
if filtered: if key == ord("\t"):
item = filtered[cursor] if focus == "filter" and selected:
if item in selected: focus = "order"
selected.remove(item) order_cursor = 0
else: else:
selected.append(item) focus = "filter"
continue
elif key in (curses.KEY_UP, ord("k")): if focus == "filter":
if cursor > 0: if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r"), _KEY_SPACE):
cursor -= 1 if filtered:
item = filtered[cursor]
if item in selected:
selected.remove(item)
else:
selected.append(item)
elif key in (curses.KEY_DOWN, ord("j")): elif key in (curses.KEY_UP, ord("k")):
if cursor < len(filtered) - 1: if cursor > 0:
cursor += 1 cursor -= 1
elif key in (curses.KEY_BACKSPACE, _KEY_BACKSPACE_WIN, 127): elif key in (curses.KEY_DOWN, ord("j")):
query = query[:-1] if cursor < len(filtered) - 1:
new_filtered = _filter_items(items, query) cursor += 1
if cursor >= len(new_filtered):
cursor = max(0, len(new_filtered) - 1)
elif 32 <= key <= 126 and key != _KEY_SPACE: elif key in (curses.KEY_BACKSPACE, _KEY_BACKSPACE_WIN, 127):
query += chr(key) query = query[:-1]
cursor = 0 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( def _render_multiselect(
@@ -366,6 +420,8 @@ def _render_multiselect(
query: str, query: str,
title: str, title: str,
selected: list[str], selected: list[str],
focus: str = "filter",
order_cursor: int = 0,
) -> None: ) -> None:
screen.erase() screen.erase()
rows, cols = screen.getmaxyx() rows, cols = screen.getmaxyx()
@@ -374,56 +430,79 @@ def _render_multiselect(
if rows < min_rows: if rows < min_rows:
raise curses.error("terminal too small") raise curses.error("terminal too small")
sep = "" * min(cols - 1, 40)
row = 0 row = 0
if title and row < rows - 1: if title and row < rows - 1:
_addstr_safe(screen, row, 0, title[:cols - 1], curses.A_BOLD) _addstr_safe(screen, row, 0, title[:cols - 1], curses.A_BOLD)
row += 1 row += 1
# Filter line — dim when focus is on the order panel.
filter_label = f"Filter: {query}" 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: 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 row += 1
sep = "" * min(cols - 1, 40)
if row < rows - 1: if row < rows - 1:
_addstr_safe(screen, row, 0, sep) _addstr_safe(screen, row, 0, sep)
row += 1 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_start = row
list_rows = rows - list_start - 4 list_rows = rows - list_start - bottom_reserved
if list_rows < 1: if list_rows < 1:
return list_rows = 1
selected_set = set(selected) selected_set = set(selected)
filter_dim = focus == "order"
scroll = max(0, cursor - list_rows + 1) scroll = max(0, cursor - list_rows + 1)
visible = filtered[scroll: scroll + list_rows] visible = filtered[scroll: scroll + list_rows]
for idx, item in enumerate(visible): for idx, item in enumerate(visible):
abs_idx = scroll + idx abs_idx = scroll + idx
mark = "[*]" if item in selected_set else "[ ]" 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] line = (prefix + mark + " " + item)[:cols - 1]
attr = curses.A_REVERSE if abs_idx == cursor else curses.A_NORMAL item_attr = curses.A_DIM if filter_dim else (
if row < rows - 4: curses.A_REVERSE if abs_idx == cursor else curses.A_NORMAL
_addstr_safe(screen, row, 0, line, attr) )
if row < rows - bottom_reserved:
_addstr_safe(screen, row, 0, line, item_attr)
row += 1 row += 1
if row < rows - 3: # Separator before the order panel.
if row < rows - (order_rows + 2):
_addstr_safe(screen, row, 0, sep) _addstr_safe(screen, row, 0, sep)
row += 1 row += 1
selected_summary = "Selected (in order): " + (", ".join(selected) if selected else "(none)") # Order panel.
if row < rows - 2: order_scroll = max(0, order_cursor - order_rows + 1)
_addstr_safe(screen, row, 0, selected_summary[:cols - 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 row += 1
if row < rows - 1: if row < rows - 1:
_addstr_safe(screen, row, 0, sep) _addstr_safe(screen, row, 0, sep)
row += 1 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: if row < rows:
_addstr_safe(screen, min(rows - 1, row), 0, help_line[:cols - 1]) _addstr_safe(screen, min(rows - 1, row), 0, help_line[:cols - 1])
+93 -1
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
import unittest 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): class TestFilterItems(unittest.TestCase):
@@ -57,5 +57,97 @@ class TestFilterMultiselectEmptyItems(unittest.TestCase):
self.assertIsNone(result) 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__": if __name__ == "__main__":
unittest.main() unittest.main()