Skip to content

Commit 9fe6191

Browse files
feat(plugins): add ActionResult flow control for menu navigation
ESC now goes back one level instead of exiting the entire menu tree. Plugin actions (toggle, install, rebuild) close the menu immediately with a notification instead of showing intermediate "Done" screens. Plugins return ActionResult.CLOSE or ActionResult.BACK from run() to control navigation flow. None return is treated as CLOSE for backwards compatibility with external plugins.
1 parent ec79b09 commit 9fe6191

8 files changed

Lines changed: 377 additions & 320 deletions

File tree

src/menu_kit/core/runner.py

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from menu_kit.core.database import Database, ItemType, MenuItem
1111
from menu_kit.core.display_mode import DisplayMode, DisplayModeManager
1212
from menu_kit.menu.base import GUI_BACKENDS, get_backend
13+
from menu_kit.plugins.base import ActionResult
1314
from menu_kit.plugins.loader import PluginLoader
1415

1516
if TYPE_CHECKING:
@@ -221,34 +222,33 @@ def _run_menu(self) -> int:
221222

222223
result = self.backend.show(items, prompt="menu-kit")
223224

224-
# Exit only when cancelled from main menu
225+
# ESC at main menu level exits the app
225226
if result.cancelled or result.selected is None:
226227
return EXIT_CANCELLED
227228

228229
item = result.selected
229230

230231
# Handle submenu entry selection
231232
if item.id.startswith("_submenu:"):
232-
from menu_kit.plugins.base import MenuCancelled
233-
234233
plugin_name = item.id[9:] # Remove "_submenu:" prefix
235-
try:
236-
if self._show_plugin_submenu(plugin_name, display_manager):
237-
return EXIT_SUCCESS # Plugin was executed, exit
238-
except MenuCancelled:
239-
return EXIT_CANCELLED # ESC pressed, exit
234+
submenu_result = self._show_plugin_submenu(plugin_name, display_manager)
235+
if submenu_result == ActionResult.CLOSE:
236+
return EXIT_SUCCESS
237+
# BACK or None: continue loop (show main menu again)
240238
continue
241239

242240
# Record usage
243241
if self.config.frequency_tracking:
244242
self.database.record_use(item.id)
245243

246-
# Execute plugin and exit (launcher behavior)
244+
# Execute plugin
247245
if item.plugin:
248246
action = ""
249247
if ":" in item.id:
250248
_, action = item.id.split(":", 1)
251-
self.loader.run_plugin(item.plugin, action)
249+
action_result = self.loader.run_plugin(item.plugin, action)
250+
if action_result == ActionResult.BACK:
251+
continue # Show main menu again
252252
return EXIT_SUCCESS
253253

254254
def _build_main_menu(self, display_manager: DisplayModeManager) -> list[MenuItem]:
@@ -287,7 +287,9 @@ def _build_main_menu(self, display_manager: DisplayModeManager) -> list[MenuItem
287287
result.append(
288288
MenuItem(
289289
id=item.id,
290-
title=display_manager.format_inline_title(item.plugin, item.title),
290+
title=display_manager.format_inline_title(
291+
item.plugin, item.title
292+
),
291293
item_type=item.item_type,
292294
path=item.path,
293295
plugin=item.plugin,
@@ -341,18 +343,15 @@ def _sort_menu_items(self, items: list[MenuItem], sort: str) -> list[MenuItem]:
341343
else:
342344
return items
343345

344-
def _show_plugin_submenu(self, plugin_name: str, display_manager: DisplayModeManager) -> bool:
346+
def _show_plugin_submenu(
347+
self, plugin_name: str, display_manager: DisplayModeManager
348+
) -> ActionResult:
345349
"""Show items for a plugin in submenu mode.
346350
347351
Returns:
348-
True if a plugin was executed (caller should exit),
349-
False if back button selected (caller should continue).
350-
351-
Raises:
352-
MenuCancelled: If user presses ESC (should exit entire menu).
352+
ActionResult.CLOSE if a plugin action completed (caller should exit).
353+
ActionResult.BACK if user navigated back (caller should continue).
353354
"""
354-
from menu_kit.plugins.base import MenuCancelled
355-
356355
assert self.database is not None
357356
assert self.backend is not None
358357
assert self.config is not None
@@ -365,35 +364,38 @@ def _show_plugin_submenu(self, plugin_name: str, display_manager: DisplayModeMan
365364
items = self.database.get_items(plugin=plugin_name, order_by_frequency=True)
366365

367366
if not items:
368-
return False
367+
return ActionResult.BACK
369368

370369
# Add back button
371370
items.append(MenuItem(id="_back", title="Back", item_type=ItemType.ACTION))
372371

373372
result = self.backend.show(items, prompt=prompt)
374373

375-
# ESC pressed - propagate up to exit entire menu
376-
if result.cancelled:
377-
raise MenuCancelled()
378-
379-
if result.selected is None or result.selected.id == "_back":
380-
return False
374+
# ESC or Back: go back to main menu
375+
if (
376+
result.cancelled
377+
or result.selected is None
378+
or result.selected.id == "_back"
379+
):
380+
return ActionResult.BACK
381381

382382
item = result.selected
383383

384384
# Record usage
385385
if self.config.frequency_tracking:
386386
self.database.record_use(item.id)
387387

388-
# Execute and exit
388+
# Execute plugin action
389389
if item.plugin:
390390
action = ""
391391
if ":" in item.id:
392392
_, action = item.id.split(":", 1)
393-
self.loader.run_plugin(item.plugin, action)
394-
return True # Signal to exit menu
393+
action_result = self.loader.run_plugin(item.plugin, action)
394+
if action_result == ActionResult.BACK:
395+
continue # Show submenu again
396+
return ActionResult.CLOSE
395397

396-
return False
398+
return ActionResult.BACK
397399

398400
def _format_item(self, item: MenuItem, prefix: str) -> str:
399401
"""Format an item for display."""

src/menu_kit/plugins/base.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from abc import ABC, abstractmethod
66
from dataclasses import dataclass, field
7+
from enum import Enum
78
from typing import TYPE_CHECKING, Any
89

910
from menu_kit.core.database import ItemType, MenuItem
@@ -15,15 +16,27 @@
1516
from menu_kit.plugins.loader import PluginLoader
1617

1718

19+
class ActionResult(Enum):
20+
"""Flow control signal returned by plugin actions.
21+
22+
Plugins return this from run() to tell the runner what to do next:
23+
- CLOSE: Action completed, close the entire menu (launcher behaviour).
24+
- BACK: Go back to the previous menu level.
25+
"""
26+
27+
CLOSE = "close"
28+
BACK = "back"
29+
30+
1831
# Sentinel for back navigation
1932
BACK_SELECTED = object()
2033

2134

2235
class MenuCancelled(Exception):
2336
"""Raised when user cancels (ESC) the menu.
2437
25-
This exception propagates up to exit the entire plugin menu tree,
26-
rather than just going back one level like the Back button.
38+
Deprecated: ESC now returns None (same as Back) from ctx.menu().
39+
Kept for backwards compatibility with plugins that catch this exception.
2740
"""
2841

2942

@@ -49,10 +62,7 @@ def menu(
4962
show_back: Whether to show a back button (default True)
5063
5164
Returns:
52-
Selected MenuItem, or None if back button selected
53-
54-
Raises:
55-
MenuCancelled: If user presses ESC (cancels the menu)
65+
Selected MenuItem, or None if back/ESC selected
5666
"""
5767
display_items = list(items)
5868

@@ -67,8 +77,9 @@ def menu(
6777

6878
result = self.menu_backend.show(display_items, prompt)
6979

80+
# ESC and Back both mean "go back one level"
7081
if result.cancelled:
71-
raise MenuCancelled()
82+
return None
7283

7384
if result.selected and result.selected.id == "_back":
7485
return None
@@ -201,12 +212,18 @@ def teardown(self, ctx: PluginContext) -> None: # noqa: B027
201212
"""Called when plugin is unloaded. Optional override."""
202213

203214
@abstractmethod
204-
def run(self, ctx: PluginContext, action: str = "") -> None:
215+
def run(self, ctx: PluginContext, action: str = "") -> ActionResult | None:
205216
"""Called when user selects this plugin.
206217
207218
Args:
208219
ctx: Plugin context for accessing core functionality
209220
action: Sub-action if invoked via -p plugin:action
221+
222+
Returns:
223+
ActionResult controlling menu flow:
224+
- CLOSE: action completed, close the entire menu
225+
- BACK: go back to the previous menu level
226+
- None: treated as CLOSE (backwards compatible default)
210227
"""
211228
...
212229

0 commit comments

Comments
 (0)