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
# ============================================================================
[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.
"""
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
# ============================================================================
[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.
"""
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
# 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."""
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.
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]
popup.show(lx, ly + lh)
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.get_global_rect()
scale = self.font_size / 16.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.