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
# ============================================================================
[docs]
class MenuItem:
"""Data for a single menu entry (text, callback, shortcut, or separator)."""
__slots__ = ("text", "callback", "shortcut", "separator", "submenu")
def __init__(
self,
text: str = "",
callback: Callable = None,
shortcut: str = "",
separator: bool = False,
submenu: list[MenuItem] | None = None,
):
self.text = text
self.callback = callback
self.shortcut = shortcut
self.separator = separator
self.submenu = submenu # When set, callback is ignored.
# ============================================================================
# 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
# ============================================================================
[docs]
class MenuBar(Control):
"""Horizontal menu bar with named dropdown popups.
The MenuBar owns modality: opening any menu calls :meth:`show_modal` so the
router gates input to the bar + open popup chain. Hovering another label
while a menu is open switches to it; clicking outside the bar/popup
dismisses the whole chain via the router's outside-click path.
"""
BAR_HEIGHT = 28.0
bg_colour = ThemeColour("bg_darker")
text_colour = ThemeColour("text")
hover_colour = ThemeColour("btn_bg")
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.menus: list[tuple[str, PopupMenu]] = []
self.font_size = 14.0
self.size = Vec2(800, self.BAR_HEIGHT)
self._open_index = -1
self._hovered_index = -1
self.dismiss_on_outside_click = True
# Don't paint a backdrop dim: menu chains are bounded popups, not
# screen-blocking modals.
self.pause_tree_when_modal = False
# The MenuBar itself doesn't render on top of everything: only the
# popup children do (they have ``top_level=True``).
self.cancel_requested.connect(self._on_dismissed)
def _menu_rects(self) -> list[tuple[float, float, float, float]]:
"""Compute (x, y, w, h) for each menu label in the bar."""
x, y, _, _ = self.get_global_rect()
char_w = self.font_size * 0.6
rects = []
cx = x
for name, _ in self.menus:
label_w = len(name) * char_w + 16
rects.append((cx, y, label_w, self.BAR_HEIGHT))
cx += label_w
return rects
def _label_index_at(self, pos) -> int:
"""Return menu label index under screen position, or -1."""
px = pos.x if hasattr(pos, "x") else pos[0]
py = pos.y if hasattr(pos, "y") else pos[1]
for i, (lx, ly, lw, lh) in enumerate(self._menu_rects()):
if lx <= px < lx + lw and ly <= py < ly + lh:
return i
return -1
[docs]
def is_point_inside(self, point) -> bool:
"""Bar covers its own label strip; while a menu is open the modal scope
also includes the popup chain (handled via child hit-testing)."""
return super().is_point_inside(point)
def _on_gui_input(self, event):
if event.position is not None:
self._hovered_index = self._label_index_at(event.position)
# Hovering another label while a menu is open switches to it.
if self._open_index >= 0 and self._hovered_index >= 0 and self._hovered_index != self._open_index:
self._toggle_menu(self._hovered_index)
if event.button == MouseButton.LEFT and event.pressed and event.position is not None:
idx = self._label_index_at(event.position)
if idx >= 0:
self._toggle_menu(idx)
def _toggle_menu(self, index: int):
"""Open the menu at ``index``, closing any previously open one."""
for _, popup in self.menus:
popup.hide()
# Clicking the already-open label closes it.
if index == self._open_index:
self._open_index = -1
self._exit_modal_if_idle()
return
rects = self._menu_rects()
if 0 <= index < len(rects):
lx, ly, _, lh = rects[index]
_, popup = self.menus[index]
popup.show(lx, ly + lh)
self._open_index = index
if not self.modal:
self.show_modal()
def _close_all_and_dismiss(self):
"""Close every popup and exit modal. Called by item activation."""
self._close_all()
self._exit_modal_if_idle()
def _on_dismissed(self):
"""Router emitted ``cancel_requested`` (outside click / Escape)."""
self._close_all()
self._open_index = -1
def _close_all(self):
for _, popup in self.menus:
popup.hide()
self._open_index = -1
def _exit_modal_if_idle(self):
if self.modal and self._open_index < 0:
self.close_modal()
[docs]
def show_modal(self):
"""Open the bar's modal scope. ``visible`` stays True; only the popup
children reveal additional content."""
# Skip the default backdrop injection: MenuBar isn't a screen-blocking
# modal, just a bounded input scope. We subclass the lifecycle by hand.
self.modal = True
if self._tree:
self._tree.push_modal(self)
[docs]
def on_draw(self, renderer):
x, y, w, h = self.get_global_rect()
scale = self.font_size / 14.0
renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True)
rects = self._menu_rects()
for i, (name, _) in enumerate(self.menus):
lx, ly, lw, lh = rects[i]
if i == self._open_index or i == self._hovered_index:
renderer.draw_rect((lx, ly), (lw, lh), colour=self.hover_colour, filled=True)
text_x = lx + 8
text_y = ly + (lh - self.font_size) / 2
renderer.draw_text(name, (text_x, text_y), colour=self.text_colour, scale=scale)
# Popup children draw themselves via the standard child-draw walk.