Source code for simvx.core.ui.menu

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

The MenuBar is the modal owner: when any of its menus is open, ``MenuBar``
captures input via the unified modal API. PopupMenus are children of the
MenuBar (or of their parent popup for submenu chains) marked ``top_level=True``
so they render above sibling Controls. Outside-click and Escape dismiss the
whole chain via the router.
"""

from __future__ import annotations

import logging
from collections.abc import Callable

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

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 is a non-modal floating Control with ``top_level=True``. It relies on the owning MenuBar (or parent PopupMenu) to be modal: the router gates input to the modal subtree, the popup gets its clicks via standard child hit-testing within that subtree. """ 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.top_level = True self.item_height = 24.0 self.font_size = 14.0 self.z_index = 1000 self._hovered_index = -1 self._child_popup: PopupMenu | None = None self._submenu_parent: PopupMenu | None = None # ``True`` when this popup owns its own modal scope (standalone context # menu shown outside a MenuBar). Set in :meth:`show`. self._self_modal: bool = False 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 make it visible. If no ancestor :class:`MenuBar` and no parent :class:`PopupMenu` exists, this popup pushes itself onto the modal stack so outside clicks dismiss it (used for standalone context menus). When part of a menu chain, the owning MenuBar / parent popup holds modality. """ self.position = Vec2(x, y) self.size = self._compute_size() self.visible = True self._hovered_index = -1 if self._submenu_parent is None and self._find_menubar_ancestor() is None: self._self_modal = True self.modal = True self.dismiss_on_outside_click = True self.pause_tree_when_modal = False if self._tree: self._tree.push_modal(self)
[docs] def hide(self): """Close this menu and any open child submenu.""" self._hide_child_submenu() was_visible = self.visible self.visible = False self._hovered_index = -1 if self._self_modal and was_visible and self._tree: self._tree.pop_modal(self) self.modal = False
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 _on_self_dismissed(self): """Router emitted ``cancel_requested`` on this self-modal popup.""" if self._self_modal: 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 # Attach the submenu to the modal owner (MenuBar) so it shares the # modal subtree. Walk up to find the nearest MenuBar. owner = self while owner is not None and not isinstance(owner, MenuBar): owner = owner.parent if owner is not None: owner.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 / 14.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.