Source code for simvx.core.ui.menu
"""Menu widgets -- MenuItem, PopupMenu, MenuBar."""
from __future__ import annotations
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")
def __init__(self, text: str = "", callback: Callable = None, shortcut: str = "", separator: bool = False):
self.text = text
self.callback = callback
self.shortcut = shortcut
self.separator = separator
# ============================================================================
# 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.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
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
max_w = max(max_w, text_w + 40)
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."""
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:
self._hovered_index = self._item_index_at(event.position)
# ---- Popup overlay API ----
[docs]
def is_popup_point_inside(self, point) -> bool:
if not self.visible:
return False
return self._item_index_at(point) >= 0
[docs]
def popup_input(self, event):
idx = self._item_index_at(event.position) if event.position else -1
if idx >= 0:
item = self.items[idx]
if not item.separator and item.callback:
item.callback()
self.item_selected.emit(item)
self.hide()
# Also close the parent MenuBar
if self.parent and isinstance(self.parent, MenuBar):
self.parent._close_all()
[docs]
def dismiss_popup(self):
self.hide()
if 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_filled_rect(x - 1, y - 1, w + 2, h + 2, self.border_colour)
renderer.draw_filled_rect(x, y, w, h, 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.draw_line_coloured(x + 4, sep_y, x + w - 4, sep_y, 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_filled_rect(x + 1, iy, w - 2, self.item_height, self.hover_colour)
text_y = iy + (self.item_height - self.font_size) / 2
renderer.draw_text_coloured(item.text, x + pad, text_y, scale, self.text_colour)
if item.shortcut:
sc_w = renderer.text_width(item.shortcut, scale)
renderer.draw_text_coloured(item.shortcut, x + w - sc_w - pad, text_y, scale, self.shortcut_colour)
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_filled_rect(x, y, w, h, 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.draw_filled_rect(lx, ly, lw, lh, self.hover_colour)
text_x = lx + 8
text_y = ly + (lh - self.font_size) / 2
renderer.draw_text_coloured(name, text_x, text_y, scale, self.text_colour)
# Popups draw via the overlay system (SceneTree._popup_stack)