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
# ============================================================================






# ============================================================================
# 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(self, renderer): # PopupMenu draws via draw_popup overlay, not here pass
[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 # ============================================================================ # Popups draw via the overlay system (SceneTree._popup_stack)