diff --git a/crates/op-orchestrator/src/cleanup.rs b/crates/op-orchestrator/src/cleanup.rs index ce7757d8b..e18fc03cc 100644 --- a/crates/op-orchestrator/src/cleanup.rs +++ b/crates/op-orchestrator/src/cleanup.rs @@ -11,7 +11,7 @@ use crate::cleanup_typography::repair_overbold_text_hierarchy; use crate::plan::OrchestratorPlan; use crate::types::DocSink; use jian_ops_schema::node::{ - container::{ContainerProps, LayoutMode, Padding}, + container::{ContainerProps, JustifyContent, LayoutMode, Padding}, PenNode, }; use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword}; @@ -98,6 +98,18 @@ pub(crate) fn remove_duplicate_bottom_nav_sections_for_all_roots(sink: &mut dyn } } +pub(crate) fn distribute_bottom_nav_tabs_for_all_roots(sink: &mut dyn DocSink) { + let root_ids: Vec = sink + .state() + .active_children() + .iter() + .map(|node| node.id_str().to_string()) + .collect(); + for root_id in root_ids { + distribute_bottom_nav_tabs(sink, &root_id); + } +} + pub(crate) fn collapse_nested_horizontal_padding_for_all_roots(sink: &mut dyn DocSink) { let root_ids: Vec = sink .state() @@ -271,6 +283,116 @@ fn remove_duplicate_bottom_nav_sections(sink: &mut dyn DocSink, root_id: &str) { } } +fn distribute_bottom_nav_tabs(sink: &mut dyn DocSink, root_id: &str) { + let repairs: Vec = { + let Some(root) = find_root(sink.state(), root_id) else { + return; + }; + let mut repairs = Vec::new(); + collect_bottom_nav_tab_distribution_repairs(root, &mut repairs); + repairs + }; + + for repair in repairs { + for tab_id in repair.tabs_to_fill { + sink.apply(EditorCommand::SetNodeLayoutProp { + node_id: tab_id, + property: "width".to_string(), + value: LayoutPropValue::Keyword("fill_container".to_string()), + }); + } + if repair.set_row_justify { + sink.apply(EditorCommand::SetNodeLayoutProp { + node_id: repair.row_id, + property: "justifyContent".to_string(), + value: LayoutPropValue::Keyword("space_between".to_string()), + }); + } + } +} + +#[derive(Debug, Clone)] +struct BottomNavTabDistributionRepair { + row_id: NodeId, + tabs_to_fill: Vec, + set_row_justify: bool, +} + +fn collect_bottom_nav_tab_distribution_repairs( + node: &PenNode, + repairs: &mut Vec, +) { + if let Some(repair) = bottom_nav_tab_distribution_repair(node) { + repairs.push(repair); + } + if let Some(children) = node.children() { + for child in children { + collect_bottom_nav_tab_distribution_repairs(child, repairs); + } + } +} + +fn bottom_nav_tab_distribution_repair(node: &PenNode) -> Option { + let props = frame_container_props(node)?; + if props.layout.as_ref() != Some(&LayoutMode::Horizontal) { + return None; + } + let children = node.children()?; + if children.len() < 3 || !children.iter().all(is_vertical_icon_label_tab_frame) { + return None; + } + + let tabs_to_fill = children + .iter() + .filter_map(|child| { + let child_props = frame_container_props(child)?; + (!width_is_fill_container(child_props)).then(|| NodeId::new(child.id_str().to_string())) + }) + .collect::>(); + let set_row_justify = !matches!( + props.justify_content.as_ref(), + Some(JustifyContent::SpaceBetween) | Some(JustifyContent::SpaceAround) + ); + + (!tabs_to_fill.is_empty() || set_row_justify).then(|| BottomNavTabDistributionRepair { + row_id: NodeId::new(node.id_str().to_string()), + tabs_to_fill, + set_row_justify, + }) +} + +fn is_vertical_icon_label_tab_frame(node: &PenNode) -> bool { + let Some(props) = frame_container_props(node) else { + return false; + }; + if props.layout.as_ref() != Some(&LayoutMode::Vertical) { + return false; + } + let mut has_icon = false; + let mut has_text = false; + collect_icon_label_descendants(node, &mut has_icon, &mut has_text); + has_icon && has_text +} + +fn collect_icon_label_descendants(node: &PenNode, has_icon: &mut bool, has_text: &mut bool) { + if *has_icon && *has_text { + return; + } + if let Some(children) = node.children() { + for child in children { + match child { + PenNode::IconFont(_) => *has_icon = true, + PenNode::Text(_) => *has_text = true, + _ => {} + } + collect_icon_label_descendants(child, has_icon, has_text); + if *has_icon && *has_text { + return; + } + } + } +} + /// Fixed-height vertical mobile artboards must keep direct content sections /// content-driven; fill-height direct children can consume all leftover space /// when a sibling subtask emits zero nodes. @@ -1341,6 +1463,7 @@ pub fn run_cleanup_passes(sink: &mut dyn DocSink, plan: &OrchestratorPlan, root_ remove_duplicate_status_bars(sink, rid); remove_duplicate_bottom_nav_sections(sink, rid); + distribute_bottom_nav_tabs(sink, rid); collapse_nested_horizontal_padding(sink, rid); expand_absolute_container_to_children(sink, rid); pad_clipping_horizontal_row_for_stroke(sink, rid); @@ -1496,6 +1619,10 @@ mod tests_mobile_chrome; #[path = "cleanup_mobile_bottom_nav_dedup_tests.rs"] mod tests_mobile_bottom_nav_dedup; +#[cfg(test)] +#[path = "cleanup_bottom_nav_tests.rs"] +mod tests_bottom_nav; + #[cfg(test)] #[path = "cleanup_nested_horizontal_padding_tests.rs"] mod tests_nested_horizontal_padding; diff --git a/crates/op-orchestrator/src/cleanup_bottom_nav_tests.rs b/crates/op-orchestrator/src/cleanup_bottom_nav_tests.rs new file mode 100644 index 000000000..4ee051f86 --- /dev/null +++ b/crates/op-orchestrator/src/cleanup_bottom_nav_tests.rs @@ -0,0 +1,352 @@ +use super::*; +use crate::test_support::VecDocSink; +use serde_json::{json, Value}; + +fn insert_tree(sink: &mut VecDocSink, json: &str) { + let tree: PenNode = serde_json::from_str(json).expect("test tree json"); + sink.state.apply(EditorCommand::InsertAuthoredSubtree { + nodes: vec![tree], + parent_id: NodeId::NONE, + page_id: None, + }); + sink.applied.clear(); +} + +fn find_node<'a>(node: &'a PenNode, id: &str) -> Option<&'a PenNode> { + if node.id_str() == id { + return Some(node); + } + node.children()? + .iter() + .find_map(|child| find_node(child, id)) +} + +fn find_active_node<'a>(sink: &'a VecDocSink, id: &str) -> &'a PenNode { + sink.state + .active_children() + .iter() + .find_map(|node| find_node(node, id)) + .expect("node exists") +} + +fn node_json(sink: &VecDocSink, id: &str) -> Value { + serde_json::to_value(find_active_node(sink, id)).expect("serialize node") +} + +#[test] +fn bunched_bottom_nav_tabs_distributed() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Mobile Root", + "width": 390, + "height": 844, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "bottom-nav", + "name": "Bottom Navigation", + "width": "fill_container", + "height": 72, + "layout": "horizontal", + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "home-tab", + "name": "Home Tab", + "width": 68, + "height": "fit_content", + "layout": "vertical", + "children": [ + { "type": "icon_font", "id": "home-icon", "name": "Home Icon", "iconFontName": "home", "width": 22, "height": 22 }, + { "type": "text", "id": "home-label", "name": "Home Label", "content": "Home", "width": "fit_content", "height": 16 } + ] + }, + { + "type": "frame", + "id": "search-tab", + "name": "Search Tab", + "width": 68, + "height": "fit_content", + "layout": "vertical", + "children": [ + { "type": "icon_font", "id": "search-icon", "name": "Search Icon", "iconFontName": "search", "width": 22, "height": 22 }, + { "type": "text", "id": "search-label", "name": "Search Label", "content": "Search", "width": "fit_content", "height": 16 } + ] + }, + { + "type": "frame", + "id": "cart-tab", + "name": "Cart Tab", + "width": 68, + "height": "fit_content", + "layout": "vertical", + "children": [ + { "type": "icon_font", "id": "cart-icon", "name": "Cart Icon", "iconFontName": "shopping-cart", "width": 22, "height": 22 }, + { "type": "text", "id": "cart-label", "name": "Cart Label", "content": "Cart", "width": "fit_content", "height": 16 } + ] + }, + { + "type": "frame", + "id": "profile-tab", + "name": "Profile Tab", + "width": 68, + "height": "fit_content", + "layout": "vertical", + "children": [ + { "type": "icon_font", "id": "profile-icon", "name": "Profile Icon", "iconFontName": "user", "width": 22, "height": 22 }, + { "type": "text", "id": "profile-label", "name": "Profile Label", "content": "Profile", "width": "fit_content", "height": 16 } + ] + } + ] + } + ] + }"##, + ); + + distribute_bottom_nav_tabs(&mut sink, "root"); + + assert_eq!( + node_json(&sink, "bottom-nav")["justifyContent"], + json!("space_between") + ); + for tab_id in ["home-tab", "search-tab", "cart-tab", "profile-tab"] { + assert_eq!(node_json(&sink, tab_id)["width"], json!("fill_container")); + } +} + +#[test] +fn date_chip_row_untouched() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Mobile Root", + "width": 390, + "height": 844, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "date-row", + "name": "Date Chips", + "width": "fill_container", + "height": 40, + "layout": "horizontal", + "justifyContent": "start", + "children": [ + { + "type": "frame", + "id": "today-chip", + "name": "Today Chip", + "width": 96, + "height": 36, + "layout": "horizontal", + "children": [ + { "type": "icon_font", "id": "today-icon", "iconFontName": "calendar", "width": 16, "height": 16 }, + { "type": "text", "id": "today-label", "content": "Today", "width": "fit_content", "height": 16 } + ] + }, + { + "type": "frame", + "id": "tomorrow-chip", + "name": "Tomorrow Chip", + "width": 116, + "height": 36, + "layout": "horizontal", + "children": [ + { "type": "icon_font", "id": "tomorrow-icon", "iconFontName": "calendar", "width": 16, "height": 16 }, + { "type": "text", "id": "tomorrow-label", "content": "Tomorrow", "width": "fit_content", "height": 16 } + ] + }, + { + "type": "frame", + "id": "weekend-chip", + "name": "Weekend Chip", + "width": 112, + "height": 36, + "layout": "horizontal", + "children": [ + { "type": "icon_font", "id": "weekend-icon", "iconFontName": "calendar", "width": 16, "height": 16 }, + { "type": "text", "id": "weekend-label", "content": "Weekend", "width": "fit_content", "height": 16 } + ] + } + ] + } + ] + }"##, + ); + let before = node_json(&sink, "date-row"); + + distribute_bottom_nav_tabs(&mut sink, "root"); + + assert_eq!(node_json(&sink, "date-row"), before); +} + +#[test] +fn two_tab_row_untouched() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Mobile Root", + "width": 390, + "height": 844, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "two-tab-row", + "name": "Two Tabs", + "width": "fill_container", + "height": 72, + "layout": "horizontal", + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "left-tab", + "width": 96, + "height": "fit_content", + "layout": "vertical", + "children": [ + { "type": "icon_font", "id": "left-icon", "iconFontName": "home", "width": 22, "height": 22 }, + { "type": "text", "id": "left-label", "content": "Home", "width": "fit_content", "height": 16 } + ] + }, + { + "type": "frame", + "id": "right-tab", + "width": 96, + "height": "fit_content", + "layout": "vertical", + "children": [ + { "type": "icon_font", "id": "right-icon", "iconFontName": "user", "width": 22, "height": 22 }, + { "type": "text", "id": "right-label", "content": "Profile", "width": "fit_content", "height": 16 } + ] + } + ] + } + ] + }"##, + ); + let before = node_json(&sink, "two-tab-row"); + + distribute_bottom_nav_tabs(&mut sink, "root"); + + assert_eq!(node_json(&sink, "two-tab-row"), before); +} + +#[test] +fn already_distributed_untouched() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Mobile Root", + "width": 390, + "height": 844, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "bottom-nav", + "name": "Bottom Navigation", + "width": "fill_container", + "height": 72, + "layout": "horizontal", + "justifyContent": "space_between", + "children": [ + { + "type": "frame", + "id": "home-tab", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "children": [ + { "type": "icon_font", "id": "home-icon", "iconFontName": "home", "width": 22, "height": 22 }, + { "type": "text", "id": "home-label", "content": "Home", "width": "fit_content", "height": 16 } + ] + }, + { + "type": "frame", + "id": "search-tab", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "children": [ + { "type": "icon_font", "id": "search-icon", "iconFontName": "search", "width": 22, "height": 22 }, + { "type": "text", "id": "search-label", "content": "Search", "width": "fit_content", "height": 16 } + ] + }, + { + "type": "frame", + "id": "profile-tab", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "children": [ + { "type": "icon_font", "id": "profile-icon", "iconFontName": "user", "width": 22, "height": 22 }, + { "type": "text", "id": "profile-label", "content": "Profile", "width": "fit_content", "height": 16 } + ] + } + ] + } + ] + }"##, + ); + let before = node_json(&sink, "bottom-nav"); + + distribute_bottom_nav_tabs(&mut sink, "root"); + + assert_eq!(node_json(&sink, "bottom-nav"), before); + assert!(sink.applied.is_empty()); +} + +#[test] +fn non_tab_horizontal_row_untouched() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Mobile Root", + "width": 390, + "height": 844, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "plain-row", + "name": "Plain Horizontal Row", + "width": "fill_container", + "height": 72, + "layout": "horizontal", + "justifyContent": "end", + "children": [ + { "type": "frame", "id": "plain-a", "width": 68, "height": 44, "layout": "vertical", "children": [] }, + { "type": "frame", "id": "plain-b", "width": 68, "height": 44, "layout": "vertical", "children": [] }, + { "type": "frame", "id": "plain-c", "width": 68, "height": 44, "layout": "vertical", "children": [] } + ] + } + ] + }"##, + ); + let before = node_json(&sink, "plain-row"); + + distribute_bottom_nav_tabs(&mut sink, "root"); + + assert_eq!(node_json(&sink, "plain-row"), before); +} diff --git a/crates/op-orchestrator/src/loop_finalize.rs b/crates/op-orchestrator/src/loop_finalize.rs index 07d8c2292..571312dfc 100644 --- a/crates/op-orchestrator/src/loop_finalize.rs +++ b/crates/op-orchestrator/src/loop_finalize.rs @@ -447,6 +447,7 @@ pub fn apply_loop_finalize(state: &mut EditorState) { let mut sink = StateDocSink { state: &mut *state }; crate::abandoned_duplicate_roots::remove_abandoned_duplicate_roots(&mut sink); crate::cleanup::remove_duplicate_bottom_nav_sections_for_all_roots(&mut sink); + crate::cleanup::distribute_bottom_nav_tabs_for_all_roots(&mut sink); crate::cleanup::collapse_nested_horizontal_padding_for_all_roots(&mut sink); crate::cleanup::expand_absolute_container_to_children_for_all_roots(&mut sink); crate::cleanup::pad_clipping_horizontal_row_for_stroke_for_all_roots(&mut sink);