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