use super::*;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use teksilo_canvas::MockTextBackend;
use teksilo_core::widget::LayoutContext;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
#[test]
fn nothing_overflows_when_it_all_fits() {
let flags = compute_overflow(
200.0,
0.0,
&[40.0, 40.0, 40.0],
&[0, 0, 0],
&[false; 3],
0.0,
3,
);
assert_eq!(flags, vec![false, false, false]);
}
#[test]
fn lowest_priority_overflows_first() {
let flags = compute_overflow(70.0, 0.0, &[40.0, 40.0], &[10, 0], &[false; 2], 0.0, 2);
assert_eq!(
flags,
vec![false, true],
"the high-priority action stays inline"
);
}
#[test]
fn ties_break_toward_the_last_declared() {
let flags = compute_overflow(
70.0,
0.0,
&[40.0, 40.0, 40.0],
&[0, 0, 0],
&[false; 3],
0.0,
3,
);
assert_eq!(flags, vec![false, true, true]);
}
#[test]
fn always_overflow_actions_start_collapsed_even_with_room() {
let flags = compute_overflow(1000.0, 0.0, &[40.0, 40.0], &[0, 0], &[false, true], 0.0, 2);
assert_eq!(
flags,
vec![false, true],
"always_overflow action stays in the menu"
);
}
#[test]
fn always_overflow_does_not_distort_spacing_estimate() {
let just_fits = compute_overflow(80.0, 0.0, &[40.0, 40.0], &[0, 0], &[false, true], 10.0, 2);
assert_eq!(
just_fits,
vec![false, true],
"action0 stays inline when the bar is exactly wide enough (80px)"
);
let too_tight = compute_overflow(79.0, 0.0, &[40.0, 40.0], &[0, 0], &[false, true], 10.0, 2);
assert_eq!(
too_tight,
vec![true, true],
"action0 collapses once the bar is below the exact-fit width"
);
}
#[test]
fn pinned_width_reduces_room_for_actions() {
let flags = compute_overflow(200.0, 160.0, &[40.0, 40.0], &[0, 0], &[false; 2], 0.0, 4);
assert_eq!(flags, vec![true, true]);
}
fn themed_tree() -> WidgetTree {
WidgetTree::new()
.with_theme(teksilo_core::presets::intui::light())
.with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
}
fn many_actions(n: usize) -> Toolbar {
let mut tb = Toolbar::new();
for i in 0..n {
tb = tb.action(
ToolbarAction::new(lit!(format!("Action {i}")), || IconWidget::checkmark(16.0))
.on_activate(|_| {}),
);
}
tb
}
#[test]
fn wide_bar_shows_everything_narrow_bar_overflows_and_rewidening_restores() {
let tb = many_actions(8);
let overflowing = tb.is_overflowing();
let mut tree = themed_tree();
let _id = tree.add(tb);
tree.layout(SizeProposal::exact(2000.0, 50.0));
assert!(!overflowing.get(), "wide bar should not overflow");
tree.layout(SizeProposal::exact(120.0, 50.0));
assert!(
overflowing.get(),
"narrow bar should overflow into the chevron"
);
tree.layout(SizeProposal::exact(2000.0, 50.0));
assert!(
!overflowing.get(),
"re-widened bar should restore all actions inline"
);
}
#[derive(Debug)]
struct WidthProbe {
target: WidgetId,
out: Rc<Cell<f32>>,
}
impl Widget for WidthProbe {
fn layout_response(
&self,
p: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let w = ctx
.child_size(
self.target,
SizeProposal {
width: Some(300.0),
height: None,
},
)
.map(|s| s.width)
.unwrap_or(-1.0);
self.out.set(w);
p.resolve(0.0, 0.0).into()
}
fn cacheable_layout(&self) -> bool {
false
}
}
#[test]
fn toolbar_fills_offered_width_instead_of_reporting_natural() {
let tb = many_actions(8);
let mut tree = themed_tree();
let tb_id = tree.add(tb);
let out = Rc::new(Cell::new(0.0));
let _probe = tree.add(WidthProbe {
target: tb_id,
out: out.clone(),
});
tree.layout(SizeProposal::exact(2000.0, 50.0));
assert!(
(out.get() - 300.0).abs() < 0.5,
"toolbar should fill the offered 300px, got {} (would spill its container)",
out.get()
);
}
#[test]
fn toolbar_has_toolbar_role_orientation_and_name() {
let tb = Toolbar::new().label(lit!("Formatting")).action(
ToolbarAction::new(lit!("Bold"), || IconWidget::checkmark(16.0)).on_activate(|_| {}),
);
let mut tree = themed_tree();
let id = tree.add(tb);
tree.layout(SizeProposal::exact(400.0, 50.0));
let info = tree.accessibility_node(id);
assert_eq!(info.role(), teksilo_core::accesskit::Role::Toolbar);
assert_eq!(info.name(), Some("Formatting"));
}
#[test]
fn vertical_orientation_is_announced() {
let tb = Toolbar::new()
.orientation(ToolbarOrientation::Vertical)
.action(ToolbarAction::new(lit!("A"), || IconWidget::checkmark(16.0)).on_activate(|_| {}));
let mut tree = themed_tree();
let id = tree.add(tb);
tree.layout(SizeProposal::exact(60.0, 400.0));
let info = tree.accessibility_node(id);
assert_eq!(info.role(), teksilo_core::accesskit::Role::Toolbar);
}
#[test]
fn empty_toolbar_builds() {
let mut tree = themed_tree();
let id = tree.add(Toolbar::new());
tree.layout(SizeProposal::exact(400.0, 50.0));
assert!(tree.bounds(id).width > 0.0);
}
#[test]
fn arrow_keys_move_roving_focus_between_actions() {
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let tb = many_actions(4);
let mut tree = themed_tree();
let id = tree.add(tb);
tree.layout(SizeProposal::exact(2000.0, 50.0));
let first = tree
.first_focusable_descendant(id)
.expect("a focusable toolbar control");
tree.focus(first);
assert_eq!(tree.focused(), Some(first), "focus seeded on first control");
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::NONE,
text: None,
});
assert_ne!(
tree.focused(),
Some(first),
"ArrowRight should move roving focus to the next action"
);
}
#[test]
fn rtl_swaps_the_roving_arrow_direction() {
use teksilo_core::environment::LayoutDirection;
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let tb = many_actions(4);
let mut tree = themed_tree();
tree.set_layout_direction(LayoutDirection::RightToLeft);
let id = tree.add(tb);
tree.layout(SizeProposal::exact(2000.0, 50.0));
let first = tree
.first_focusable_descendant(id)
.expect("a focusable toolbar control");
tree.focus(first);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowLeft,
modifiers: Modifiers::NONE,
text: None,
});
assert_ne!(
tree.focused(),
Some(first),
"RTL: ArrowLeft should advance the roving focus"
);
tree.focus(first);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::NONE,
text: None,
});
assert_eq!(
tree.focused(),
Some(first),
"RTL: ArrowRight from the first control should stay put"
);
}
#[test]
fn roving_tab_suppresses_a_composite_controls_inner_leaf() {
use crate::combo_box::ComboBox;
use crate::icon_button::IconButton;
use crate::primitives::icon_widget::IconWidget;
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
use teksilo_core::signal::Signal;
let view_mode = Signal::new(Some("List".to_string()));
let tb = Toolbar::new()
.item(
ToolbarItem::custom(ComboBox::new(["List", "Grid", "Columns"], view_mode)).overflow_as(
ToolbarAction::new(lit!("View mode"), || IconWidget::checkmark(16.0))
.on_activate(|_| {}),
),
)
.item(
ToolbarItem::custom(IconButton::new(IconWidget::checkmark(16.0)).tooltip(lit!("Ok")))
.overflow_as(
ToolbarAction::new(lit!("Ok"), || IconWidget::checkmark(16.0))
.on_activate(|_| {}),
),
)
.action(
ToolbarAction::new(lit!("New"), || IconWidget::checkmark(16.0)).on_activate(|_| {}),
);
let mut tree = themed_tree();
let id = tree.add(tb);
tree.layout(SizeProposal::exact(2000.0, 50.0));
let combo_leaf = tree
.first_focusable_descendant(id)
.expect("the ComboBox exposes a focusable leaf");
tree.focus(combo_leaf);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::NONE,
text: None,
});
let roving_target = tree.focused();
assert_ne!(roving_target, Some(combo_leaf), "arrow moved off the combo");
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::Tab,
modifiers: Modifiers::NONE,
text: None,
});
assert_eq!(
tree.focused(),
roving_target,
"Tab must stay on the single roving stop"
);
assert_ne!(
tree.focused(),
Some(combo_leaf),
"the composite control's inner leaf must not leak into Tab"
);
}
fn active_dropdown_panels(tree: &WidgetTree) -> u32 {
tree.widget_type_histogram()
.iter()
.filter(|(k, _)| k.contains("DropdownPanel"))
.map(|(_, v)| *v)
.sum()
}
#[test]
fn example_mix_overflow_rows_dormant_while_closed_across_resizes() {
use crate::combo_box::ComboBox;
use crate::icon_button::IconButton;
use crate::primitives::icon_widget::IconWidget;
use teksilo_core::signal::Signal;
let view_mode = Signal::new(Some("List".to_string()));
let menu_mode = view_mode.clone();
let tb = Toolbar::new()
.item(
ToolbarItem::custom(ComboBox::new(["List", "Grid", "Columns"], view_mode))
.overflow_widget(move || {
Box::new(ComboBox::new(
["List", "Grid", "Columns"],
menu_mode.clone(),
))
}),
)
.item(
ToolbarItem::custom(IconButton::new(IconWidget::checkmark(16.0)).tooltip(lit!("Ok")))
.overflow_as(
ToolbarAction::new(lit!("Confirm"), || IconWidget::checkmark(16.0))
.on_activate(|_| {}),
),
)
.item(ToolbarItem::separator())
.action(
ToolbarAction::new(lit!("New Document"), || IconWidget::checkmark(16.0))
.on_activate(|_| {}),
)
.action(
ToolbarAction::new(lit!("Open Recent Project"), || IconWidget::checkmark(16.0))
.on_activate(|_| {}),
)
.action(
ToolbarAction::new(lit!("Save Document As"), || IconWidget::checkmark(16.0))
.on_activate(|_| {}),
)
.action(
ToolbarAction::new(lit!("Export to PDF"), || IconWidget::checkmark(16.0))
.on_activate(|_| {}),
)
.action(
ToolbarAction::new(lit!("Print Preview"), || IconWidget::checkmark(16.0))
.on_activate(|_| {}),
);
let mut tree = themed_tree();
let _id = tree.add(tb);
let active_menu_rows = |t: &WidgetTree| -> u32 {
t.widget_type_histogram()
.iter()
.filter(|(k, _)| k.contains("menu_item::MenuItem"))
.map(|(_, v)| *v)
.sum()
};
for w in [900.0_f32, 500.0, 300.0, 160.0, 900.0, 240.0] {
tree.layout(SizeProposal::exact(w, 48.0));
assert_eq!(
active_menu_rows(&tree),
0,
"no overflow menu row may render while the chevron is closed (width {w})"
);
}
}
#[test]
fn overflow_menu_rows_are_dormant_while_the_chevron_is_closed() {
let tb = many_actions(6);
let mut tree = themed_tree();
let _id = tree.add(tb);
tree.layout(SizeProposal::exact(120.0, 50.0));
let active_menu_items: u32 = tree
.widget_type_histogram()
.iter()
.filter(|(k, _)| k.contains("menu_item::MenuItem"))
.map(|(_, v)| *v)
.sum();
assert_eq!(
active_menu_items, 0,
"overflow menu rows must stay dormant until the chevron is opened"
);
}
#[test]
fn collapsing_a_combobox_does_not_leak_its_dropdown_open() {
use crate::combo_box::ComboBox;
use teksilo_core::signal::Signal;
let view_mode = Signal::new(Some("List".to_string()));
let tb = Toolbar::new()
.item(
ToolbarItem::custom(ComboBox::new(["List", "Grid", "Columns"], view_mode)).overflow_as(
ToolbarAction::new(lit!("View"), || IconWidget::checkmark(16.0))
.on_activate(|_| {}),
),
)
.action(
ToolbarAction::new(lit!("New"), || IconWidget::checkmark(16.0)).on_activate(|_| {}),
);
let mut tree = themed_tree();
let _id = tree.add(tb);
tree.layout(SizeProposal::exact(2000.0, 50.0)); assert_eq!(active_dropdown_panels(&tree), 0, "closed dropdown at start");
tree.layout(SizeProposal::exact(40.0, 50.0)); tree.layout(SizeProposal::exact(2000.0, 50.0)); tree.layout(SizeProposal::exact(2000.0, 50.0)); assert_eq!(
active_dropdown_panels(&tree),
0,
"no ComboBox dropdown should be open after collapse→reappear"
);
}
#[test]
fn embedded_overflow_widget_combobox_does_not_leak_its_dropdown() {
use crate::combo_box::ComboBox;
use teksilo_core::signal::Signal;
let view_mode = Signal::new(Some("List".to_string()));
let menu_mode = view_mode.clone();
let tb = Toolbar::new()
.item(
ToolbarItem::custom(ComboBox::new(["List", "Grid", "Columns"], view_mode))
.overflow_widget(move || {
Box::new(ComboBox::new(
["List", "Grid", "Columns"],
menu_mode.clone(),
))
}),
)
.action(
ToolbarAction::new(lit!("New"), || IconWidget::checkmark(16.0)).on_activate(|_| {}),
);
let mut tree = themed_tree();
let _id = tree.add(tb);
tree.layout(SizeProposal::exact(2000.0, 50.0));
tree.layout(SizeProposal::exact(40.0, 50.0));
tree.layout(SizeProposal::exact(2000.0, 50.0));
tree.layout(SizeProposal::exact(2000.0, 50.0));
assert_eq!(
active_dropdown_panels(&tree),
0,
"neither the inline nor the embedded ComboBox dropdown should leak open"
);
}
#[test]
fn overflow_widget_makes_a_custom_widget_collapsible() {
use crate::combo_box::ComboBox;
use teksilo_core::signal::Signal;
let view_mode = Signal::new(Some("List".to_string()));
let menu_mode = view_mode.clone();
let tb = Toolbar::new().item(
ToolbarItem::custom(ComboBox::new(["List", "Grid", "Columns"], view_mode)).overflow_widget(
move || {
Box::new(ComboBox::new(
["List", "Grid", "Columns"],
menu_mode.clone(),
))
},
),
);
let overflowing = tb.is_overflowing();
let mut tree = themed_tree();
let _id = tree.add(tb);
tree.layout(SizeProposal::exact(40.0, 50.0)); assert!(
overflowing.get(),
"a widget-form collapsible should overflow into the menu when it doesn't fit"
);
}
#[test]
fn a_collapsible_custom_overflows_when_it_does_not_fit() {
use crate::primitives::TextWidget;
let tb = Toolbar::new().item(
ToolbarItem::custom(TextWidget::new(lit!("A very wide collapsible widget"))).overflow_as(
ToolbarAction::new(lit!("Wide"), || IconWidget::checkmark(16.0)).on_activate(|_| {}),
),
);
let overflowing = tb.is_overflowing();
let mut tree = themed_tree();
let _id = tree.add(tb);
tree.layout(SizeProposal::exact(40.0, 50.0)); assert!(
overflowing.get(),
"a collapsible custom should overflow into the menu when it doesn't fit"
);
}
#[test]
fn a_pinned_custom_never_overflows() {
use crate::primitives::TextWidget;
let tb = Toolbar::new().item(ToolbarItem::custom(TextWidget::new(lit!(
"Wide pinned content"
))));
let overflowing = tb.is_overflowing();
let mut tree = themed_tree();
let _id = tree.add(tb);
tree.layout(SizeProposal::exact(40.0, 50.0));
assert!(!overflowing.get(), "pinned customs never collapse");
}
#[test]
fn pinned_custom_widget_stays_inline_under_pressure() {
use crate::primitives::TextWidget;
let tb = Toolbar::new()
.item(ToolbarItem::custom(TextWidget::new(lit!("Search:"))))
.action(ToolbarAction::new(lit!("One"), || IconWidget::checkmark(16.0)).on_activate(|_| {}))
.action(ToolbarAction::new(lit!("Two"), || IconWidget::checkmark(16.0)).on_activate(|_| {}))
.action(
ToolbarAction::new(lit!("Three"), || IconWidget::checkmark(16.0)).on_activate(|_| {}),
);
let overflowing = tb.is_overflowing();
let mut tree = themed_tree();
let _id = tree.add(tb);
tree.layout(SizeProposal::exact(110.0, 50.0));
assert!(overflowing.get(), "actions should overflow in a tight bar");
}
#[test]
fn tooltip_appears_on_hover() {
let tb = Toolbar::new().action(
ToolbarAction::new(lit!("Save"), || IconWidget::checkmark(16.0))
.tooltip(lit!("Tip"))
.on_activate(|_| {}),
);
let mut tree = themed_tree();
let id = tree.add(tb);
tree.layout(SizeProposal::exact(300.0, 50.0));
let btn = tree
.first_focusable_descendant(id)
.expect("toolbar action should be focusable");
tree.pointer_move(tree.bounds(btn).center());
tree.advance_time(std::time::Duration::from_secs(1));
assert_eq!(
tree.active_overlays().len(),
1,
"tooltip should appear after hovering the toolbar action"
);
assert!(tree.find_by_label("Tip").is_some());
}
#[test]
fn compact_toolbar_reports_the_button_height_not_the_panel_padding() {
#[derive(Debug)]
struct HeightProbe {
target: WidgetId,
out: Rc<Cell<f32>>,
}
impl Widget for HeightProbe {
fn layout_response(
&self,
p: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let h = ctx
.child_size(self.target, SizeProposal::unspecified())
.map(|s| s.height)
.unwrap_or(-1.0);
self.out.set(h);
p.resolve(0.0, 0.0).into()
}
fn cacheable_layout(&self) -> bool {
false
}
}
let bar = Toolbar::new()
.compact(true)
.action(ToolbarAction::new(lit!("A"), || {
IconWidget::checkmark(16.0)
}))
.action(ToolbarAction::new(lit!("B"), || {
IconWidget::checkmark(16.0)
}));
let mut tree = themed_tree();
let id = tree.add(bar);
let out = Rc::new(Cell::new(0.0));
tree.add(HeightProbe {
target: id,
out: out.clone(),
});
tree.layout(SizeProposal::exact(400.0, 400.0));
let compact_size = crate::styles::recipe_icon_button_style::ICON_BUTTON_SIZE_COMPACT;
assert!(
(out.get() - compact_size).abs() < 1.0,
"compact toolbar should be one Compact button tall ({compact_size}), got {}",
out.get()
);
}
#[test]
fn compact_toolbar_overflows_gradually_and_re_expands() {
let mut tb = Toolbar::new().compact(true);
for i in 0..6 {
tb = tb.action(ToolbarAction::new(lit!(format!("A{i}")), || {
IconWidget::checkmark(16.0)
}));
}
let overflowed = tb.is_overflowing().clone();
let mut tree = themed_tree();
tree.add(tb);
tree.layout(SizeProposal::exact(400.0, 30.0));
assert!(!overflowed.get(), "wide: everything inline");
tree.layout(SizeProposal::exact(70.0, 30.0));
assert!(overflowed.get(), "narrow: overflow kicks in");
tree.layout(SizeProposal::exact(400.0, 30.0));
assert!(
!overflowed.get(),
"re-widened: the bar restores every action inline (the regression)"
);
}
#[test]
fn menu_action_opens_a_dropdown_on_click() {
use teksilo_core::event::PointerButton;
let tb = Toolbar::new().action(
ToolbarAction::new(lit!("Sort"), || IconWidget::checkmark(16.0))
.menu(|| MenuList::new().item(MenuItem::new(lit!("By name")).on_activate_fn(|_| {}))),
);
let mut tree = themed_tree();
let id = tree.add(tb);
tree.layout(SizeProposal::exact(300.0, 50.0));
let btn = tree
.first_focusable_descendant(id)
.expect("dropdown trigger should be focusable");
let c = tree.bounds(btn).center();
tree.pointer_down_button(c, PointerButton::Primary);
tree.pointer_up_button(c, PointerButton::Primary);
assert!(
!tree.active_overlays().is_empty(),
"clicking a menu action opens its dropdown popover"
);
assert!(
tree.find_by_label("By name").is_some(),
"the dropdown shows its menu items"
);
}