Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 27 additions & 10 deletions lib/devbase/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 は従来どおり一覧
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
133 changes: 127 additions & 6 deletions lib/devbase/tui/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 単独押下のハンドラを後付けする共通処理。

Expand Down Expand Up @@ -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 相当の属性値を集めるのに使う)
# ---------------------------------------------------------------------------
Expand Down
52 changes: 40 additions & 12 deletions tests/cli/tui/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading