slint/internal/compiler/passes/lower_menus.rs
Olivier Goffart ca0c75bfe1 Menu: don't remove the title properties
Let further pass materialize them, and most likely remove them anyway.
That way, they can be properly set and read from the rest of the .slint
code.

Fixes #8080
2025-04-08 13:30:01 +02:00

685 lines
24 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
//! Passe lower 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";
//! MenuItem {
//! title: "A";
//! activated => { ... }
//! }
//! Menu {
//! title: "B";
//! MenuItem { title: "C"; }
//! }
//! }
//! }
//! content := ...
//! }
//! ```
//! Is transformed to
//! ```slint
//! Window {
//! menu-bar := VerticalLayout {
//! property <[MenuEntry]> entries : [ { id: "0", title: "File", has-sub-menu: true } ];
//! callback sub-menu(entry: MenuEntry) => {
//! if(entry.id == "0") { return [ { id: "1", title: "A" }, { id: "2", title: "B", has-sub-menu: true } ]; }
//! else if(entry.id == "2") { return [ { id: "3", title: "C" } ]; } else { return []; }
//! }
//! callback activated() => { if (entry.id == "2") { ... } }
//! if !Builtin.supports_native_menu_bar() : MenuBarImpl {
//! entries: parent.entries
//! sub-menu(..) => { parent.sub-menu(..) }
//! activated(..) => { parent.activated(..) }
//! }
//! Empty {
//! content := ...
//! }
//! }
//! init => {
//! // ... rest of init ...
//! Builtin.setup_native_menu_bar(menu-bar.entries, menu-bar.sub-menu, menu-bar.activated)
//! }
//! }
//! ```
//!
//! ## 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
//! callback show(point) => { Builtin.show_context_menu(entries, sub-menu, activated, point) }
//! }
//!
//! ## ContextMenuArea
//!
//! This is the same as ContextMenuInternal, but entries, sub-menu, and activated are generated
//! from the MenuItem similar to MenuBar
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::collections::HashMap;
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;
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_menu |= process_window(elem, &useful_menu_component, 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_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");
let item_tree_root = if !is_internal {
// Lower Menu into entries
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 item_tree_root = if let Some(menu_elem) = menu_elem {
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);
lower_menu_items(context_menu_elem, children, components)
.map(|c| Expression::ElementReference(Rc::downgrade(&c.root_element)))
} else {
diag.push_error(
"ContextMenuArea should have a Menu".into(),
&*context_menu_elem.borrow(),
);
None
};
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);
}
}
item_tree_root
} else {
None
};
let entries = if let Some(item_tree_root) = item_tree_root {
item_tree_root
} else {
// Materialize the entries property
context_menu_elem.borrow_mut().property_declarations.insert(
SmolStr::new_static(ENTRIES),
Type::Array(components.menu_entry.clone().into()).into(),
);
Expression::PropertyReference(NamedReference::new(
context_menu_elem,
SmolStr::new_static(ENTRIES),
))
};
// generate the show callback
let source_location = Some(context_menu_elem.borrow().to_source_location());
let expr = Expression::FunctionCall {
function: BuiltinFunction::ShowPopupMenu.into(),
arguments: vec![
Expression::ElementReference(Rc::downgrade(context_menu_elem)),
entries,
Expression::FunctionParameterReference {
index: 0,
ty: crate::typeregister::logical_point_type(),
},
],
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,
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 entries
let children = std::mem::take(&mut menu_bar.borrow_mut().children);
let item_tree_root = if !children.is_empty() {
lower_menu_items(&menu_bar, children, components)
.map(|c| Expression::ElementReference(Rc::downgrade(&c.root_element)))
} else {
None
};
let menubar_impl = Element {
id: format_smolstr!("{}-menulayout", window.id),
base_type: components.menubar_impl.clone(),
enclosing_component: window.enclosing_component.clone(),
repeated: Some(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];
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),
)),
];
if let Some(item_tree_root) = item_tree_root {
arguments.push(item_tree_root.into());
}
for prop in [ENTRIES, SUB_MENU, ACTIVATED] {
menu_bar
.borrow()
.property_analysis
.borrow_mut()
.entry(SmolStr::new_static(prop))
.or_default()
.is_set = true;
}
let setup_menubar = Expression::FunctionCall {
function: BuiltinFunction::SetupNativeMenuBar.into(),
arguments,
source_location: source_location.clone(),
};
window.children.push(menu_bar);
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;
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,
) -> Option<Rc<Component>> {
let mut has_repeated = false;
for i in &children {
recurse_elem(i, &(), &mut |e, _| {
if e.borrow().repeated.is_some() {
has_repeated = true;
}
});
if has_repeated {
break;
}
}
if !has_repeated {
let menu_entry = &components.menu_entry;
let mut state = GenMenuState {
id: 0,
menu_entry: menu_entry.clone(),
activate: Vec::new(),
sub_menu: Vec::new(),
};
let entries = generate_menu_entries(children.into_iter(), &mut state);
parent.borrow_mut().bindings.insert(
ENTRIES.into(),
RefCell::new(
Expression::Array { element_ty: menu_entry.clone(), values: entries }.into(),
),
);
let entry_id = Expression::StructFieldAccess {
base: Expression::FunctionParameterReference { index: 0, ty: menu_entry.clone() }
.into(),
name: SmolStr::new_static("id"),
};
let sub_entries = build_cases_function(
&entry_id,
Expression::Array { element_ty: menu_entry.clone(), values: vec![] },
state.sub_menu,
);
parent.borrow_mut().bindings.insert(SUB_MENU.into(), RefCell::new(sub_entries.into()));
let activated =
build_cases_function(&entry_id, Expression::CodeBlock(vec![]), state.activate);
parent.borrow_mut().bindings.insert(ACTIVATED.into(), RefCell::new(activated.into()));
None
} else {
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 {
node: parent.borrow().debug.first().map(|n| n.node.clone().into()),
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());
Some(component)
}
}
fn build_cases_function(
entry_id: &Expression,
default_case: Expression,
cases: Vec<(SmolStr, Expression)>,
) -> Expression {
let mut result = default_case;
for (id, expr) in cases.into_iter().rev() {
result = Expression::Condition {
condition: Expression::BinaryExpression {
lhs: entry_id.clone().into(),
rhs: Expression::StringLiteral(id).into(),
op: '=',
}
.into(),
true_expr: expr.into(),
false_expr: result.into(),
}
}
result
}
struct GenMenuState {
id: usize,
/// Maps `entry.id` to the callback
activate: Vec<(SmolStr, Expression)>,
/// Maps `entry.id` to the sub-menu entries
sub_menu: Vec<(SmolStr, Expression)>,
menu_entry: Type,
}
/// Recursively generate the menu entries for the given menu items
fn generate_menu_entries(
menu_items: impl Iterator<Item = ElementRc>,
state: &mut GenMenuState,
) -> Vec<Expression> {
let mut entries = Vec::new();
let mut last_is_separator = false;
for item in menu_items {
let mut borrow_mut = item.borrow_mut();
let base_name = borrow_mut.base_type.type_name().unwrap();
let is_sub_menu = base_name == "Menu";
let is_separator = base_name == "MenuSeparator";
if !is_sub_menu && !is_separator {
assert_eq!(base_name, "MenuItem");
}
if is_separator && (last_is_separator || entries.is_empty()) {
continue;
}
last_is_separator = is_separator;
borrow_mut
.enclosing_component
.upgrade()
.unwrap()
.optimized_elements
.borrow_mut()
.push(item.clone());
assert!(borrow_mut.repeated.is_none());
let mut values = HashMap::<SmolStr, Expression>::new();
state.id += 1;
let id_str = format_smolstr!("{}", state.id);
values.insert(SmolStr::new_static("id"), Expression::StringLiteral(id_str.clone()));
if let Some(callback) = borrow_mut.bindings.remove(ACTIVATED) {
state.activate.push((id_str.clone(), callback.into_inner().expression));
}
if is_sub_menu {
let sub_entries =
generate_menu_entries(std::mem::take(&mut borrow_mut.children).into_iter(), state);
state.sub_menu.push((
id_str,
Expression::Array { element_ty: state.menu_entry.clone(), values: sub_entries },
));
values
.insert(SmolStr::new_static("has-sub-menu"), Expression::BoolLiteral(true).into());
}
drop(borrow_mut);
if !is_separator {
for prop in ["title"] {
if item.borrow().bindings.contains_key(prop) {
let n = SmolStr::new_static(prop);
values.insert(
n.clone(),
Expression::PropertyReference(NamedReference::new(&item, n)),
);
}
}
} else {
values.insert(SmolStr::new_static("is_separator"), Expression::BoolLiteral(true));
}
entries.push(mk_struct(state.menu_entry.clone(), values));
}
if last_is_separator {
entries.pop();
}
entries
}
fn mk_struct(ty: Type, mut values: HashMap<SmolStr, Expression>) -> Expression {
let Type::Struct(ty) = ty else { panic!("Not a struct") };
for (k, v) in ty.fields.iter() {
values.entry(k.clone()).or_insert_with(|| Expression::default_value_for_type(v));
}
Expression::Struct { ty, values }
}