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 ..descriptors import Property
from ..input.controls import Pointer
from ..input.enums import PointerPhase
from ..input.events import PointerEvent
from ..math.types import Vec2
from ..signals import Signal
from ..text.measure import LOGICAL_EM, text_minimum_width
from .core import Control, ThemeColour, ThemeStyleBox
from .enums import PointerFilter

log = logging.getLogger(__name__)

#: Blank space either side of a MenuBar label. The hit rect and the drawn
#: text read the same constant so a click cannot land beside the label it
#: looks like it is on.
_LABEL_PAD = 8.0

__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. """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") top_level = Property(True, on_change="_invalidate_transform", hint="Placed on the screen, not by its parent") 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") visible = Property( False, coerce=bool, hint="A popup is up only between show() and its dismissal", on_change="_on_visible_changed", ) def __init__(self, items: list[MenuItem] = None, **kwargs): super().__init__(**kwargs) self.items: list[MenuItem] = items or [] 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) scale = self.font_size / LOGICAL_EM max_w = 120.0 has_submenu = False for item in self.items: if item.separator: continue text_w = text_minimum_width(item.text, scale) if item.shortcut: text_w += text_minimum_width(item.shortcut, scale) + 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.""" _x, _y, w, h = self._local_rect() try: px, py = self.to_local(pos) except ValueError: return -1 # scaled to zero: nothing to hit if not (0.0 <= px < w and 0.0 <= py < h): return -1 idx = int(py / 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
[docs] def on_gui_input(self, event): if not self.visible or not isinstance(event, PointerEvent): return None # Hover update on motion: opens / closes submenus. if event.phase is PointerPhase.MOVE: 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 None # Click on item: fire callback, close chain. Claimed either way: a click # anywhere on an open menu belongs to the menu. if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN: 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 True if not item.separator and item.callback: item.callback() self.item_selected.emit(item) self._close_chain() return True return None
# ---- 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 _x, _y, w, _h = self._local_rect() anchor = self.to_global((w - 2, index * self.item_height)) self._child_popup.show(anchor.x, anchor.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._local_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.rect((x - 1, y - 1), (w + 2, h + 2), fill=self.border_colour) renderer.rect((x, y), (w, h), fill=self.bg_colour) 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.line((x + 4, sep_y), (x + w - 4, sep_y), stroke=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.rect((x + 1, iy), (w - 2, self.item_height), fill=self.hover_colour) text_y = iy + (self.item_height - self.font_size) / 2 renderer.text(item.text, (x + pad, text_y), fill=self.text_colour, scale=scale) if item.submenu is not None: arrow = "\u25b8" aw = renderer.text_width(arrow, scale) renderer.text(arrow, (x + w - aw - pad, text_y), fill=self.shortcut_colour, scale=scale) elif item.shortcut: sc_w = renderer.text_width(item.shortcut, scale) renderer.text(item.shortcut, (x + w - sc_w - pad, text_y), fill=self.shortcut_colour, scale=scale) renderer.pop_clip()
# ============================================================================ # MenuBar -- horizontal menu strip # ============================================================================ # Popup children draw themselves via the standard child-draw walk.