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: 30 additions & 7 deletions lib/devbase/commands/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,27 @@ def _start_project_up(name: str) -> int:
return _start_project_action(name, "up")


def _with_escape_cancel(question):
"""questionary の select に Esc 単独押下での中止を後付けする。

questionary 2.x の select は Ctrl-C / Ctrl-Q しか中止に割り当てないため、
生成済み ``Question.application`` の key_bindings に Escape ハンドラを足す。
Ctrl-C と同じく ``KeyboardInterrupt`` で抜けるので ``ask()`` は ``None``
(= 中止) を返す。

Escape は矢印キー等のエスケープシーケンス (``\\x1b[A`` 等) の先頭バイトでも
あるため、``eager=False`` で登録し prompt_toolkit のフラッシュ待ちで単独 Esc
のみを拾う (矢印キー移動と衝突させない)。
"""
from prompt_toolkit.keys import Keys

@question.application.key_bindings.add(Keys.Escape)
def _(event):
event.app.exit(exception=KeyboardInterrupt, style="class:aborting")

return question


def _show_menu(rows: list[dict]) -> int | None:
"""questionary の select を起動し、選択された rows の index を返す (中止時 None)。

Expand All @@ -198,33 +219,35 @@ def _show_menu(rows: list[dict]) -> int | None:
entries = _build_menu_entries(rows, colorize=_STATUS_COLOR)
choices = [questionary.Choice(title=entry, value=i)
for i, entry in enumerate(entries)]
return questionary.select(
"起動するプロジェクトを選択 (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Ctrl-C 中止):",
question = questionary.select(
"起動するプロジェクトを選択 (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc・Ctrl-C 中止):",
choices=choices,
use_arrow_keys=True,
use_jk_keys=False, # use_search_filter と併用不可のため False
use_search_filter=True, # 文字入力でプロジェクト名等を部分一致絞り込み
use_shortcuts=False, # 単一キーショートカットは使わない
).ask() # 選択された value (= rows の index) / 中止時 None
)
return _with_escape_cancel(question).ask() # value (= rows index) / 中止時 None


def _show_action_menu(name: str) -> str | None:
"""running 中プロジェクトの操作 (up/rebuild/down) を選ぶサブメニュー。

選択された action 文字列 (``"up"`` / ``"rebuild"`` / ``"down"``) を返す。
中止 (Ctrl-C) 時は None。テストではこの関数を monkeypatch する。
中止 (Esc / Ctrl-C) 時は None。テストではこの関数を monkeypatch する。
"""
choices = [
questionary.Choice(title="再起動 (up)", value="up"),
questionary.Choice(title="再ビルド (rebuild --no-cache)", value="rebuild"),
questionary.Choice(title="停止 (down)", value="down"),
]
return questionary.select(
f"'{name}' は起動中です。操作を選択 (↑↓ 移動 / Enter 決定 / Ctrl-C 中止):",
question = questionary.select(
f"'{name}' は起動中です。操作を選択 (↑↓ 移動 / Enter 決定 / Esc・Ctrl-C 中止):",
choices=choices,
use_arrow_keys=True,
use_shortcuts=False,
).ask()
)
return _with_escape_cancel(question).ask()


def _tui_select_and_up(rows: list[dict]) -> int:
Expand Down
46 changes: 46 additions & 0 deletions tests/cli/test_project_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,52 @@ def test_tui_non_running_row_direct_up(monkeypatch, status):
assert captured["name"] == "carmo"


def test_with_escape_cancel_registers_escape_binding():
"""_with_escape_cancel が select に単独 Esc 中止バインドを後付けすること。"""
questionary = pytest.importorskip("questionary")
from prompt_toolkit.keys import Keys

from devbase.commands import project as project_mod

q = questionary.select("t", choices=[questionary.Choice(title="a", value=0)])
assert project_mod._with_escape_cancel(q) is q # 同じ question を返す

esc = [b for b in q.application.key_bindings.bindings if Keys.Escape in b.keys]
assert len(esc) == 1
# eager=False: 矢印キー等のエスケープシーケンス (\x1b[A 等) の先頭と衝突させない
assert esc[0].eager() is False

# ハンドラは Ctrl-C と同様 KeyboardInterrupt で app を抜ける (= ask() が None)
captured = {}
fake_app = types.SimpleNamespace(exit=lambda **kw: captured.update(kw))
esc[0].handler(types.SimpleNamespace(app=fake_app))
assert captured["exception"] is KeyboardInterrupt


@pytest.mark.parametrize("call", [
lambda m: m._show_menu([{"name": "carmo", "plugin": "-", "status": "stopped"}]),
lambda m: m._show_action_menu("carmo"),
])
def test_select_menus_wire_escape_cancel(monkeypatch, call):
"""_show_menu / _show_action_menu が select に Esc 中止を仕込んでから ask する。"""
pytest.importorskip("questionary")
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.keys import Keys

from devbase.commands import project as project_mod

kb = KeyBindings()
fake_q = types.SimpleNamespace(
application=types.SimpleNamespace(key_bindings=kb),
ask=lambda: "sentinel",
)
monkeypatch.setattr(project_mod.questionary, "select", lambda *a, **k: fake_q)

assert call(project_mod) == "sentinel"
esc = [b for b in kb.bindings if Keys.Escape in b.keys]
assert len(esc) == 1, "Esc 中止バインドが登録されていない"


def test_interactive_falls_back_when_no_terminal_menu(tmp_path, monkeypatch):
"""questionary 未導入時は input() 番号入力にフォールバックして up する。"""
from devbase.commands import project as project_mod
Expand Down
Loading