feat(tui): add reordering to filter_multiselect
lint / lint (push) Failing after 2m1s
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 19s

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
parent b1c068c34d
commit 32bd877e82
2 changed files with 208 additions and 37 deletions
+115 -36
View File
@@ -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])
+93 -1
View File
@@ -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()