slint/internal/compiler/passes/lower_menus.rs
Olivier Goffart fb9a2c0f47 Simplify menu handling
Previously there were two kinds of Menu:
  1. "Simple" menu that don't have any `if` or `for`
  2. "Complex" menu that have `if` and `for`

For the first kind, we were generating in the compiler the `entries` and
`sub-menu` callback. This lead to more efficient and simple code at
runtime.
For the second kind, we generate an item tree so we can dynamically
produce them at runtime.

The issue is that as we added feature, the code became complex to
handle, even in the simple case as we need to create a `VRc<MenuVTable>`
also for the context menu so we can have native context menu.

We still need the "Simple" case for the internal though.
So for that I added a ShowPopupMenuInternal builtin function although it
only differ from ShowPopupMenu by the type of its second argument.
Since the generated code has lots in common, they are still handled
together.

The proof that the two different codepath were harmful is that removing
it showed a bug with contextmenu within repeated element.
the `contextmenu_delete.slint` started failling. It worked before
because it was only a problem with "Complex" menu and the test used a
"Simple" menu.

The change in the interpreter should also solve the issue #9031 which
were using the wrong item tree as the menu.
2025-08-15 12:07:46 +02:00

551 lines
20 KiB
Rust

// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
//! This pass lowers the `MenuBar` and `ContextMenuArea` as well as all their contents
//!
//! We can't have properties of type Model because that is not binary compatible with C++,
//! so all the code that handle model of MenuEntry need to be handle by code in the generated code
//! and transformed into a `SharedVector<MenuEntry>` that is passed to Slint runtime.
//!
//! ## MenuBar
//!
//! ```slint
//! Window {
//! menu-bar := MenuBar {
//! Menu {
//! title: "File";
//! if cond1 : MenuItem {
//! title: "A";
//! activated => { debug("A") }
//! }
//! Menu {
//! title: "B";
//! for x in 42 : MenuItem { title: "C" + x; }
//! }
//! }
//! }
//! content := ...
//! }
//! ```
//! Is transformed to
//! ```slint
//! Window {
//! menu-bar := VerticalLayout {
//! // these callbacks are connected by the setup_native_menu_bar call to an adapter from the menu tree
//! callback sub-menu(entry: MenuEntry);
//! callback activated();
//! if !Builtin.supports_native_menu_bar() : MenuBarImpl {
//! entries: parent.entries
//! sub-menu(..) => { parent.sub-menu(..) }
//! activated(..) => { parent.activated(..) }
//! }
//! Empty {
//! content := ...
//! }
//! }
//! init => {
//! // ... rest of init ...
//! // that function will always be called even for non-native.
//! // the menu-index is the index of the `Menu` element moved in the `object_tree::Component::menu_item_trees`
//! Builtin.setup_native_menu_bar(menu-bar.entries, menu-bar.sub-menu, menu-bar.activated, menu-index, no_native_menu)
//! }
//! }
//! ```
//!
//! ## ContextMenuInternal
//!
//! ```slint
//! menu := ContextMenuInternal {
//! entries: [...]
//! sub-menu => ...
//! activated => ...
//! }
//! Button { clicked => {menu.show({x: 0, y: 0;})} }
//! ```
//! Is transformed to
//!
//! ```slint
//! menu := ContextMenu {
//! property <[MenuEntry]> entries : ...
//! sub-menu => { ... }
//! activated => { ... }
//!
//! // show is actually a callback called by the native code when right clicking
//! show(point) => { Builtin.show_popup_menu(self, self.entries, &self.sub-menu, &self.activated, point) }
//! }
//! ```
//!
//! ## ContextMenuArea
//!
//! This is the same as ContextMenuInternal, but entries, sub-menu, and activated are generated
//! from the MenuItem similar to MenuBar
//!
//! We get a extra item tree in [`Component::menu_item_trees`]
//! and the call to `show_popup_menu` will be responsible to set the callback handler to the
//! `ContextMenu` item callbacks.
//!
//! ```slint
//! // A `ContextMenuArea` with a complex Menu with `if` and `for` will be lowered to:
//! menu := ContextMenu {
//! show(point) => {
//! // menu-index is an index in `Component::menu_item_trees`
//! // that function will set the handler to self.sub-menu and self.activated
//! Builtin.show_popup_menu(self, menu-index, &self.sub-menu, &self.activated, point)
//! }
//! }
//! ```
//!
use crate::diagnostics::{BuildDiagnostics, Spanned};
use crate::expression_tree::{BuiltinFunction, Callable, Expression, NamedReference};
use crate::langtype::{ElementType, Type};
use crate::object_tree::*;
use core::cell::RefCell;
use i_slint_common::MENU_SEPARATOR_PLACEHOLDER_TITLE;
use smol_str::{format_smolstr, SmolStr};
use std::rc::{Rc, Weak};
const HEIGHT: &str = "height";
const ENTRIES: &str = "entries";
const SUB_MENU: &str = "sub-menu";
const ACTIVATED: &str = "activated";
const SHOW: &str = "show";
struct UsefulMenuComponents {
menubar_impl: ElementType,
vertical_layout: ElementType,
context_menu_internal: ElementType,
empty: ElementType,
menu_entry: Type,
menu_item_element: ElementType,
}
pub async fn lower_menus(
doc: &mut Document,
type_loader: &mut crate::typeloader::TypeLoader,
diag: &mut BuildDiagnostics,
) {
// Ignore import errors
let mut build_diags_to_ignore = BuildDiagnostics::default();
let menubar_impl = type_loader
.import_component("std-widgets.slint", "MenuBarImpl", &mut build_diags_to_ignore)
.await
.expect("MenuBarImpl should be in std-widgets.slint");
let menu_item_element = type_loader
.global_type_registry
.borrow()
.lookup_builtin_element("ContextMenuArea")
.unwrap()
.as_builtin()
.additional_accepted_child_types
.get("Menu")
.expect("ContextMenuArea should accept Menu")
.additional_accepted_child_types
.get("MenuItem")
.expect("Menu should accept MenuItem")
.clone()
.into();
let useful_menu_component = UsefulMenuComponents {
menubar_impl: menubar_impl.clone().into(),
context_menu_internal: type_loader
.global_type_registry
.borrow()
.lookup_builtin_element("ContextMenuInternal")
.expect("ContextMenuInternal is a builtin type"),
vertical_layout: type_loader
.global_type_registry
.borrow()
.lookup_builtin_element("VerticalLayout")
.expect("VerticalLayout is a builtin type"),
empty: type_loader.global_type_registry.borrow().empty_type(),
menu_entry: type_loader.global_type_registry.borrow().lookup("MenuEntry"),
menu_item_element,
};
assert!(matches!(&useful_menu_component.menu_entry, Type::Struct(..)));
let mut has_menu = false;
let mut has_menubar = false;
doc.visit_all_used_components(|component| {
recurse_elem_including_sub_components_no_borrow(component, &(), &mut |elem, _| {
if matches!(&elem.borrow().builtin_type(), Some(b) if b.name == "Window") {
has_menubar |= process_window(elem, &useful_menu_component, type_loader.compiler_config.no_native_menu, diag);
}
if matches!(&elem.borrow().builtin_type(), Some(b) if matches!(b.name.as_str(), "ContextMenuArea" | "ContextMenuInternal")) {
has_menu |= process_context_menu(elem, &useful_menu_component, diag);
}
})
});
if has_menubar {
recurse_elem_including_sub_components_no_borrow(&menubar_impl, &(), &mut |elem, _| {
if matches!(&elem.borrow().builtin_type(), Some(b) if matches!(b.name.as_str(), "ContextMenuArea" | "ContextMenuInternal"))
{
has_menu |= process_context_menu(elem, &useful_menu_component, diag);
}
});
}
if has_menu {
let popup_menu_impl = type_loader
.import_component("std-widgets.slint", "PopupMenuImpl", &mut build_diags_to_ignore)
.await
.expect("PopupMenuImpl should be in std-widgets.slint");
{
let mut root = popup_menu_impl.root_element.borrow_mut();
for prop in [ENTRIES, SUB_MENU, ACTIVATED] {
match root.property_declarations.get_mut(prop) {
Some(d) => d.expose_in_public_api = true,
None => diag.push_error(format!("PopupMenuImpl doesn't have {prop}"), &*root),
}
}
root.property_analysis
.borrow_mut()
.entry(SmolStr::new_static(ENTRIES))
.or_default()
.is_set = true;
}
recurse_elem_including_sub_components_no_borrow(&popup_menu_impl, &(), &mut |elem, _| {
if matches!(&elem.borrow().builtin_type(), Some(b) if matches!(b.name.as_str(), "ContextMenuArea" | "ContextMenuInternal"))
{
process_context_menu(elem, &useful_menu_component, diag);
}
});
doc.popup_menu_impl = popup_menu_impl.into();
}
}
fn process_context_menu(
context_menu_elem: &ElementRc,
components: &UsefulMenuComponents,
diag: &mut BuildDiagnostics,
) -> bool {
let is_internal = matches!(&context_menu_elem.borrow().base_type, ElementType::Builtin(b) if b.name == "ContextMenuInternal");
if is_internal && context_menu_elem.borrow().property_declarations.contains_key(ENTRIES) {
// Already processed;
return false;
}
// generate the show callback
let source_location = Some(context_menu_elem.borrow().to_source_location());
let position = Expression::FunctionParameterReference {
index: 0,
ty: crate::typeregister::logical_point_type(),
};
let expr = if !is_internal {
let menu_element_type = context_menu_elem
.borrow()
.base_type
.as_builtin()
.additional_accepted_child_types
.get("Menu")
.expect("ContextMenu should accept Menu")
.clone()
.into();
context_menu_elem.borrow_mut().base_type = components.context_menu_internal.clone();
let mut menu_elem = None;
context_menu_elem.borrow_mut().children.retain(|x| {
if x.borrow().base_type == menu_element_type {
if menu_elem.is_some() {
diag.push_error(
"Only one Menu is allowed in a ContextMenu".into(),
&*x.borrow(),
);
} else {
menu_elem = Some(x.clone());
}
false
} else {
true
}
});
let Some(menu_elem) = menu_elem else {
diag.push_error(
"ContextMenuArea should have a Menu".into(),
&*context_menu_elem.borrow(),
);
return false;
};
if menu_elem.borrow().repeated.is_some() {
diag.push_error(
"ContextMenuArea's root Menu cannot be in a conditional or repeated element".into(),
&*menu_elem.borrow(),
);
}
let children = std::mem::take(&mut menu_elem.borrow_mut().children);
let c = lower_menu_items(context_menu_elem, children, components);
let item_tree_root = Expression::ElementReference(Rc::downgrade(&c.root_element));
for (name, _) in &components.context_menu_internal.property_list() {
if let Some(decl) = context_menu_elem.borrow().property_declarations.get(name) {
diag.push_error(format!("Cannot re-define internal property '{name}'"), &decl.node);
}
}
Expression::FunctionCall {
function: BuiltinFunction::ShowPopupMenu.into(),
arguments: vec![
Expression::ElementReference(Rc::downgrade(context_menu_elem)),
item_tree_root,
position,
],
source_location,
}
} else {
// `ContextMenuInternal`
// Materialize the entries property
context_menu_elem.borrow_mut().property_declarations.insert(
SmolStr::new_static(ENTRIES),
Type::Array(components.menu_entry.clone().into()).into(),
);
let entries = Expression::PropertyReference(NamedReference::new(
context_menu_elem,
SmolStr::new_static(ENTRIES),
));
Expression::FunctionCall {
function: BuiltinFunction::ShowPopupMenuInternal.into(),
arguments: vec![
Expression::ElementReference(Rc::downgrade(context_menu_elem)),
entries,
position,
],
source_location,
}
};
let old = context_menu_elem
.borrow_mut()
.bindings
.insert(SmolStr::new_static(SHOW), RefCell::new(expr.into()));
if let Some(old) = old {
diag.push_error("'show' is not a callback in ContextMenuArea".into(), &old.borrow().span);
}
true
}
fn process_window(
win: &ElementRc,
components: &UsefulMenuComponents,
no_native_menu: bool,
diag: &mut BuildDiagnostics,
) -> bool {
let mut window = win.borrow_mut();
let mut menu_bar = None;
window.children.retain(|x| {
if matches!(&x.borrow().base_type, ElementType::Builtin(b) if b.name == "MenuBar") {
if menu_bar.is_some() {
diag.push_error("Only one MenuBar is allowed in a Window".into(), &*x.borrow());
} else {
menu_bar = Some(x.clone());
}
false
} else {
true
}
});
let Some(menu_bar) = menu_bar else {
return false;
};
if menu_bar.borrow().repeated.is_some() {
diag.push_error(
"MenuBar cannot be in a conditional or repeated element".into(),
&*menu_bar.borrow(),
);
}
// Lower MenuItem's into a tree root
let children = std::mem::take(&mut menu_bar.borrow_mut().children);
let c = lower_menu_items(&menu_bar, children, components);
let item_tree_root = Expression::ElementReference(Rc::downgrade(&c.root_element));
let menubar_impl = Element {
id: format_smolstr!("{}-menulayout", window.id),
base_type: components.menubar_impl.clone(),
enclosing_component: window.enclosing_component.clone(),
repeated: (!no_native_menu).then(|| crate::object_tree::RepeatedElementInfo {
model: Expression::UnaryOp {
op: '!',
sub: Expression::FunctionCall {
function: BuiltinFunction::SupportsNativeMenuBar.into(),
arguments: vec![],
source_location: None,
}
.into(),
},
model_data_id: SmolStr::default(),
index_id: SmolStr::default(),
is_conditional_element: true,
is_listview: None,
}),
..Default::default()
}
.make_rc();
// Create a child that contains all the children of the window but the menubar
let child = Element {
id: format_smolstr!("{}-child", window.id),
base_type: components.empty.clone(),
enclosing_component: window.enclosing_component.clone(),
children: std::mem::take(&mut window.children),
..Default::default()
}
.make_rc();
let child_height = NamedReference::new(&child, SmolStr::new_static(HEIGHT));
let source_location = Some(menu_bar.borrow().to_source_location());
for prop in [ENTRIES, SUB_MENU, ACTIVATED] {
// materialize the properties and callbacks
let ty = components.menubar_impl.lookup_property(prop).property_type;
assert_ne!(ty, Type::Invalid, "Can't lookup type for {prop}");
let nr = NamedReference::new(&menu_bar, SmolStr::new_static(prop));
let forward_expr = if let Type::Callback(cb) = &ty {
Expression::FunctionCall {
function: Callable::Callback(nr),
arguments: cb
.args
.iter()
.enumerate()
.map(|(index, ty)| Expression::FunctionParameterReference {
index,
ty: ty.clone(),
})
.collect(),
source_location: source_location.clone(),
}
} else {
Expression::PropertyReference(nr)
};
menubar_impl.borrow_mut().bindings.insert(prop.into(), RefCell::new(forward_expr.into()));
let old = menu_bar
.borrow_mut()
.property_declarations
.insert(prop.into(), PropertyDeclaration { property_type: ty, ..Default::default() });
if let Some(old) = old {
diag.push_error(format!("Cannot re-define internal property '{prop}'"), &old.node);
}
}
// Transform the MenuBar in a layout
menu_bar.borrow_mut().base_type = components.vertical_layout.clone();
menu_bar.borrow_mut().children = vec![menubar_impl, child];
for prop in [ENTRIES, SUB_MENU, ACTIVATED] {
menu_bar
.borrow()
.property_analysis
.borrow_mut()
.entry(SmolStr::new_static(prop))
.or_default()
.is_set = true;
}
window.children.push(menu_bar.clone());
let component = window.enclosing_component.upgrade().unwrap();
drop(window);
// Rename every access to `root.height` into `child.height`
let win_height = NamedReference::new(win, SmolStr::new_static(HEIGHT));
crate::object_tree::visit_all_named_references(&component, &mut |nr| {
if nr == &win_height {
*nr = child_height.clone()
}
});
// except for the actual geometry
win.borrow_mut().geometry_props.as_mut().unwrap().height = win_height;
let mut arguments = vec![
Expression::PropertyReference(NamedReference::new(&menu_bar, SmolStr::new_static(ENTRIES))),
Expression::PropertyReference(NamedReference::new(
&menu_bar,
SmolStr::new_static(SUB_MENU),
)),
Expression::PropertyReference(NamedReference::new(
&menu_bar,
SmolStr::new_static(ACTIVATED),
)),
];
arguments.push(item_tree_root.into());
arguments.push(Expression::BoolLiteral(no_native_menu));
let setup_menubar = Expression::FunctionCall {
function: BuiltinFunction::SetupMenuBar.into(),
arguments,
source_location,
};
component.init_code.borrow_mut().constructor_code.push(setup_menubar.into());
true
}
/// Lower the MenuItem's and Menu's to either
/// - `entries` and `activated` and `sub-menu` properties/callback, in which cases it returns None
/// - or a Component which is a tree of MenuItem, in which case returns the component that is within the enclosing component's menu_item_trees
fn lower_menu_items(
parent: &ElementRc,
children: Vec<ElementRc>,
components: &UsefulMenuComponents,
) -> Rc<Component> {
let component = Rc::new_cyclic(|component_weak| {
let root_element = Rc::new(RefCell::new(Element {
base_type: components.empty.clone(),
children,
enclosing_component: component_weak.clone(),
..Default::default()
}));
recurse_elem(&root_element, &true, &mut |element: &ElementRc, is_root| {
if !is_root {
debug_assert!(Weak::ptr_eq(
&element.borrow().enclosing_component,
&parent.borrow().enclosing_component
));
element.borrow_mut().enclosing_component = component_weak.clone();
element.borrow_mut().geometry_props = None;
if element.borrow().base_type.type_name() == Some("MenuSeparator") {
element.borrow_mut().bindings.insert(
"title".into(),
RefCell::new(
Expression::StringLiteral(SmolStr::new_static(
MENU_SEPARATOR_PLACEHOLDER_TITLE,
))
.into(),
),
);
}
// Menu/MenuSeparator -> MenuItem
element.borrow_mut().base_type = components.menu_item_element.clone();
}
false
});
Component {
id: SmolStr::default(),
root_element,
parent_element: Rc::downgrade(parent),
..Default::default()
}
});
parent
.borrow()
.enclosing_component
.upgrade()
.unwrap()
.menu_item_tree
.borrow_mut()
.push(component.clone());
component
}