feat(orchestrator): distribute bottom-nav tabs evenly
A recursive cleanup pass detects a horizontal row of >=3 vertical icon+label tabs (a bottom nav bar, incl. a nested tab container) and makes each tab fill_container + the row space_between, so tabs spread the full width instead of bunching to one side and overflowing off-screen.
This commit is contained in:
parent
741c3a8b65
commit
b39c1636c0
|
|
@ -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<String> = 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<String> = 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<BottomNavTabDistributionRepair> = {
|
||||
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<NodeId>,
|
||||
set_row_justify: bool,
|
||||
}
|
||||
|
||||
fn collect_bottom_nav_tab_distribution_repairs(
|
||||
node: &PenNode,
|
||||
repairs: &mut Vec<BottomNavTabDistributionRepair>,
|
||||
) {
|
||||
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<BottomNavTabDistributionRepair> {
|
||||
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::<Vec<_>>();
|
||||
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;
|
||||
|
|
|
|||
352
crates/op-orchestrator/src/cleanup_bottom_nav_tests.rs
Normal file
352
crates/op-orchestrator/src/cleanup_bottom_nav_tests.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue