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:
Fini 2026-07-06 01:24:49 +08:00
parent 741c3a8b65
commit b39c1636c0
3 changed files with 481 additions and 1 deletions

View file

@ -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;

View 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);
}

View file

@ -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);