Skip to content

Commit 7236cbd

Browse files
takemi-ohamaclaude
andcommitted
feat(tui): カテゴリメニューを最下部固定の横並びメニューバーへ変更
devbase list トップ画面のカテゴリ (環境変数 / プラグイン / スナップショット / ステータス) を一覧末尾の行から、画面最下部に常設する横並びメニューバーへ 移す。一覧はプロジェクトのみになり、カテゴリは ←→ で項目間を移動して Enter で決定する。 - menu.select_with_menubar() 新設: questionary select のレイアウト下に 区切り線 + 横並びバー (FormattedTextControl) を組み込む。フォーカス中の 項目は bold reverse でハイライト - キー操作: ←→ でバーへ入り巡回 (端で循環)、バー上の ↑↓ で一覧へ復帰、 Enter はフォーカス位置で確定。questionary は ←→ を明示バインドしない (Keys.Any の catch-all のみ) ため後付けマージで安全に割り当てられる - プロジェクト 0 件時は「(プロジェクトがありません)」のプレースホルダ行を 置く (questionary select は選択可能 choice 0 件だと構築できない)。 Enter しても何も起動せず再表示 - _add_key_binding を _merge_app_bindings (KeyBindings 一括マージ) へ リファクタリングし、バー用バインド群の後付けに再利用 - 単体テスト 7 件 + 実 TTY (pty) でのキー操作・バー描画テストを追加 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4e40925 commit 7236cbd

5 files changed

Lines changed: 383 additions & 28 deletions

File tree

lib/devbase/tui/app.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
``run(devbase_root, args)`` が ``cmd_project_list`` から呼ばれる入口。
44
利用頻度が最も高い **プロジェクト一覧を起動直後のトップ画面** とし、
55
プロジェクト選択 → (running なら操作サブメニュー / それ以外は up) を最短経路にする。
6-
env / plugin / snapshot / status は一覧の末尾に並ぶカテゴリ項目から遷移する。
6+
env / plugin / snapshot / status は画面最下部に横並びで常設するメニューバー
7+
(``menu.select_with_menubar``) から遷移する (←→ で項目間を移動、Enter で決定)。
78
89
後方互換 (plan 3.2):
910
- ``--no-interactive`` / ``--plain`` (interactive=False) と非 TTY は従来どおり一覧
@@ -87,20 +88,33 @@ def _pause_for_review() -> bool:
8788
return True
8889

8990

91+
# プロジェクト 0 件時に一覧へ置くプレースホルダの value 番兵。questionary の
92+
# select は選択可能な choice が 1 件も無いと構築できないため、案内行を 1 件
93+
# 置き、Enter されたらトップを再表示する (rows index の int と区別する)。
94+
_NO_PROJECTS = object()
95+
96+
9097
def _select_top(rows: list[dict]):
91-
"""トップ画面: プロジェクト一覧 + カテゴリ項目から 1 件選ばせる。
98+
"""トップ画面: プロジェクト一覧 + 最下部の常設カテゴリメニューから 1 件選ばせる。
99+
100+
カテゴリ (env/plugin/snapshot/status) は一覧の行ではなく、画面最下部に
101+
横並びで常設するメニューバーに置く (←→ で項目間を移動、Enter で決定)。
92102
93103
戻り値: rows の index (``int`` = プロジェクト選択) / カテゴリ key (``str``) /
94-
``None`` (Esc・Ctrl-C → 終了)。プロジェクトとカテゴリは値の型で判別する。
95-
件数が多いため文字入力での絞り込み (search=True) を有効にする。
104+
``_NO_PROJECTS`` (プレースホルダ選択 = 再表示) / ``None`` (Esc・Ctrl-C → 終了)。
105+
プロジェクトとカテゴリは値の型で判別する。件数が多いため文字入力での
106+
絞り込み (search) を有効にする。
96107
"""
97-
entries = _build_menu_entries(rows, colorize=_STATUS_COLOR)
98-
choices: list[tuple[str, object]] = [(entry, i) for i, entry in enumerate(entries)]
99-
choices += [(f"{label} ({key})", key) for key, label in TOP_CATEGORIES]
100-
return menu.select(
108+
if rows:
109+
entries = _build_menu_entries(rows, colorize=_STATUS_COLOR)
110+
choices: list[tuple[str, object]] = [(e, i) for i, e in enumerate(entries)]
111+
else:
112+
# _build_menu_entries は 0 件を想定しない (max() が落ちる) ため迂回する。
113+
choices = [("(プロジェクトがありません)", _NO_PROJECTS)]
114+
return menu.select_with_menubar(
101115
"プロジェクトまたは操作を選択 "
102-
"(↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc・Ctrl-C 終了):",
103-
choices, back=False, search=True)
116+
"(↑↓ 移動 / 名前で絞り込み / ←→ 下部メニュー / Enter 決定 / Esc・Ctrl-C 終了):",
117+
choices, [(label, key) for key, label in TOP_CATEGORIES])
104118

105119

106120
def _top_menu_loop(devbase_root: Path) -> int:
@@ -126,6 +140,9 @@ def _top_menu_loop(devbase_root: Path) -> int:
126140
# トップで Esc / Ctrl-C → これまでの実行 rc を返して終了
127141
logger.info("中止しました。")
128142
return last_rc
143+
if sel is _NO_PROJECTS:
144+
# プロジェクト 0 件のプレースホルダ行 → 何もせず再表示
145+
continue
129146

130147
if isinstance(sel, str):
131148
result = _route(sel, devbase_root)

lib/devbase/tui/menu.py

Lines changed: 127 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,23 +52,31 @@
5252
# キーバインド (Esc / ←)
5353
# ---------------------------------------------------------------------------
5454

55-
def _add_key_binding(question, key, handler):
56-
"""生成済み ``Question.application`` にキーハンドラを後付けする共通処理
55+
def _merge_app_bindings(question, kb):
56+
"""生成済み ``Question.application`` に ``KeyBindings`` を後付けマージする
5757
5858
select の application は素の ``KeyBindings`` を持つが、confirm/text/path は
5959
``merge_key_bindings`` 済みの ``_MergedKeyBindings`` (``add`` を持たない) の
60-
ため、直接 ``add`` せず新しい ``KeyBindings`` を作って再マージする。
60+
ため、直接 ``add`` せず再マージする。後からマージしたバインドは同一キーで
61+
既存より優先される (prompt_toolkit は ``matches[-1]`` を呼ぶ)。
6162
"""
62-
from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
63+
from prompt_toolkit.key_binding import merge_key_bindings
6364

64-
kb = KeyBindings()
65-
kb.add(key)(handler)
6665
existing = question.application.key_bindings
6766
question.application.key_bindings = (
6867
merge_key_bindings([existing, kb]) if existing is not None else kb)
6968
return question
7069

7170

71+
def _add_key_binding(question, key, handler):
72+
"""生成済み ``Question.application`` にキーハンドラを 1 つ後付けする共通処理。"""
73+
from prompt_toolkit.key_binding import KeyBindings
74+
75+
kb = KeyBindings()
76+
kb.add(key)(handler)
77+
return _merge_app_bindings(question, kb)
78+
79+
7280
def _add_escape_binding(question, handler):
7381
"""questionary の question に Esc 単独押下のハンドラを後付けする共通処理。
7482
@@ -220,6 +228,119 @@ def select(message: str, choices, *, back: bool = False, search: bool = False):
220228
return _ask_erased(question)
221229

222230

231+
# ---------------------------------------------------------------------------
232+
# 最下部メニューバー付き select (トップ画面用)
233+
# ---------------------------------------------------------------------------
234+
235+
def _build_menubar_question(message: str, choices, menu_items):
236+
"""一覧 select の最下部に横並びメニューバーを組み込んだ question を構築する。
237+
238+
``select_with_menubar`` の構築部分。テストが実 TTY なしでキーバインドと
239+
バー描画を検証できるよう、ask せずに ``(question, focus)`` を返す。
240+
``focus["tab"]`` が ``None`` なら一覧、``int`` ならバーの該当項目に
241+
フォーカスがある。
242+
"""
243+
from prompt_toolkit.filters import Condition
244+
from prompt_toolkit.key_binding import KeyBindings
245+
from prompt_toolkit.keys import Keys
246+
from prompt_toolkit.layout import HSplit, Layout, Window
247+
from prompt_toolkit.layout.controls import FormattedTextControl
248+
249+
norm = [
250+
c if isinstance(c, questionary.Choice)
251+
else questionary.Choice(title=c[0], value=c[1])
252+
for c in choices
253+
]
254+
question = questionary.select(
255+
message,
256+
choices=norm,
257+
use_arrow_keys=True,
258+
use_jk_keys=False,
259+
use_search_filter=True,
260+
use_shortcuts=False,
261+
)
262+
263+
count = len(menu_items)
264+
focus: dict = {"tab": None}
265+
tab_focused = Condition(lambda: focus["tab"] is not None)
266+
267+
kb = KeyBindings()
268+
269+
# questionary select は ←/→ を明示バインドしない (Keys.Any の catch-all のみ)
270+
# ため、後付けマージで安全に奪える。search 絞り込みの入力カーソル移動は
271+
# 失われるが、絞り込みは短文入力なので追記・Backspace で十分。
272+
@kb.add(Keys.Right, eager=True)
273+
def _tab_next(event):
274+
focus["tab"] = 0 if focus["tab"] is None else (focus["tab"] + 1) % count
275+
event.app.invalidate()
276+
277+
@kb.add(Keys.Left, eager=True)
278+
def _tab_prev(event):
279+
focus["tab"] = (count - 1 if focus["tab"] is None
280+
else (focus["tab"] - 1) % count)
281+
event.app.invalidate()
282+
283+
# バーから ↑/↓ で一覧へフォーカスを戻す (一覧内の移動は questionary 既定)。
284+
@kb.add(Keys.Up, filter=tab_focused, eager=True)
285+
@kb.add(Keys.Down, filter=tab_focused, eager=True)
286+
def _tab_leave(event):
287+
focus["tab"] = None
288+
event.app.invalidate()
289+
290+
# バーにフォーカスがあるときの Enter はバー項目の value で確定する
291+
# (一覧フォーカス時は questionary 既定の Enter が choice value を返す)。
292+
@kb.add(Keys.ControlM, filter=tab_focused, eager=True)
293+
def _tab_accept(event):
294+
event.app.exit(result=menu_items[focus["tab"]][1])
295+
296+
def _bar_fragments():
297+
frags = [("", " ")]
298+
for i, (label, _value) in enumerate(menu_items):
299+
style = "bold reverse" if focus["tab"] == i else "class:text"
300+
frags.append((style, f" {label} "))
301+
if i < count - 1:
302+
frags.append(("", " "))
303+
return frags
304+
305+
app = question.application
306+
bar = HSplit([
307+
Window(height=1, char="─", style="class:separator"),
308+
Window(FormattedTextControl(_bar_fragments), height=1,
309+
dont_extend_height=True),
310+
])
311+
# 既存レイアウト全体の下にバーを常設する (一覧の件数・絞り込みに関わらず
312+
# プロンプト描画の最下部に固定される)。フォーカス可能要素は一覧のみなので
313+
# Layout の既定フォーカス解決に任せる。
314+
app.layout = Layout(HSplit([app.layout.container, bar]))
315+
_merge_app_bindings(question, kb)
316+
return question, focus
317+
318+
319+
def select_with_menubar(message: str, choices, menu_items):
320+
"""最下部に常設メニューバーを付けた選択メニュー (トップ画面用)。
321+
322+
Parameters
323+
----------
324+
message: プロンプト文言。
325+
choices: 一覧部分の選択肢 (``select`` と同じ形式)。
326+
menu_items: バー項目の ``(label, value)`` リスト。
327+
328+
キー操作:
329+
- ↑↓ / 文字入力: 一覧の移動・絞り込み (questionary 既定)
330+
- ← →: バーへフォーカスを移して項目間を巡回 (← は末尾から、→ は先頭から)
331+
- ↑↓ (バー上): 一覧へフォーカスを戻す
332+
- Enter: フォーカス位置で確定
333+
- Esc / Ctrl-C: 中止 (トップ画面専用のため戻り先なし)
334+
335+
Returns
336+
-------
337+
一覧の choice value / バー項目の value / ``None`` (Esc・Ctrl-C 中止)。
338+
テストではこの関数自体を monkeypatch して questionary の実起動を避ける。
339+
"""
340+
question, _focus = _build_menubar_question(message, choices, menu_items)
341+
return _ask_erased(with_escape_cancel(question))
342+
343+
223344
# ---------------------------------------------------------------------------
224345
# 引数収集ヘルパ (PR2 以降の各カテゴリ操作が CLI 相当の属性値を集めるのに使う)
225346
# ---------------------------------------------------------------------------

tests/cli/tui/test_app.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -155,23 +155,51 @@ def _patch_loop(monkeypatch, selects, rows=None):
155155
return pauses
156156

157157

158-
def test_select_top_appends_categories_after_projects(monkeypatch):
159-
"""トップ一覧はプロジェクト行が先頭、カテゴリ項目が末尾に並ぶ。"""
158+
def test_select_top_projects_in_list_categories_in_menubar(monkeypatch):
159+
"""トップは一覧にプロジェクト行のみ、カテゴリは最下部メニューバーに並ぶ。"""
160160
captured = {}
161161

162-
def fake_select(message, choices, *, back, search):
163-
captured.update(back=back, search=search,
164-
titles=[c[0] for c in choices],
165-
values=[c[1] for c in choices])
162+
def fake_menubar(message, choices, menu_items):
163+
captured.update(values=[c[1] for c in choices],
164+
menu_labels=[m[0] for m in menu_items],
165+
menu_values=[m[1] for m in menu_items])
166166
return 0
167167

168-
monkeypatch.setattr(menu, "select", fake_select)
168+
monkeypatch.setattr(menu, "select_with_menubar", fake_menubar)
169169
assert app._select_top(_ROWS) == 0
170-
assert captured["back"] is False, "トップは Esc=終了 (戻り先なし)"
171-
assert captured["search"] is True, "名前絞り込みを有効化"
172-
assert captured["values"][:2] == [0, 1], "プロジェクトは rows index"
173-
assert captured["values"][2:] == ["env", "plugin", "snapshot", "status"]
174-
assert captured["titles"][2] == "環境変数 (env)", "ラベル (key) 形式で表示"
170+
assert captured["values"] == [0, 1], "一覧はプロジェクトの rows index のみ"
171+
assert captured["menu_values"] == ["env", "plugin", "snapshot", "status"]
172+
assert captured["menu_labels"] == [
173+
"環境変数", "プラグイン", "スナップショット", "ステータス"]
174+
175+
176+
def test_select_top_empty_projects_uses_placeholder(monkeypatch):
177+
"""プロジェクト 0 件は選択不能エラーを避けるためプレースホルダ行を 1 件置く。
178+
179+
questionary の select は選択可能な choice が 0 件だと構築できない。
180+
"""
181+
captured = {}
182+
183+
def fake_menubar(message, choices, menu_items):
184+
captured.update(titles=[c[0] for c in choices],
185+
values=[c[1] for c in choices])
186+
return captured["values"][0]
187+
188+
monkeypatch.setattr(menu, "select_with_menubar", fake_menubar)
189+
assert app._select_top([]) is app._NO_PROJECTS
190+
assert captured["values"] == [app._NO_PROJECTS]
191+
assert "プロジェクトがありません" in captured["titles"][0]
192+
193+
194+
def test_top_loop_no_projects_placeholder_redisplays(monkeypatch, tmp_path):
195+
"""プレースホルダ行 (_NO_PROJECTS) を Enter しても何も起動せず再表示する。"""
196+
_patch_loop(monkeypatch, [app._NO_PROJECTS, None], rows=[])
197+
handled = []
198+
monkeypatch.setattr(actions_project, "handle_row",
199+
lambda root, row: handled.append(1) or 0)
200+
201+
assert app._top_menu_loop(tmp_path) == 0
202+
assert handled == [], "プレースホルダでは何も起動しない"
175203

176204

177205
def test_top_loop_project_selection_delegates_handle_row(monkeypatch, tmp_path):

0 commit comments

Comments
 (0)