Source code for radioviz.services.menu_builder

#  Copyright 2025–2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Module for building Qt menus from specification objects.

This module provides functionality to construct Qt menu structures from
specification objects. It handles the creation of menus, submenus, actions,
and separators based on the provided specifications.
"""

from __future__ import annotations

from typing import Dict, List, Sequence, Tuple

from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import QMenu, QWidget

from radioviz.models.menu_spec import MenuEntry, ToolActionSpec, ToolMenuSpec, ToolSeparatorSpec


[docs] def build_tool_menus(parent: QWidget, menu_specs: List[ToolMenuSpec]) -> List[QMenu]: """ Build a list of Qt menus from tool menu specifications. Creates Qt menu objects from the provided menu specifications and associates them with the given parent widget. :param parent: The parent widget for the menus :type parent: QWidget :param menu_specs: List of tool menu specifications :type menu_specs: List[:class:`radioviz.models.menu_spec.ToolMenuSpec`] :return: List of created Qt menu objects :rtype: List[:class:`PySide6.QtWidgets.QMenu`] """ merged_specs = _merge_menu_specs(menu_specs) menus: List[QMenu] = [] for spec in merged_specs: menu = _build_menu(parent, spec) menus.append(menu) return menus
[docs] def build_context_menu(parent: QWidget, menu_specs: List[ToolMenuSpec]) -> QMenu: """ Build a context menu from menu specifications. Creates a root QMenu that contains the merged top-level menu specifications as submenus. This allows combining tool menus with window-specific menus. The resulting menu is designed for context use and closes automatically after actions are triggered. :param parent: The parent widget for the menu :type parent: QWidget :param menu_specs: List of menu specifications :type menu_specs: List[:class:`radioviz.models.menu_spec.ToolMenuSpec`] :return: Root context menu :rtype: :class:`PySide6.QtWidgets.QMenu` """ root = QMenu(parent) merged_specs = _merge_menu_specs(menu_specs) for spec in merged_specs: root.addMenu(_build_menu(parent, spec, root_menu=root, close_on_trigger=True)) return root
[docs] def _build_menu( parent: QWidget, spec: ToolMenuSpec, group_map: Dict[str, QActionGroup] | None = None, root_menu: QMenu | None = None, close_on_trigger: bool = False, ) -> QMenu: """ Recursively build a Qt menu from a tool menu specification. Creates a Qt menu object and populates it with actions, submenus, and separators based on the specification entries. Actions that declare an ``action_group`` are assigned to a shared QActionGroup (one group per name), enforcing exclusive radio-like behavior. :param parent: The parent widget for the menu :type parent: QWidget :param spec: Tool menu specification :type spec: :class:`radioviz.models.menu_spec.ToolMenuSpec` :param group_map: Optional mapping of action group names to QActionGroups :type group_map: Dict[str, QActionGroup] | None :return: Created Qt menu object :rtype: :class:`PySide6.QtWidgets.QMenu` """ if close_on_trigger and root_menu is None: raise ValueError('root_menu must be provided when close_on_trigger is True') menu_parent: QWidget | QMenu if close_on_trigger: assert root_menu is not None menu_parent = root_menu else: menu_parent = parent menu = QMenu(spec.title, menu_parent) group_map = group_map or {} entries = _normalize_separators(_sorted_entries(spec.entries)) for entry in entries: if isinstance(entry, ToolMenuSpec): submenu = _build_menu( parent, entry, group_map=group_map, root_menu=root_menu, close_on_trigger=close_on_trigger, ) menu.addMenu(submenu) elif isinstance(entry, ToolSeparatorSpec): menu.addSeparator() else: # ToolActionSpec action_parent: QWidget | QMenu if close_on_trigger: assert root_menu is not None action_parent = root_menu else: action_parent = parent action = QAction(entry.text, action_parent) action.triggered.connect(entry.triggered) if close_on_trigger: assert root_menu is not None action.triggered.connect(root_menu.close) if entry.shortcut: action.setShortcut(entry.shortcut) if entry.enabled_changed_signal is not None: # start disabled until signal fires action.setEnabled(False) entry.enabled_changed_signal.connect(action.setEnabled) action.setCheckable(entry.checkable) if entry.action_group: group = group_map.get(entry.action_group) if group is None: group = QActionGroup(menu) group.setExclusive(True) group_map[entry.action_group] = group group.addAction(action) action.setCheckable(True) if entry.toggled is not None: action.toggled.connect(entry.toggled) if entry.checked_changed_signal is not None: entry.checked_changed_signal.connect(action.setChecked) menu.addAction(action) return menu
[docs] def _merge_menu_specs(specs: Sequence[ToolMenuSpec]) -> List[ToolMenuSpec]: """ Merge a sequence of :class:`~radioviz.models.menu_spec.ToolMenuSpec` objects. Duplicate menus (identified by their title) are merged into a single specification, preserving the order and entries of each. The function returns a list containing only the top‑level menu specifications. :param specs: Sequence of menu specifications to merge. :type specs: Sequence[:class:`radioviz.models.menu_spec.ToolMenuSpec`] :return: List of merged top‑level menu specifications. :rtype: List[:class:`radioviz.models.menu_spec.ToolMenuSpec`] """ merged_entries = _merge_entries(list(specs)) return [entry for entry in merged_entries if isinstance(entry, ToolMenuSpec)]
[docs] def _merge_entries(entries: List[MenuEntry]) -> List[MenuEntry]: """ Recursively merge a list of :class:`~radioviz.models.menu_spec.MenuEntry` objects. Menu entries that are themselves :class:`~radioviz.models.menu_spec.ToolMenuSpec` and share the same title are combined: their ``order`` is reconciled and their child entries are merged. Non‑menu entries are passed through unchanged. :param entries: List of menu entries to merge. :type entries: List[:class:`radioviz.models.menu_spec.MenuEntry`] :return: List of merged menu entries. :rtype: List[:class:`radioviz.models.menu_spec.MenuEntry`] """ merged: List[MenuEntry] = [] menu_index: Dict[str, ToolMenuSpec] = {} for entry in entries: if isinstance(entry, ToolMenuSpec): existing = menu_index.get(entry.title) if existing is None: menu_index[entry.title] = entry merged.append(entry) continue if existing.order is None and entry.order is not None: existing.order = entry.order existing.entries = _merge_entries(existing.entries + entry.entries) else: merged.append(entry) return merged
[docs] def _sorted_entries(entries: Sequence[MenuEntry]) -> List[MenuEntry]: """ Return menu entries sorted by explicit order, then alphabetically. If any entry defines an ``order`` attribute, entries are sorted first by the presence of an order (unordered entries come last), then by the numeric order, followed by a case‑insensitive alphabetical key, and finally by their original position to preserve stability. When no entry defines an order, the original sequence is returned unchanged. :param entries: Sequence of menu entries to sort. :type entries: Sequence[:class:`radioviz.models.menu_spec.MenuEntry`] :return: List of sorted menu entries. :rtype: List[:class:`radioviz.models.menu_spec.MenuEntry`] """ has_order = any(_entry_order(entry) is not None for entry in entries) indexed = list(enumerate(entries)) if not has_order: return [entry for _, entry in indexed] def sort_key(item: Tuple[int, MenuEntry]) -> Tuple[int, int, str, int]: idx, entry = item order = _entry_order(entry) order_none = 1 if order is None else 0 order_value = order if order is not None else 0 alpha_key = _entry_alpha_key(entry) return (order_none, order_value, alpha_key, idx) sorted_items = sorted(indexed, key=sort_key) return [entry for _, entry in sorted_items]
[docs] def _entry_order(entry: MenuEntry) -> int | None: """ Retrieve the ``order`` attribute of a menu entry, if applicable. Both :class:`~radioviz.models.menu_spec.ToolMenuSpec` and :class:`~radioviz.models.menu_spec.ToolActionSpec` may define an ``order``. For other entry types ``None`` is returned. :param entry: The menu entry to inspect. :type entry: :class:`radioviz.models.menu_spec.MenuEntry` :return: The order value or ``None`` if not defined. :rtype: int | None """ if not hasattr(entry, 'order'): return None if isinstance(entry, ToolMenuSpec): return entry.order if isinstance(entry, ToolActionSpec): return entry.order return None
[docs] def _entry_alpha_key(entry: MenuEntry) -> str: """ Produce a case‑folded string key for alphabetical sorting of menu entries. For menu specifications the key is the title; for action specifications it is the displayed text. Non‑sortable entries return a placeholder that sorts after normal strings. :param entry: The menu entry to generate a key for. :type entry: :class:`radioviz.models.menu_spec.MenuEntry` :return: A lower‑cased string used as a sorting key. :rtype: str """ if isinstance(entry, ToolMenuSpec): return entry.title.casefold() if isinstance(entry, ToolActionSpec): return entry.text.casefold() return '{'
[docs] def _normalize_separators(entries: Sequence[MenuEntry]) -> List[MenuEntry]: """ Remove redundant and trailing separators from a sequence of menu entries. Consecutive :class:`~radioviz.models.menu_spec.ToolSeparatorSpec` objects are collapsed into a single separator, and any separator at the end of the list is discarded. The function returns a new list preserving the original order of non‑separator entries. :param entries: Sequence of menu entries possibly containing separators. :type entries: Sequence[:class:`radioviz.models.menu_spec.MenuEntry`] :return: List of entries with normalized separators. :rtype: List[:class:`radioviz.models.menu_spec.MenuEntry`] """ normalized: List[MenuEntry] = [] prev_was_sep = False for entry in entries: is_sep = isinstance(entry, ToolSeparatorSpec) if is_sep: if not normalized or prev_was_sep: continue normalized.append(entry) prev_was_sep = True continue normalized.append(entry) prev_was_sep = False while normalized and isinstance(normalized[-1], ToolSeparatorSpec): normalized.pop() return normalized