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:
+115
-36
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user