← XeFM crftwr/xefm on GitHub · craftware

XeFM Menu System

Overview

XeFM builds one menu model — a tree of Menu / MenuItem / SEPARATOR objects from PuiKit — and hands it to the UI layer. PuiKit decides how that single model is realized per backend:

XeFM never branches on the backend. It describes menus as intent (labels, callbacks, enable/checked predicates) and PuiKit resolves the rest. The menu is not a separate command surface either: every item routes into the same action handlers the keymap already calls, and shortcut hints on the labels are read back out of the live keymap so they track user rebindings.

Architecture

The system spans three layers.

1. The model — puikit.menu (backend-agnostic)

Source: puikit/menu.py in the PuiKit repo (installed editable from ../puikit). XeFM imports it directly:

from puikit.menu import Menu, MenuItem, SEPARATOR

The shortcut field is a hint only — the menu does not bind it. Key handling stays in XeFM’s keymap; the shortcut string just labels the row.

2. Realization — PuiKit Panel + widgets

The app hands the model to the Panel, which resolves it per backend (puikit/panel.py):

The two fallback widgets live in puikit/widgets/menu.py (re-exported from puikit.widgets):

The native builders are puikit/backends/_macos_menu.py (turns a Menu into an NSMenu; a _MenuTarget implements validateMenuItem: to answer live is_enabled() / is_checked()) and puikit/backends/_win32_menu.py (builds an HMENU via MenuResponder).

The native_menus capability is True in PROFILE_GUI_DESKTOP (puikit/capability.py) — inherited by the macOS and Windows native backends — and False for the TUI and web profiles.

3. Application layer — xefm/app.py

XeFM owns the menu content. All of it lives in xefm/app.py:

Two submenus are factored out because they are reused by keyboard-triggered popups as well as the bar:

Both use a live checked predicate to mark the active choice.

Callbacks reuse the keymap

Menu items call the same handlers as the keyboard. Two helpers glue them:

def _menu(self, action: str) -> None:
    """Run a keymap action from a menu/context-menu selection and redraw."""
    if self.dispatch(action):
        self.panel.render()

def _menu_shortcut(self, action: str) -> str | None:
    """Display-formatted first key bound to `action` (or None), so menu
    labels track the live keymap instead of hardcoded strings."""
    keys, _ = self.keys.get_keys_for_action(action)
    return self.keys.format_key_for_display(keys[0]) if keys else None

An item either calls self._menu("some_action") (dispatch through the keymap, identical to pressing the key) or calls a bound method directly (on_select=self.create_directory). Its shortcut= is filled from _menu_shortcut(...) (aliased sc inside _build_menu) so the hint reflects the user’s actual binding.

Live enable / check predicates

Because enabled / checked accept predicates, menu state is expressed inline and re-evaluated on open — no separate “update states” pass. Examples from _build_menu():

def has_files() -> bool:
    return bool(self.active_pane()["files"])

MenuItem("Rename…", on_select=self.rename,
         enabled=has_files, shortcut=sc("rename_file"))

MenuItem("Show Hidden Files", on_select=lambda: self._menu("toggle_hidden"),
         checked=lambda: self.flm.show_hidden, shortcut=sc("toggle_hidden"))

MenuItem("Clear Selection", on_select=lambda: self._menu("unselect_all"),
         enabled=lambda: bool(self.active_pane()["selected_files"]),
         shortcut=sc("unselect_all"))

Context menus (right-click)

Each file pane wires a right-click handler through its on_context callback:

self.left_view = FilePane(..., on_context=lambda i, x, y:
                          self._show_context_menu("left", i, x, y), ...)

_show_context_menu(pane_name, index, x, y) makes the clicked pane/row active, builds a fresh Menu(...) (Open, View File, Select/Deselect, Rename, Duplicate, Copy/Move to Other Pane, Delete, Copy Name(s)/Path(s), Show Hidden Files), and calls self.panel.popup_menu(menu, x, y). As with the bar, the items reuse the same handlers and carry live enabled / checked predicates (e.g. enabled=entry is not None, the Select/Deselect label chosen from the row’s current selection state).

Adding a menu item

There are no item-id constants, no dispatch table, and no per-item state pass to touch. To add an item to a menu in _build_menu():

  1. Add a MenuItem to the appropriate submenu Menu(...).
  2. Point on_select at either an existing bound method (on_select=self.duplicate_files) or a keymap action via the helper (on_select=lambda: self._menu("some_action")). Prefer routing through an existing action so the keyboard and the menu stay in sync.
  3. Add a shortcut hint with sc("action_name") if the action has a key binding — the hint then tracks the live keymap automatically.
  4. Express availability inline with enabled=/checked= — a bool for a static case, or a zero-arg predicate for anything that depends on live state (it is re-evaluated every time the menu opens).

No renderer, backend, or test-harness change is needed: the same MenuItem renders natively on macOS/Windows and as a widget row on the TUI.

Desktop vs. terminal

Whether XeFM is running a native GUI is available via is_desktop_mode() (xefm/backend_detector.py), used for launch behavior (e.g. detaching vs. suspending child processes). The menu system itself does not consult it — the OS-bar-vs-in-window decision is made by PuiKit from the backend’s native_menus capability, so XeFM stays backend-agnostic.

Code locations

XeFM (xefm/app.py)

PuiKit (../puikit)