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
# ============================================================================
[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 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
# ============================================================================
[docs]
class MenuBar(Control):
"""Horizontal menu bar with named dropdown popups.
Opening a menu registers that dropdown as a ``light`` overlay whose chain
``owner`` is this MenuBar, so the overlay 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.
"""
pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test")
BAR_HEIGHT = 28.0
bg_colour = ThemeColour("bg_darker")
text_colour = ThemeColour("text")
hover_colour = ThemeColour("btn_bg")
size_x = Property(800.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed")
size_y = Property(BAR_HEIGHT, range=(0, 10000), hint="Control height", on_change="_on_size_changed")
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.menus: list[tuple[str, PopupMenu]] = []
self.font_size = 14.0
self._open_index = -1
self._hovered_index = -1
# The MenuBar is the chain OWNER, not an overlay itself: its bar strip
# renders inline; only the dropdown popups are overlays. The router fires
# this owner's cancel_requested on an outside click / Escape.
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, in local space."""
x = y = 0.0
scale = self.font_size / LOGICAL_EM
rects = []
cx = x
for name, _ in self.menus:
label_w = text_minimum_width(name, scale) + _LABEL_PAD * 2
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."""
try:
px, py = self.to_local(pos)
except ValueError:
return -1 # scaled to zero: nothing to hit
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)
[docs]
def on_gui_input(self, event):
if not isinstance(event, PointerEvent):
return 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 in Pointer.PRIMARY and event.phase is PointerPhase.DOWN:
idx = self._label_index_at(event.position)
if idx >= 0:
self._toggle_menu(idx)
return True
return None
def _toggle_menu(self, index: int):
"""Open the menu at ``index``, closing any previously open one.
Each dropdown is its own ``light`` overlay (owner = this MenuBar), so the
bar holds no modal scope of its own: opening a popup registers it, and the
overlay layer scopes input + dismisses the chain on an outside click.
"""
for _, popup in self.menus:
popup.hide()
# Clicking the already-open label closes it.
if index == self._open_index:
self._open_index = -1
return
rects = self._menu_rects()
if 0 <= index < len(rects):
lx, ly, _, lh = rects[index]
_, popup = self.menus[index]
anchor = self.to_global((lx, ly + lh))
popup.show(anchor.x, anchor.y)
self._open_index = index
def _close_all_and_dismiss(self):
"""Close every popup. Called by item activation."""
self._close_all()
def _on_dismissed(self):
"""Router emitted ``cancel_requested`` (outside click / Escape) on this owner."""
self._close_all()
def _close_all(self):
for _, popup in self.menus:
popup.hide()
self._open_index = -1
[docs]
def on_draw(self, renderer):
x, y, w, h = self._local_rect()
scale = self.font_size / 16.0
renderer.rect((x, y), (w, h), fill=self.bg_colour)
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.rect((lx, ly), (lw, lh), fill=self.hover_colour)
text_x = lx + _LABEL_PAD
text_y = ly + (lh - self.font_size) / 2
renderer.text(name, (text_x, text_y), fill=self.text_colour, scale=scale)
# Popup children draw themselves via the standard child-draw walk.