Source code for simvx.core.ui.menu
"""Menu widgets -- MenuItem, PopupMenu, MenuBar."""
import logging
from collections.abc import Callable
from ..descriptors import Signal
from ..math.types import Vec2
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 # Nested menu items; when set, callback is ignored
# ============================================================================
# PopupMenu -- dropdown list of menu items
# ============================================================================
[docs]
class PopupMenu(Control):
"""Dropdown popup showing a vertical list of MenuItems."""
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.z_index = 1000
self._hovered_index = -1
self._child_popup: PopupMenu | None = None
self._submenu_parent: PopupMenu | None = None
self.item_selected = Signal()
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."""
self.position = Vec2(x, y)
self.size = self._compute_size()
self.visible = True
self._hovered_index = -1
if self._tree:
self._tree.push_popup(self)
[docs]
def hide(self):
"""Close the menu and any open child submenu."""
self._hide_child_submenu()
was_visible = self.visible
self.visible = False
self._hovered_index = -1
if was_visible and self._tree:
self._tree.pop_popup(self)
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
def _on_gui_input(self, event):
if not self.visible:
return
if event.position:
new_hover = self._item_index_at(event.position)
if new_hover != self._hovered_index:
self._hovered_index = new_hover
if new_hover >= 0 and 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()
# ---- 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
if self._tree:
self._tree.root.add_child(self._child_popup)
self._child_popup.items = item.submenu
gx, gy, gw, gh = 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):
"""Close any open child submenu."""
if self._child_popup and self._child_popup.visible:
self._child_popup.hide()
def _close_chain(self):
"""Close this popup and the entire parent chain (for submenu item activation)."""
self.hide()
if self._submenu_parent:
self._submenu_parent._close_chain()
elif self.parent and isinstance(self.parent, MenuBar):
self.parent._close_all()
# ---- Popup overlay API ----
[docs]
def is_popup_point_inside(self, point) -> bool:
if not self.visible:
return False
if self._item_index_at(point) >= 0:
return True
if self._child_popup and self._child_popup.is_popup_point_inside(point):
return True
return False
[docs]
def popup_input(self, event):
# Delegate to child popup if click is inside it
if self._child_popup and self._child_popup.visible:
if event.position and self._child_popup.is_popup_point_inside(event.position):
self._child_popup.popup_input(event)
return
idx = self._item_index_at(event.position) if event.position else -1
if idx >= 0:
item = self.items[idx]
# Clicking a submenu item does nothing (hover opens it)
if item.submenu is not None:
return
if not item.separator and item.callback:
item.callback()
self.item_selected.emit(item)
# Close the entire chain
self._close_chain()
[docs]
def dismiss_popup(self):
self.hide()
if self._submenu_parent:
self._submenu_parent._close_chain()
elif self.parent and isinstance(self.parent, MenuBar):
self.parent._on_popup_dismissed()
[docs]
def draw_popup(self, renderer):
"""Draw menu as an overlay (on top of everything)."""
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
# Background via StyleBox (with border), fallback to flat
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."""
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._just_dismissed = -1 # Track externally dismissed menu index
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
def _on_gui_input(self, event):
if event.position:
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 == 1 and event.pressed and event.position:
idx = self._label_index_at(event.position)
if idx >= 0:
# If this menu was just externally dismissed (click-outside),
# the _just_dismissed flag prevents immediate reopen.
# But a fresh click on the same label should open it, so
# clear the flag after consuming it once.
if idx == self._just_dismissed:
self._just_dismissed = -1
return
self._toggle_menu(idx)
else:
self._close_all()
def _toggle_menu(self, index: int):
"""Open menu at index, closing any previously open menu."""
for _, popup in self.menus:
popup.hide()
# Clicking the already-open label closes it
if index == self._open_index:
self._open_index = -1
return
self._just_dismissed = -1
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 _on_popup_dismissed(self):
"""Called when a popup is externally dismissed (e.g. clicking outside)."""
self._just_dismissed = self._open_index
self._close_all()
def _close_all(self):
"""Close all open popups."""
for _, popup in self.menus:
popup.hide()
self._open_index = -1
[docs]
def 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)
# Popups draw via the overlay system (SceneTree._popup_stack)