Source code for simvx.core.ui.menu

"""Menu widgets -- MenuItem, PopupMenu, MenuBar.

A MenuBar's dropdowns (and their submenus) each register a ``light`` overlay
whose chain ``owner`` is the MenuBar, so the overlay layer draws them on top and
scopes input to the open chain. Outside-click and Escape dismiss the whole chain
via the router. A standalone PopupMenu (no MenuBar ancestor) owns its own chain.
"""

from __future__ import annotations

import logging
from collections.abc import Callable

from ..input.enums import MouseButton
from ..math.types import Vec2
from ..signals import Signal
from .core import Control, ThemeColour, ThemeStyleBox

log = logging.getLogger(__name__)

__all__ = [
    "MenuItem",
    "PopupMenu",
    "MenuBar",
]

# ============================================================================
# MenuItem -- menu entry data
# ============================================================================






# ============================================================================
# PopupMenu -- dropdown list of menu items
# ============================================================================


[docs] class PopupMenu(Control): """Dropdown popup showing a vertical list of MenuItems. A PopupMenu registers itself as a ``light`` overlay (via ``show``) whose chain owner is the ancestor MenuBar (or itself, for a standalone context menu). The overlay router scopes input to the open chain; the popup gets its clicks via standard child hit-testing within that scope. """ bg_colour = ThemeColour("popup_bg") hover_colour = ThemeColour("popup_hover") text_colour = ThemeColour("text") shortcut_colour = ThemeColour("text_muted") separator_colour = ThemeColour("popup_separator") border_colour = ThemeColour("border_light") style = ThemeStyleBox("popup_style") style_hover = ThemeStyleBox("popup_style_hover") def __init__(self, items: list[MenuItem] = None, **kwargs): super().__init__(**kwargs) self.items: list[MenuItem] = items or [] self.visible = False self.item_height = 24.0 self.font_size = 14.0 self._hovered_index = -1 self._child_popup: PopupMenu | None = None self._submenu_parent: PopupMenu | None = None self.item_selected = Signal() self.cancel_requested.connect(self._on_self_dismissed) def _compute_size(self) -> Vec2: """Compute menu size from item content.""" if not self.items: return Vec2(120, self.item_height) char_w = self.font_size * 0.6 max_w = 120.0 has_submenu = False for item in self.items: if item.separator: continue text_w = len(item.text) * char_w if item.shortcut: text_w += len(item.shortcut) * char_w + 20 if item.submenu is not None: has_submenu = True max_w = max(max_w, text_w + 40) if has_submenu: max_w += 16 # space for "▸" arrow return Vec2(max_w, len(self.items) * self.item_height)
[docs] def show(self, x: float, y: float): """Position the menu and register it as a ``light`` overlay. The overlay's chain ``owner`` is the ancestor :class:`MenuBar` (so a dropdown + its submenus dismiss as one chain), or the root popup of a standalone context-menu chain. Layering, input scope, and outside-click dismissal are the overlay layer's job; this method only positions + opens. """ self.position = Vec2(x, y) self.size = self._compute_size() self.visible = True self._hovered_index = -1 self.show_overlay("light", owner=self._chain_owner())
[docs] def hide(self): """Close this menu (and its open-chain) and any open child submenu.""" self._hide_child_submenu() self.visible = False self._hovered_index = -1 self.close_overlay()
[docs] def on_exit_tree(self) -> None: """Ensure the overlay entry is released if removed from the tree while shown. ``Control._exit_tree`` already pops a registered overlay before detaching, so this is belt-and-braces: hide and close idempotently so an item callback that rebuilds the owning panel mid-open never strands the entry. """ self.visible = False self.close_overlay()
def _find_menubar_ancestor(self) -> MenuBar | None: node = self.parent while node is not None: if isinstance(node, MenuBar): return node node = node.parent return None def _chain_owner(self): """The logical chain owner: the ancestor MenuBar, else the chain's root popup.""" mb = self._find_menubar_ancestor() if mb is not None: return mb root = self while root._submenu_parent is not None: root = root._submenu_parent return root def _on_self_dismissed(self): """Router emitted ``cancel_requested`` on a standalone (self-owned) popup chain.""" self.hide() def _item_index_at(self, pos) -> int: """Return item index under screen position, or -1.""" gx, gy, gw, gh = self.get_global_rect() px = pos.x if hasattr(pos, "x") else pos[0] py = pos.y if hasattr(pos, "y") else pos[1] if not (gx <= px < gx + gw and gy <= py < gy + gh): return -1 idx = int((py - gy) / self.item_height) return idx if 0 <= idx < len(self.items) else -1
[docs] def is_point_inside(self, point) -> bool: if not self.visible: return False return self._item_index_at(point) >= 0
def _on_gui_input(self, event): if not self.visible: return # Hover update on mouse-move (no button): opens / closes submenus. if event.position is not None and getattr(event, "button", None) is None and not event.key: new_hover = self._item_index_at(event.position) if new_hover != self._hovered_index: self._hovered_index = new_hover if 0 <= new_hover < len(self.items) and self.items[new_hover].submenu is not None: self._show_child_submenu(new_hover) else: self._hide_child_submenu() return # Click on item: fire callback, close chain. if event.button == MouseButton.LEFT and event.pressed and event.position is not None: idx = self._item_index_at(event.position) if idx >= 0: item = self.items[idx] if item.submenu is not None: # Submenu items don't activate on click; hover opens them. return if not item.separator and item.callback: item.callback() self.item_selected.emit(item) self._close_chain() # ---- Submenu management ---- def _show_child_submenu(self, index: int): """Open a child popup for the submenu at ``index``.""" item = self.items[index] if item.submenu is None: return if self._child_popup is None: self._child_popup = PopupMenu() self._child_popup._submenu_parent = self # Parent the submenu under the chain owner's subtree so it shares the # owner's input scope: the ancestor MenuBar when present, else this # popup (a standalone context-menu chain). It registers its OWN overlay # entry (owner = the chain owner) in ``show``. (self._find_menubar_ancestor() or self).add_child(self._child_popup) self._child_popup.items = item.submenu gx, gy, gw, _ = self.get_global_rect() child_y = gy + index * self.item_height self._child_popup.show(gx + gw - 2, child_y) def _hide_child_submenu(self): if self._child_popup and self._child_popup.visible: self._child_popup.hide() def _close_chain(self): """Hide this popup, parent popups, and ask the MenuBar to dismiss.""" self.hide() if self._submenu_parent: self._submenu_parent._close_chain() return owner = self.parent while owner is not None and not isinstance(owner, MenuBar): owner = owner.parent if owner is not None: owner._close_all_and_dismiss()
[docs] def on_draw(self, renderer): if not self.visible or not self.items: return self.size = self._compute_size() x, y, w, h = self.get_global_rect() scale = self.font_size / 16.0 pad = 10.0 box = self.style if box is not None: box.draw(renderer, x, y, w, h) else: renderer.draw_rect((x - 1, y - 1), (w + 2, h + 2), colour=self.border_colour, filled=True) renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True) renderer.push_clip(int(x), int(y), int(w), int(h)) for i, item in enumerate(self.items): iy = y + i * self.item_height if item.separator: sep_y = iy + self.item_height / 2 renderer.draw_line((x + 4, sep_y), (x + w - 4, sep_y), colour=self.separator_colour) continue if i == self._hovered_index: hover_box = self.style_hover if hover_box is not None: hover_box.draw(renderer, x + 1, iy, w - 2, self.item_height) else: renderer.draw_rect((x + 1, iy), (w - 2, self.item_height), colour=self.hover_colour, filled=True) text_y = iy + (self.item_height - self.font_size) / 2 renderer.draw_text(item.text, (x + pad, text_y), colour=self.text_colour, scale=scale) if item.submenu is not None: arrow = "\u25b8" aw = renderer.text_width(arrow, scale) renderer.draw_text(arrow, (x + w - aw - pad, text_y), colour=self.shortcut_colour, scale=scale) elif item.shortcut: sc_w = renderer.text_width(item.shortcut, scale) renderer.draw_text( item.shortcut, (x + w - sc_w - pad, text_y), colour=self.shortcut_colour, scale=scale ) renderer.pop_clip()
# ============================================================================ # MenuBar -- horizontal menu strip # ============================================================================ # Popup children draw themselves via the standard child-draw walk.