use serde_json::{json, Value};
use super::{bool_prop, js, str_prop, Builder, LNode, LayerPlacement, NodeKind, ToastSpec};
use crate::overlay::{OverlayAlign, OverlaySide};
use crate::theme::RecipeQuery;
use crate::types::UiNode;
pub fn is_context_menu(component: &str, props: &crate::types::Props) -> bool {
component == "Menu" && str_prop(props, "openOn") == Some("contextmenu")
}
pub const MAX_TOASTS: usize = 3;
impl Builder<'_, '_> {
fn layer_root_suffixed(&mut self, owner: &LNode, part: &str, suffix: &str, style: Value) -> (u32, u32) {
let (layer, root) = self.layer_root(owner, part, style);
if !suffix.is_empty() {
let id = format!("{}.{part}{suffix}", owner.id);
self.nodes[root as usize].id = id;
}
(layer, root)
}
pub(crate) fn overlay(&mut self, index: u32, node: &UiNode, scope: &str) {
let owner = self.nodes[index as usize].clone();
let layer_parent = owner.layer;
let kind = owner.component.clone();
let context_menu = is_context_menu(&owner.component, &owner.props);
let press_menu = kind == "Menu" && !context_menu;
let slot_trigger = if press_menu { node.children.first() } else { node.slots.as_ref().and_then(|s| s.get("trigger")) };
let fills = slot_trigger.is_some_and(|t| !matches!(t.component.as_str(), "Button" | "Toggle"));
if context_menu {
self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("column"))]);
} else if fills {
self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("row")), ("alignItems", json!("stretch"))]);
} else if slot_trigger.is_none() && !press_menu && kind != "Tooltip" {
self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("row")), ("alignSelf", json!("flex-start"))]);
} else {
self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("row")), ("alignItems", json!("flex-start"))]);
}
let mut anchor: Option<u32> = None;
if let Some(trigger) = slot_trigger {
anchor = self.add(trigger, Some(index), layer_parent, scope, false);
if let Some(a) = anchor.filter(|_| fills) {
self.style_default(a, &[("flexGrow", json!(1)), ("minWidth", json!(0))]);
}
} else if press_menu {
let label = owner.props.get("label").cloned().unwrap_or_else(|| Value::String(self.string("menu")));
let mut props = json!({"label": label, "variant": "outline"});
if let Some(icon) = owner.props.get("icon") {
props["icon"] = icon.clone();
if owner.props.get("label").is_none() {
props["size"] = json!("icon");
}
}
let t = self.part(&owner, "trigger", "Button", NodeKind::Leaf, json!({}), props);
anchor = Some(t);
} else if kind == "Tooltip" {
let wrap = self.part(&owner, "anchor", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row"}), json!({}));
self.nodes[wrap as usize].part_query = None;
self.children_of(wrap, node, scope, false);
anchor = Some(wrap);
} else if context_menu {
self.children_of(index, node, scope, false);
}
let open_on_hover = kind == "Tooltip" || str_prop(&owner.props, "openOn") == Some("hover");
if let Some(a) = anchor {
self.nodes[a as usize].pressable |= !open_on_hover;
self.nodes[a as usize].trigger_for = Some(owner.id.clone());
}
let open = self.is_open(&owner.id, &owner.props, false);
if let Some(a) = anchor.filter(|_| open) {
self.add_state(a, "open");
}
if !open {
return;
}
let side = self.responsive_prop(&owner.props, "side").and_then(|v| v.as_str().and_then(OverlaySide::parse));
let point = self.ctx.local.context_point.get(&owner.id).copied();
let placement = match kind.as_str() {
"Dialog" => LayerPlacement::Centered,
"Drawer" => LayerPlacement::Edge(side.unwrap_or(OverlaySide::Bottom)),
"Tooltip" => LayerPlacement::Anchored { anchor: anchor.unwrap_or(index), side: side.unwrap_or(OverlaySide::Top), align: OverlayAlign::Center },
"Menu" if context_menu => match point {
Some((x, y)) => LayerPlacement::AtPoint { x, y },
None => LayerPlacement::Anchored { anchor: index, side: OverlaySide::Bottom, align: OverlayAlign::Start },
},
"Menu" => LayerPlacement::Anchored { anchor: anchor.unwrap_or(index), side: OverlaySide::Bottom, align: OverlayAlign::Start },
_ => LayerPlacement::Anchored { anchor: anchor.unwrap_or(index), side: side.unwrap_or(OverlaySide::Bottom), align: OverlayAlign::Center },
};
let content_style = match kind.as_str() {
"Dialog" => json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.md", "padding": "$spacing.lg", "borderRadius": "$radius.xl", "width": "100%"}),
"Drawer" => match placement {
LayerPlacement::Edge(OverlaySide::Top | OverlaySide::Bottom) => json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.md", "padding": "$spacing.lg", "width": "100%"}),
_ => json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.md", "padding": "$spacing.lg", "height": "100%"}),
},
"Tooltip" => json!({"display": "flex", "flexDirection": "row", "paddingHorizontal": "$spacing.sm", "paddingVertical": "$spacing.xs", "borderRadius": "$radius.md"}),
"Menu" => json!({"display": "flex", "flexDirection": "column", "padding": "$spacing.xs", "borderRadius": "$radius.md", "minWidth": 160}),
_ => json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.md", "padding": "$spacing.md", "borderRadius": "$radius.md"}),
};
let (layer, root) = self.layer_root(&owner, "content", content_style);
if matches!(kind.as_str(), "Dialog" | "Drawer") {
let label = str_prop(&owner.props, "title").map(str::to_string).unwrap_or_else(|| self.string("dialog"));
self.default_a11y(root, "label", json!(label));
}
let owner_now = self.nodes[index as usize].clone();
let mut scroll_body = None;
let dismissible = owner.props.get("dismissible").and_then(Value::as_bool).unwrap_or(true);
match kind.as_str() {
"Dialog" | "Drawer" => {
if kind == "Drawer" && matches!(placement, LayerPlacement::Edge(OverlaySide::Bottom)) && owner.props.get("dragToDismiss").and_then(Value::as_bool).unwrap_or(true) {
self.part_in(root, &owner_now, "handle", "Box", NodeKind::Container, json!({"width": 40, "height": 4, "borderRadius": "$radius.full", "alignSelf": "center", "flexShrink": 0}), json!({}), "");
}
if let Some(t) = str_prop(&owner.props, "title") {
let t = t.to_string();
let n = self.text_part(root, &owner_now, "title", &t, "title", "");
self.nodes[n as usize].base_style.insert("flexShrink".into(), json!(0));
}
if let Some(d) = str_prop(&owner.props, "description") {
let d = d.to_string();
let n = self.text_part(root, &owner_now, "description", &d, "muted", "");
self.nodes[n as usize].base_style.insert("flexShrink".into(), json!(0));
}
let body = self.part_in(root, &owner_now, "body", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.md", "flexShrink": 1, "minHeight": 0, "overflowY": "auto"}), json!({}), "");
self.nodes[body as usize].part_query = None;
scroll_body = Some(body);
self.children_of(body, node, scope, false);
if let Some(footer) = node.slots.as_ref().and_then(|s| s.get("footer")) {
let f = self.part_in(root, &owner_now, "footer", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "justifyContent": "flex-end", "gap": "$spacing.sm", "flexShrink": 0}), json!({}), "");
self.add(footer, Some(f), layer, scope, false);
}
if kind == "Dialog" && dismissible {
let label = self.string("close");
let close = self.part_in(root, &owner_now, "close", "Button", NodeKind::Leaf, json!({"position": "absolute", "top": "$spacing.sm", "insetInlineEnd": "$spacing.sm"}), json!({"icon": super::builtin_icon("Dialog.close"), "variant": "ghost", "size": "icon", "label": label}), "");
self.nodes[close as usize].pressable = true;
}
}
"Tooltip" => {
let text = owner.props.get("content").map(js).unwrap_or_default();
self.text_part(root, &owner_now, "label", &text, "caption", "");
}
"Menu" => {
let items = owner.props.get("items").and_then(Value::as_array).cloned().unwrap_or_default();
self.menu_items(root, &owner_now, &items, "", true);
scroll_body = Some(root);
}
_ => {
self.children_of(root, node, scope, false);
}
}
let modal = kind == "Dialog" || kind == "Drawer";
self.push_layer(layer, root, &owner_now, placement, modal, dismissible, false, scroll_body);
}
pub(crate) fn menu_items(&mut self, parent: u32, owner: &LNode, items: &[Value], prefix: &str, top: bool) {
let open_sub = if top { self.ctx.local.submenu.get(&owner.id).copied() } else { None };
for (i, item) in items.iter().enumerate() {
let suffix = format!("{prefix}.{i}");
let kind = item.get("kind").and_then(Value::as_str).unwrap_or(if item.get("separator").and_then(Value::as_bool) == Some(true) { "separator" } else { "item" });
match kind {
"separator" => {
self.part_in(parent, owner, "separator", "Box", NodeKind::Container, json!({"height": 1, "marginVertical": "$spacing.xs", "flexShrink": 0}), json!({}), &suffix);
}
"label" => {
let text = item.get("label").map(js).unwrap_or_default();
self.text_part(parent, owner, "label", &text, "caption", &suffix);
}
_ => {
let disabled = item.get("disabled").and_then(Value::as_bool).unwrap_or(false);
let checked = item.get("checked").and_then(Value::as_bool).unwrap_or(false);
let label = item.get("label").map(js).unwrap_or_default();
let mut props = json!({"text": label, "value": item.get("value").cloned().unwrap_or(Value::Null), "kind": kind, "disabled": disabled, "lines": 1});
if kind == "checkbox" {
props["checked"] = json!(checked);
}
if item.get("destructive").and_then(Value::as_bool) == Some(true) {
props["destructive"] = json!(true);
}
let row = self.part_in(parent, owner, "item", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "gap": "$spacing.sm"}), props, &suffix);
self.nodes[row as usize].pressable = !disabled;
if disabled {
self.add_state(row, "disabled");
}
if kind == "checkbox" {
if checked {
self.add_state(row, "checked");
}
let glyph = format!("{}.check", owner.component);
let c = self.icon_part(row, owner, "check", &glyph, &suffix);
if !checked {
self.nodes[c as usize].base_style.insert("visibility".into(), json!("hidden"));
}
}
let mut label_props = json!({"text": label, "lines": 1});
if let Some(icon) = item.get("icon") {
label_props["icon"] = icon.clone();
}
let l = self.part_in(row, owner, "itemLabel", "Text", NodeKind::Leaf, json!({"flexGrow": 1, "flexShrink": 1, "minWidth": 0}), label_props, &suffix);
self.nodes[l as usize].part_query = None;
if let Some(shortcut) = item.get("shortcut").map(js) {
self.text_part(row, owner, "shortcut", &shortcut, "muted", &suffix);
}
if kind == "submenu" {
let glyph = format!("{}.submenuIndicator", owner.component);
self.icon_part(row, owner, "submenuIndicator", &glyph, &suffix);
if open_sub == Some(i) {
self.add_state(row, "open");
let sub_items = item.get("items").and_then(Value::as_array).cloned().unwrap_or_default();
let searchable = item.get("searchable").and_then(Value::as_bool) == Some(true);
let style = if searchable {
json!({"display": "flex", "flexDirection": "column", "padding": "$spacing.xs", "borderRadius": "$radius.md", "minWidth": 240})
} else {
json!({"display": "flex", "flexDirection": "column", "padding": "$spacing.xs", "borderRadius": "$radius.md", "minWidth": 160})
};
let (layer, root) = self.layer_root_suffixed(owner, "content", &suffix, style);
let scroll = if searchable {
self.menu_search(root, owner, item, &suffix);
self.slot_of_id(&format!("{}.list{suffix}", owner.id))
} else {
self.menu_items(root, owner, &sub_items, &suffix, false);
Some(root)
};
self.push_layer(layer, root, owner, LayerPlacement::Anchored { anchor: row, side: OverlaySide::Right, align: OverlayAlign::Start }, false, true, false, scroll);
if let Some(spec) = self.layers.last_mut() {
spec.mirror = true;
}
}
}
}
}
}
}
pub(crate) fn toast(&mut self, index: u32, node: &UiNode) {
self.nodes[index as usize].hidden = true;
self.nodes[index as usize].kind = NodeKind::Leaf;
let open = self.is_open(&node.id, &self.nodes[index as usize].props.clone(), true);
if open {
self.pending_toasts.push((index, node.clone()));
}
}
pub(crate) fn flush_toasts(&mut self) {
let pending = std::mem::take(&mut self.pending_toasts);
let skip = pending.len().saturating_sub(MAX_TOASTS);
for (order, (index, _node)) in pending.into_iter().skip(skip).rev().enumerate() {
let owner = self.nodes[index as usize].clone();
let kind = str_prop(&owner.props, "type").unwrap_or("info").to_string();
let (layer, root) = self.layer_root(&owner, "root", json!({"display": "flex", "flexDirection": "row", "alignItems": "flex-start", "gap": "$spacing.sm", "padding": "$spacing.md", "borderRadius": "$radius.lg", "minWidth": 240, "maxWidth": 420}));
self.nodes[root as usize].live = Some(if kind == "error" { "assertive" } else { "polite" }.into());
self.query_prop(root, "type", Value::String(kind.clone()));
let glyph = format!("Toast.icon.{kind}");
self.icon_part(root, &owner, "icon", &glyph, "");
let body = self.part_in(root, &owner, "body", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.xxs", "flexGrow": 1, "flexShrink": 1, "minWidth": 0}), json!({}), "");
self.nodes[body as usize].part_query = None;
let title = owner.props.get("title").map(js).unwrap_or_default();
self.text_part(body, &owner, "title", &title, "label", "");
if let Some(d) = owner.props.get("description").map(js) {
self.text_part(body, &owner, "description", &d, "muted", "");
}
if let Some(label) = owner.props.get("actionLabel").map(js) {
let a = self.part_in(root, &owner, "action", "Button", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"label": label, "variant": "outline", "size": "sm"}), "");
self.nodes[a as usize].pressable = true;
self.nodes[a as usize].own_query = Some(RecipeQuery::new("Button", "root", self.ctx.recipes.native_recipe_props("Button", &self.nodes[a as usize].props.clone()), Vec::new()));
}
let dismissible = owner.props.get("dismissible").and_then(Value::as_bool).unwrap_or(true);
if dismissible {
let label = self.string("dismiss");
let c = self.part_in(root, &owner, "close", "Button", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"icon": super::builtin_icon("Toast.close"), "label": label, "variant": "ghost", "size": "icon"}), "");
self.nodes[c as usize].pressable = true;
}
self.push_layer(layer, root, &owner, LayerPlacement::Toast { order: order as u32 }, false, dismissible, false, None);
let duration = owner.props.get("duration").and_then(Value::as_f64).unwrap_or(5000.0);
self.toasts.push(ToastSpec { id: owner.id.clone(), duration_ms: duration, kind });
}
let _ = bool_prop;
}
}