diff --git a/lib/devbase/tui/app.py b/lib/devbase/tui/app.py index e7d5955..038671d 100644 --- a/lib/devbase/tui/app.py +++ b/lib/devbase/tui/app.py @@ -3,7 +3,8 @@ ``run(devbase_root, args)`` が ``cmd_project_list`` から呼ばれる入口。 利用頻度が最も高い **プロジェクト一覧を起動直後のトップ画面** とし、 プロジェクト選択 → (running なら操作サブメニュー / それ以外は up) を最短経路にする。 -env / plugin / snapshot / status は一覧の末尾に並ぶカテゴリ項目から遷移する。 +env / plugin / snapshot / status は画面最下部に横並びで常設するメニューバー +(``menu.select_with_menubar``) から遷移する (←→ で項目間を移動、Enter で決定)。 後方互換 (plan 3.2): - ``--no-interactive`` / ``--plain`` (interactive=False) と非 TTY は従来どおり一覧 @@ -87,20 +88,33 @@ def _pause_for_review() -> bool: return True +# プロジェクト 0 件時に一覧へ置くプレースホルダの value 番兵。questionary の +# select は選択可能な choice が 1 件も無いと構築できないため、案内行を 1 件 +# 置き、Enter されたらトップを再表示する (rows index の int と区別する)。 +_NO_PROJECTS = object() + + def _select_top(rows: list[dict]): - """トップ画面: プロジェクト一覧 + カテゴリ項目から 1 件選ばせる。 + """トップ画面: プロジェクト一覧 + 最下部の常設カテゴリメニューから 1 件選ばせる。 + + カテゴリ (env/plugin/snapshot/status) は一覧の行ではなく、画面最下部に + 横並びで常設するメニューバーに置く (←→ で項目間を移動、Enter で決定)。 戻り値: rows の index (``int`` = プロジェクト選択) / カテゴリ key (``str``) / - ``None`` (Esc・Ctrl-C → 終了)。プロジェクトとカテゴリは値の型で判別する。 - 件数が多いため文字入力での絞り込み (search=True) を有効にする。 + ``_NO_PROJECTS`` (プレースホルダ選択 = 再表示) / ``None`` (Esc・Ctrl-C → 終了)。 + プロジェクトとカテゴリは値の型で判別する。件数が多いため文字入力での + 絞り込み (search) を有効にする。 """ - entries = _build_menu_entries(rows, colorize=_STATUS_COLOR) - choices: list[tuple[str, object]] = [(entry, i) for i, entry in enumerate(entries)] - choices += [(f"{label} ({key})", key) for key, label in TOP_CATEGORIES] - return menu.select( + if rows: + entries = _build_menu_entries(rows, colorize=_STATUS_COLOR) + choices: list[tuple[str, object]] = [(e, i) for i, e in enumerate(entries)] + else: + # _build_menu_entries は 0 件を想定しない (max() が落ちる) ため迂回する。 + choices = [("(プロジェクトがありません)", _NO_PROJECTS)] + return menu.select_with_menubar( "プロジェクトまたは操作を選択 " - "(↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc・Ctrl-C 終了):", - choices, back=False, search=True) + "(↑↓ 移動 / 名前で絞り込み / ←→ 下部メニュー / Enter 決定 / Esc・Ctrl-C 終了):", + choices, [(label, key) for key, label in TOP_CATEGORIES]) def _top_menu_loop(devbase_root: Path) -> int: @@ -126,6 +140,9 @@ def _top_menu_loop(devbase_root: Path) -> int: # トップで Esc / Ctrl-C → これまでの実行 rc を返して終了 logger.info("中止しました。") return last_rc + if sel is _NO_PROJECTS: + # プロジェクト 0 件のプレースホルダ行 → 何もせず再表示 + continue if isinstance(sel, str): result = _route(sel, devbase_root) diff --git a/lib/devbase/tui/menu.py b/lib/devbase/tui/menu.py index e4f5b2a..1e78988 100644 --- a/lib/devbase/tui/menu.py +++ b/lib/devbase/tui/menu.py @@ -52,23 +52,31 @@ # キーバインド (Esc / ←) # --------------------------------------------------------------------------- -def _add_key_binding(question, key, handler): - """生成済み ``Question.application`` にキーハンドラを後付けする共通処理。 +def _merge_app_bindings(question, kb): + """生成済み ``Question.application`` に ``KeyBindings`` を後付けマージする。 select の application は素の ``KeyBindings`` を持つが、confirm/text/path は ``merge_key_bindings`` 済みの ``_MergedKeyBindings`` (``add`` を持たない) の - ため、直接 ``add`` せず新しい ``KeyBindings`` を作って再マージする。 + ため、直接 ``add`` せず再マージする。後からマージしたバインドは同一キーで + 既存より優先される (prompt_toolkit は ``matches[-1]`` を呼ぶ)。 """ - from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings + from prompt_toolkit.key_binding import merge_key_bindings - kb = KeyBindings() - kb.add(key)(handler) existing = question.application.key_bindings question.application.key_bindings = ( merge_key_bindings([existing, kb]) if existing is not None else kb) return question +def _add_key_binding(question, key, handler): + """生成済み ``Question.application`` にキーハンドラを 1 つ後付けする共通処理。""" + from prompt_toolkit.key_binding import KeyBindings + + kb = KeyBindings() + kb.add(key)(handler) + return _merge_app_bindings(question, kb) + + def _add_escape_binding(question, handler): """questionary の question に Esc 単独押下のハンドラを後付けする共通処理。 @@ -220,6 +228,119 @@ def select(message: str, choices, *, back: bool = False, search: bool = False): return _ask_erased(question) +# --------------------------------------------------------------------------- +# 最下部メニューバー付き select (トップ画面用) +# --------------------------------------------------------------------------- + +def _build_menubar_question(message: str, choices, menu_items): + """一覧 select の最下部に横並びメニューバーを組み込んだ question を構築する。 + + ``select_with_menubar`` の構築部分。テストが実 TTY なしでキーバインドと + バー描画を検証できるよう、ask せずに ``(question, focus)`` を返す。 + ``focus["tab"]`` が ``None`` なら一覧、``int`` ならバーの該当項目に + フォーカスがある。 + """ + from prompt_toolkit.filters import Condition + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.keys import Keys + from prompt_toolkit.layout import HSplit, Layout, Window + from prompt_toolkit.layout.controls import FormattedTextControl + + norm = [ + c if isinstance(c, questionary.Choice) + else questionary.Choice(title=c[0], value=c[1]) + for c in choices + ] + question = questionary.select( + message, + choices=norm, + use_arrow_keys=True, + use_jk_keys=False, + use_search_filter=True, + use_shortcuts=False, + ) + + count = len(menu_items) + focus: dict = {"tab": None} + tab_focused = Condition(lambda: focus["tab"] is not None) + + kb = KeyBindings() + + # questionary select は ←/→ を明示バインドしない (Keys.Any の catch-all のみ) + # ため、後付けマージで安全に奪える。search 絞り込みの入力カーソル移動は + # 失われるが、絞り込みは短文入力なので追記・Backspace で十分。 + @kb.add(Keys.Right, eager=True) + def _tab_next(event): + focus["tab"] = 0 if focus["tab"] is None else (focus["tab"] + 1) % count + event.app.invalidate() + + @kb.add(Keys.Left, eager=True) + def _tab_prev(event): + focus["tab"] = (count - 1 if focus["tab"] is None + else (focus["tab"] - 1) % count) + event.app.invalidate() + + # バーから ↑/↓ で一覧へフォーカスを戻す (一覧内の移動は questionary 既定)。 + @kb.add(Keys.Up, filter=tab_focused, eager=True) + @kb.add(Keys.Down, filter=tab_focused, eager=True) + def _tab_leave(event): + focus["tab"] = None + event.app.invalidate() + + # バーにフォーカスがあるときの Enter はバー項目の value で確定する + # (一覧フォーカス時は questionary 既定の Enter が choice value を返す)。 + @kb.add(Keys.ControlM, filter=tab_focused, eager=True) + def _tab_accept(event): + event.app.exit(result=menu_items[focus["tab"]][1]) + + def _bar_fragments(): + frags = [("", " ")] + for i, (label, _value) in enumerate(menu_items): + style = "bold reverse" if focus["tab"] == i else "class:text" + frags.append((style, f" {label} ")) + if i < count - 1: + frags.append(("", " ")) + return frags + + app = question.application + bar = HSplit([ + Window(height=1, char="─", style="class:separator"), + Window(FormattedTextControl(_bar_fragments), height=1, + dont_extend_height=True), + ]) + # 既存レイアウト全体の下にバーを常設する (一覧の件数・絞り込みに関わらず + # プロンプト描画の最下部に固定される)。フォーカス可能要素は一覧のみなので + # Layout の既定フォーカス解決に任せる。 + app.layout = Layout(HSplit([app.layout.container, bar])) + _merge_app_bindings(question, kb) + return question, focus + + +def select_with_menubar(message: str, choices, menu_items): + """最下部に常設メニューバーを付けた選択メニュー (トップ画面用)。 + + Parameters + ---------- + message: プロンプト文言。 + choices: 一覧部分の選択肢 (``select`` と同じ形式)。 + menu_items: バー項目の ``(label, value)`` リスト。 + + キー操作: + - ↑↓ / 文字入力: 一覧の移動・絞り込み (questionary 既定) + - ← →: バーへフォーカスを移して項目間を巡回 (← は末尾から、→ は先頭から) + - ↑↓ (バー上): 一覧へフォーカスを戻す + - Enter: フォーカス位置で確定 + - Esc / Ctrl-C: 中止 (トップ画面専用のため戻り先なし) + + Returns + ------- + 一覧の choice value / バー項目の value / ``None`` (Esc・Ctrl-C 中止)。 + テストではこの関数自体を monkeypatch して questionary の実起動を避ける。 + """ + question, _focus = _build_menubar_question(message, choices, menu_items) + return _ask_erased(with_escape_cancel(question)) + + # --------------------------------------------------------------------------- # 引数収集ヘルパ (PR2 以降の各カテゴリ操作が CLI 相当の属性値を集めるのに使う) # --------------------------------------------------------------------------- diff --git a/tests/cli/tui/test_app.py b/tests/cli/tui/test_app.py index d91e2a1..25a6335 100644 --- a/tests/cli/tui/test_app.py +++ b/tests/cli/tui/test_app.py @@ -155,23 +155,51 @@ def _patch_loop(monkeypatch, selects, rows=None): return pauses -def test_select_top_appends_categories_after_projects(monkeypatch): - """トップ一覧はプロジェクト行が先頭、カテゴリ項目が末尾に並ぶ。""" +def test_select_top_projects_in_list_categories_in_menubar(monkeypatch): + """トップは一覧にプロジェクト行のみ、カテゴリは最下部メニューバーに並ぶ。""" captured = {} - def fake_select(message, choices, *, back, search): - captured.update(back=back, search=search, - titles=[c[0] for c in choices], - values=[c[1] for c in choices]) + def fake_menubar(message, choices, menu_items): + captured.update(values=[c[1] for c in choices], + menu_labels=[m[0] for m in menu_items], + menu_values=[m[1] for m in menu_items]) return 0 - monkeypatch.setattr(menu, "select", fake_select) + monkeypatch.setattr(menu, "select_with_menubar", fake_menubar) assert app._select_top(_ROWS) == 0 - assert captured["back"] is False, "トップは Esc=終了 (戻り先なし)" - assert captured["search"] is True, "名前絞り込みを有効化" - assert captured["values"][:2] == [0, 1], "プロジェクトは rows index" - assert captured["values"][2:] == ["env", "plugin", "snapshot", "status"] - assert captured["titles"][2] == "環境変数 (env)", "ラベル (key) 形式で表示" + assert captured["values"] == [0, 1], "一覧はプロジェクトの rows index のみ" + assert captured["menu_values"] == ["env", "plugin", "snapshot", "status"] + assert captured["menu_labels"] == [ + "環境変数", "プラグイン", "スナップショット", "ステータス"] + + +def test_select_top_empty_projects_uses_placeholder(monkeypatch): + """プロジェクト 0 件は選択不能エラーを避けるためプレースホルダ行を 1 件置く。 + + questionary の select は選択可能な choice が 0 件だと構築できない。 + """ + captured = {} + + def fake_menubar(message, choices, menu_items): + captured.update(titles=[c[0] for c in choices], + values=[c[1] for c in choices]) + return captured["values"][0] + + monkeypatch.setattr(menu, "select_with_menubar", fake_menubar) + assert app._select_top([]) is app._NO_PROJECTS + assert captured["values"] == [app._NO_PROJECTS] + assert "プロジェクトがありません" in captured["titles"][0] + + +def test_top_loop_no_projects_placeholder_redisplays(monkeypatch, tmp_path): + """プレースホルダ行 (_NO_PROJECTS) を Enter しても何も起動せず再表示する。""" + _patch_loop(monkeypatch, [app._NO_PROJECTS, None], rows=[]) + handled = [] + monkeypatch.setattr(actions_project, "handle_row", + lambda root, row: handled.append(1) or 0) + + assert app._top_menu_loop(tmp_path) == 0 + assert handled == [], "プレースホルダでは何も起動しない" def test_top_loop_project_selection_delegates_handle_row(monkeypatch, tmp_path): diff --git a/tests/cli/tui/test_menu.py b/tests/cli/tui/test_menu.py index c31749e..99d2b24 100644 --- a/tests/cli/tui/test_menu.py +++ b/tests/cli/tui/test_menu.py @@ -131,6 +131,132 @@ def test_guard_after_done_wraps_app_key_bindings(): assert wrapped.key_bindings is inner, "既存バインドを内包したままガードする" +# --------------------------------------------------------------------------- +# select_with_menubar: 最下部メニューバーのフォーカス遷移と確定 +# --------------------------------------------------------------------------- + +_TABS = [("環境変数", "env"), ("プラグイン", "plugin"), ("ステータス", "status")] + + +def _menubar_question(): + pytest.importorskip("questionary") + return menu._build_menubar_question( + "t:", [("p1", 0), ("p2", 1)], _TABS) + + +def _handler_for(question, key, *, filtered=None): + """マージ済み key_bindings から該当キーのハンドラを取り出す。 + + 同一キーに questionary 既定とバー用の両方が居る場合は、後勝ち + (matches[-1]) の規約に合わせ最後のものを返す。filtered=True なら + filter() が真のもののみを対象にする (prompt_toolkit の適用条件と同じ)。 + """ + matches = [b for b in question.application.key_bindings.bindings + if tuple(b.keys) == (key,)] + if filtered is not None: + matches = [b for b in matches if bool(b.filter()) is filtered] + return matches[-1] + + +def test_menubar_right_left_cycles_tabs(): + """→ はバーへ入り順方向へ巡回、← は逆方向 (一覧からは末尾へ入る)。""" + import types + from prompt_toolkit.keys import Keys + + question, focus = _menubar_question() + event = types.SimpleNamespace(app=types.SimpleNamespace(invalidate=lambda: None)) + + right = _handler_for(question, Keys.Right) + left = _handler_for(question, Keys.Left) + + assert focus["tab"] is None, "初期フォーカスは一覧" + right.handler(event) + assert focus["tab"] == 0, "→ でバーの先頭へ" + right.handler(event) + assert focus["tab"] == 1 + left.handler(event) + assert focus["tab"] == 0 + left.handler(event) + assert focus["tab"] == len(_TABS) - 1, "先頭から ← は末尾へ巡回" + + focus["tab"] = None + left.handler(event) + assert focus["tab"] == len(_TABS) - 1, "一覧から ← は末尾タブへ入る" + + +def test_menubar_up_down_returns_focus_to_list(): + """バー上の ↑/↓ は一覧へフォーカスを戻す (バインドは tab_focused 条件付き)。""" + import types + from prompt_toolkit.keys import Keys + + question, focus = _menubar_question() + event = types.SimpleNamespace(app=types.SimpleNamespace(invalidate=lambda: None)) + + # 一覧フォーカス時はバー用 Up/Down バインドが無効 (questionary 既定が効く) + assert _handler_for(question, Keys.Down, filtered=True) is not None + focus["tab"] = 1 + down = _handler_for(question, Keys.Down, filtered=True) + down.handler(event) + assert focus["tab"] is None, "↓ で一覧へ戻る" + + +def test_menubar_enter_on_tab_exits_with_menu_value(): + """バーにフォーカスがあるときの Enter はバー項目の value で確定する。""" + import types + from prompt_toolkit.keys import Keys + + question, focus = _menubar_question() + focus["tab"] = 1 + enter = _handler_for(question, Keys.ControlM, filtered=True) + + captured = {} + event = types.SimpleNamespace( + app=types.SimpleNamespace(exit=lambda **kw: captured.update(kw))) + enter.handler(event) + assert captured == {"result": "plugin"} + + +def test_menubar_enter_on_list_uses_questionary_default(): + """一覧フォーカス時 (tab=None) はバー用 Enter バインドが無効になる。""" + from prompt_toolkit.keys import Keys + + question, focus = _menubar_question() + assert focus["tab"] is None + matches = [b for b in question.application.key_bindings.bindings + if tuple(b.keys) == (Keys.ControlM,)] + # questionary 既定 (filter なし=真) + バー用 (tab_focused=偽) の 2 件 + assert [bool(b.filter()) for b in matches] == [True, False], \ + "一覧フォーカス時はバー用 Enter が無効で questionary 既定が効く" + + +def test_menubar_bar_highlights_focused_tab(): + """バー描画はフォーカス中の項目だけ反転 (bold reverse) で強調する。""" + question, focus = _menubar_question() + + # レイアウト最下部のバー (FormattedTextControl) を探す + from prompt_toolkit.layout.controls import FormattedTextControl + + controls = [] + + def _collect(container): + for child in container.get_children(): + _collect(child) + content = getattr(container, "content", None) + if isinstance(content, FormattedTextControl): + controls.append(content) + + _collect(question.application.layout.container) + bar = controls[-1] + + def styles_for(label): + return [style for style, text in bar.text() if label in text] + + assert styles_for("プラグイン") == ["class:text"], "非フォーカスは通常表示" + focus["tab"] = 1 + assert styles_for("プラグイン") == ["bold reverse"], "フォーカス項目は反転表示" + assert styles_for("環境変数") == ["class:text"] + + # --------------------------------------------------------------------------- # select: バインドの仕込みと戻り値 # --------------------------------------------------------------------------- diff --git a/tests/cli/tui/test_menu_pty.py b/tests/cli/tui/test_menu_pty.py index ccb666a..59488fb 100644 --- a/tests/cli/tui/test_menu_pty.py +++ b/tests/cli/tui/test_menu_pty.py @@ -216,3 +216,66 @@ def test_buffered_key_after_answer_does_not_crash(rapid_session): raw = bytes(rapid_session._buf).decode("utf-8", errors="replace") assert "Application.exit() failed" not in raw assert "Unhandled exception" not in raw + + +_MENUBAR_DRIVER = """ +from devbase.tui import menu + +ROWS = [("alpha", 0), ("beta", 1)] +TABS = [("環境変数", "env"), ("プラグイン", "plugin"), + ("スナップショット", "snapshot"), ("ステータス", "status")] + +r = menu.select_with_menubar("TOPBAR1 を選択:", ROWS, TABS) +print(f"@R1={r!r}", flush=True) + +r = menu.select_with_menubar("TOPBAR2 を選択:", ROWS, TABS) +print(f"@R2={r!r}", flush=True) + +r = menu.select_with_menubar("TOPBAR3 を選択:", ROWS, TABS) +print(f"@R3={r!r}", flush=True) + +print("@END", flush=True) +""" + + +@pytest.fixture +def menubar_session(): + s = _PtySession(_MENUBAR_DRIVER) + yield s + if s.proc.poll() is None: + s.proc.kill() + + +def test_menubar_keys_and_rendering(menubar_session): + """最下部メニューバーの ←→ 移動・Enter 確定・↑↓ での一覧復帰 (実 TTY)。""" + s = menubar_session + + # 1) → → Enter: バーへ入り 2 項目目 (plugin) を確定 + s.wait_for("TOPBAR1") + s.send("\x1b[C") # → でバー先頭 (env) + s.send("\x1b[C") # → で plugin + s.send("\r") + s.wait_for("@R1='plugin'") + + # 2) ← Enter: 一覧から ← は末尾 (status) へ入る + s.wait_for("TOPBAR2") + s.send("\x1b[D") # ← で末尾 (status) + s.send("\r") + s.wait_for("@R2='status'") + + # 3) → ↓ ↓ Enter: バーへ入った後 ↓ で一覧へ戻り、beta を確定 + s.wait_for("TOPBAR3") + s.send("\x1b[C") # → でバーへ + s.send("\x1b[B") # ↓ で一覧へ復帰 (alpha のまま) + s.send("\x1b[B") # ↓ で beta へ + s.send("\r") + s.wait_for("@R3=1") + s.wait_for("@END") + + s.finish() + raw = bytes(s._buf).decode("utf-8", errors="replace") + # バーの 4 項目が描画されていること (最下部の常設メニュー) + for label in ("環境変数", "プラグイン", "スナップショット", "ステータス"): + assert label in raw, f"メニューバーに {label} が描画されていない" + assert "Application.exit() failed" not in raw + assert "Unhandled exception" not in raw