Skip to main content

azul_layout/widgets/
ribbon.rs

1//! Microsoft Office-style ribbon widget.
2//!
3//! A [`Ribbon`] organizes controls into a tabbed toolbar where each tab
4//! contains one or more [`RibbonSection`]s, each with a title and arbitrary
5//! content.  Unlike the simpler [`super::tabs`] widget, each tab is further
6//! subdivided into titled, visually separated sections — matching the ribbon
7//! pattern found in Office applications.
8
9use azul_core::{
10    callbacks::{CoreCallback, CoreCallbackData, Update},
11    dom::{Dom, DomVec, EventFilter, HoverEventFilter, IdOrClass, IdOrClass::Class, IdOrClassVec},
12    refany::RefAny,
13};
14#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
15use azul_css::{
16    dynamic_selector::{CssPropertyWithConditions as Cond, CssPropertyWithConditionsVec},
17    props::{
18        basic::{color::ColorU, font::{StyleFontFamily, StyleFontFamilyVec}, *},
19        layout::*,
20        property::CssProperty as P,
21        style::*,
22    },
23    *,
24};
25
26use azul_css::{impl_option, impl_vec, impl_vec_clone, impl_vec_debug, impl_vec_partialeq, impl_vec_mut};
27
28use crate::callbacks::{Callback, CallbackInfo};
29
30// -- Callback --
31
32/// Callback signature invoked when a ribbon tab is clicked.
33pub type RibbonOnTabClickCallbackType = extern "C" fn(RefAny, CallbackInfo, usize) -> Update;
34impl_widget_callback!(
35    RibbonOnTabClick, OptionRibbonOnTabClick,
36    RibbonOnTabClickCallback, RibbonOnTabClickCallbackType
37);
38
39azul_core::impl_managed_callback! {
40    wrapper:        RibbonOnTabClickCallback,
41    info_ty:        CallbackInfo,
42    return_ty:      Update,
43    default_ret:    Update::DoNothing,
44    invoker_static: RIBBON_ON_TAB_CLICK_INVOKER,
45    invoker_ty:     AzRibbonOnTabClickCallbackInvoker,
46    thunk_fn:       az_ribbon_on_tab_click_callback_thunk,
47    setter_fn:      AzApp_setRibbonOnTabClickCallbackInvoker,
48    from_handle_fn: AzRibbonOnTabClickCallback_createFromHostHandle,
49    extra_args:     [ tab_index: usize ],
50}
51
52// -- Font --
53
54const SYSTEM_UI_STR: AzString = AzString::from_const_str("system:ui");
55const SYSTEM_UI_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SYSTEM_UI_STR)];
56const SYSTEM_UI_FAMILY: StyleFontFamilyVec =
57    StyleFontFamilyVec::from_const_slice(SYSTEM_UI_FAMILIES);
58
59// -- Colors --
60
61const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
62const LIGHT_GRAY: ColorU = ColorU { r: 240, g: 240, b: 240, a: 255 };
63const BORDER_GRAY: ColorU = ColorU { r: 200, g: 200, b: 200, a: 255 };
64const TEXT_GRAY: ColorU = ColorU { r: 100, g: 100, b: 100, a: 255 };
65const ACTIVE_BLUE: ColorU = ColorU { r: 0, g: 114, b: 198, a: 255 };
66const BG_WHITE: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(WHITE)];
67const BG_LIGHT_GRAY: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(LIGHT_GRAY)];
68
69static RIBBON_CONTAINER_STYLE: &[Cond] = &[
70    Cond::simple(P::const_display(LayoutDisplay::Flex)),
71    Cond::simple(P::const_flex_direction(LayoutFlexDirection::Column)),
72    Cond::simple(P::const_font_family(SYSTEM_UI_FAMILY)),
73    Cond::simple(P::const_font_size(StyleFontSize::const_px(12))),
74];
75
76static TAB_BAR_STYLE: &[Cond] = &[
77    Cond::simple(P::const_display(LayoutDisplay::Flex)),
78    Cond::simple(P::const_flex_direction(LayoutFlexDirection::Row)),
79    Cond::simple(P::const_background_content(StyleBackgroundContentVec::from_const_slice(BG_LIGHT_GRAY))),
80    Cond::simple(P::const_border_bottom_width(LayoutBorderBottomWidth::const_px(1))),
81    Cond::simple(P::const_border_bottom_style(StyleBorderBottomStyle { inner: BorderStyle::Solid })),
82    Cond::simple(P::const_border_bottom_color(StyleBorderBottomColor { inner: BORDER_GRAY })),
83];
84
85static TAB_INACTIVE_STYLE: &[Cond] = &[
86    Cond::simple(P::const_padding_left(LayoutPaddingLeft::const_px(12))),
87    Cond::simple(P::const_padding_right(LayoutPaddingRight::const_px(12))),
88    Cond::simple(P::const_padding_top(LayoutPaddingTop::const_px(6))),
89    Cond::simple(P::const_padding_bottom(LayoutPaddingBottom::const_px(6))),
90    Cond::simple(P::const_cursor(StyleCursor::Pointer)),
91    Cond::simple(P::const_text_color(StyleTextColor { inner: TEXT_GRAY })),
92];
93
94static TAB_ACTIVE_STYLE: &[Cond] = &[
95    Cond::simple(P::const_padding_left(LayoutPaddingLeft::const_px(12))),
96    Cond::simple(P::const_padding_right(LayoutPaddingRight::const_px(12))),
97    Cond::simple(P::const_padding_top(LayoutPaddingTop::const_px(6))),
98    Cond::simple(P::const_padding_bottom(LayoutPaddingBottom::const_px(6))),
99    Cond::simple(P::const_cursor(StyleCursor::Pointer)),
100    Cond::simple(P::const_background_content(StyleBackgroundContentVec::from_const_slice(BG_WHITE))),
101    Cond::simple(P::const_border_bottom_width(LayoutBorderBottomWidth::const_px(2))),
102    Cond::simple(P::const_border_bottom_style(StyleBorderBottomStyle { inner: BorderStyle::Solid })),
103    Cond::simple(P::const_border_bottom_color(StyleBorderBottomColor { inner: ACTIVE_BLUE })),
104];
105
106static SECTIONS_CONTAINER_STYLE: &[Cond] = &[
107    Cond::simple(P::const_display(LayoutDisplay::Flex)),
108    Cond::simple(P::const_flex_direction(LayoutFlexDirection::Row)),
109    Cond::simple(P::const_flex_grow(LayoutFlexGrow::const_new(1))),
110    Cond::simple(P::const_background_content(StyleBackgroundContentVec::from_const_slice(BG_WHITE))),
111    Cond::simple(P::const_padding_top(LayoutPaddingTop::const_px(4))),
112    Cond::simple(P::const_padding_bottom(LayoutPaddingBottom::const_px(4))),
113    Cond::simple(P::const_padding_left(LayoutPaddingLeft::const_px(4))),
114    Cond::simple(P::const_padding_right(LayoutPaddingRight::const_px(4))),
115    Cond::simple(P::const_border_bottom_width(LayoutBorderBottomWidth::const_px(1))),
116    Cond::simple(P::const_border_bottom_style(StyleBorderBottomStyle { inner: BorderStyle::Solid })),
117    Cond::simple(P::const_border_bottom_color(StyleBorderBottomColor { inner: BORDER_GRAY })),
118];
119
120static SECTION_STYLE: &[Cond] = &[
121    Cond::simple(P::const_display(LayoutDisplay::Flex)),
122    Cond::simple(P::const_flex_direction(LayoutFlexDirection::Column)),
123    Cond::simple(P::const_padding_left(LayoutPaddingLeft::const_px(6))),
124    Cond::simple(P::const_padding_right(LayoutPaddingRight::const_px(6))),
125    Cond::simple(P::const_border_right_width(LayoutBorderRightWidth::const_px(1))),
126    Cond::simple(P::const_border_right_style(StyleBorderRightStyle { inner: BorderStyle::Solid })),
127    Cond::simple(P::const_border_right_color(StyleBorderRightColor { inner: BORDER_GRAY })),
128];
129
130static SECTION_CONTENT_STYLE: &[Cond] = &[
131    Cond::simple(P::const_flex_grow(LayoutFlexGrow::const_new(1))),
132];
133
134static SECTION_TITLE_STYLE: &[Cond] = &[
135    Cond::simple(P::const_font_size(StyleFontSize::const_px(11))),
136    Cond::simple(P::const_text_color(StyleTextColor { inner: TEXT_GRAY })),
137    Cond::simple(P::const_text_align(StyleTextAlign::Center)),
138    Cond::simple(P::const_padding_top(LayoutPaddingTop::const_px(2))),
139];
140
141/// Top-level ribbon widget containing multiple tabs.
142#[derive(Debug, Clone)]
143#[repr(C)]
144pub struct Ribbon {
145    /// Tabs displayed in the ribbon tab bar.
146    pub tabs: RibbonTabVec,
147    /// Index of the currently active tab.
148    pub active_tab: usize,
149    /// Optional callback fired when a tab is clicked.
150    pub on_tab_click: OptionRibbonOnTabClick,
151}
152
153/// A single tab within a [`Ribbon`], containing a label and sections.
154#[derive(Debug, Clone)]
155#[repr(C)]
156pub struct RibbonTab {
157    /// Display label shown in the tab bar.
158    pub label: AzString,
159    /// Sections rendered when this tab is active.
160    pub sections: RibbonSectionVec,
161}
162
163/// A titled section within a [`RibbonTab`], holding arbitrary content.
164#[derive(Debug, Clone)]
165#[repr(C)]
166pub struct RibbonSection {
167    /// Title displayed below the section content.
168    pub title: AzString,
169    /// Content DOM rendered inside this section.
170    pub content: Dom,
171}
172
173impl_option!(RibbonSection, OptionRibbonSection, copy = false, [Debug, Clone]);
174impl_vec!(RibbonSection, RibbonSectionVec, RibbonSectionVecDestructor, RibbonSectionVecDestructorType, RibbonSectionVecSlice, OptionRibbonSection);
175impl_vec_clone!(RibbonSection, RibbonSectionVec, RibbonSectionVecDestructor);
176impl_vec_debug!(RibbonSection, RibbonSectionVec);
177impl_vec_mut!(RibbonSection, RibbonSectionVec);
178
179impl_option!(RibbonTab, OptionRibbonTab, copy = false, [Debug, Clone]);
180impl_vec!(RibbonTab, RibbonTabVec, RibbonTabVecDestructor, RibbonTabVecDestructorType, RibbonTabVecSlice, OptionRibbonTab);
181impl_vec_clone!(RibbonTab, RibbonTabVec, RibbonTabVecDestructor);
182impl_vec_debug!(RibbonTab, RibbonTabVec);
183impl_vec_mut!(RibbonTab, RibbonTabVec);
184
185impl RibbonTab {
186    /// Creates a new tab with the given label and no sections.
187    #[must_use] pub const fn new(label: AzString) -> Self {
188        Self { label, sections: RibbonSectionVec::from_const_slice(&[]) }
189    }
190
191    /// Appends a section to this tab.
192    pub fn add_section(&mut self, section: RibbonSection) {
193        self.sections.push(section);
194    }
195
196    /// Builder method: appends a section and returns `self`.
197    #[must_use] pub fn with_section(mut self, section: RibbonSection) -> Self {
198        self.add_section(section);
199        self
200    }
201}
202
203impl RibbonSection {
204    /// Creates a new section with the given title and content DOM.
205    #[must_use] pub const fn new(title: AzString, content: Dom) -> Self {
206        Self { title, content }
207    }
208}
209
210impl Ribbon {
211    /// Creates a new ribbon with the given tabs, defaulting to the first tab active.
212    #[must_use] pub fn new(tabs: RibbonTabVec) -> Self {
213        Self { tabs, active_tab: 0, on_tab_click: None.into() }
214    }
215
216    /// Sets the active tab by index, clamping to the last valid tab.
217    pub const fn set_active_tab(&mut self, index: usize) {
218        let max = self.tabs.len().saturating_sub(1);
219        self.active_tab = if index > max { max } else { index };
220    }
221
222    /// Registers a callback invoked when a tab is clicked.
223    pub fn set_on_tab_click<C: Into<RibbonOnTabClickCallback>>(&mut self, data: RefAny, cb: C) {
224        self.on_tab_click = Some(RibbonOnTabClick {
225            callback: cb.into(), refany: data,
226        }).into();
227    }
228
229    /// Builder method: registers a tab-click callback and returns `self`.
230    #[must_use]
231    pub fn with_on_tab_click<C: Into<RibbonOnTabClickCallback>>(mut self, data: RefAny, cb: C) -> Self {
232        self.set_on_tab_click(data, cb);
233        self
234    }
235
236    /// Builds the ribbon DOM, rendering the tab bar and the active tab's sections.
237    #[must_use] pub fn dom(self) -> Dom {
238        let active_tab = self.active_tab;
239        let has_callback = self.on_tab_click.is_some();
240
241        let tab_items: Vec<Dom> = self.tabs.as_slice().iter().enumerate().map(|(idx, tab)| {
242            let style = if idx == active_tab { TAB_ACTIVE_STYLE } else { TAB_INACTIVE_STYLE };
243            let mut d = Dom::create_text(tab.label.clone())
244                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(style));
245            if has_callback {
246                d = d.with_callbacks(vec![CoreCallbackData {
247                    event: EventFilter::Hover(HoverEventFilter::MouseUp),
248                    callback: CoreCallback {
249                        cb: on_ribbon_tab_click as usize,
250                        ctx: azul_core::refany::OptionRefAny::None,
251                    },
252                    refany: RefAny::new(TabClickData {
253                        tab_idx: idx, on_tab_click: self.on_tab_click.clone(),
254                    }),
255                }].into());
256            }
257            d
258        }).collect();
259
260        let tab_bar = Dom::create_div()
261            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(TAB_BAR_STYLE))
262            .with_children(DomVec::from_vec(tab_items));
263
264        let sections_dom = if let Some(active) = self.tabs.into_library_owned_vec().into_iter().nth(active_tab) {
265            let items: Vec<Dom> = active.sections.into_library_owned_vec().into_iter().map(|s| {
266                let content = Dom::create_div()
267                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(SECTION_CONTENT_STYLE))
268                    .with_children(DomVec::from_vec(vec![s.content]));
269                let title = Dom::create_text(s.title)
270                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(SECTION_TITLE_STYLE));
271                Dom::create_div()
272                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(SECTION_STYLE))
273                    .with_children(DomVec::from_vec(vec![content, title]))
274            }).collect();
275            Dom::create_div()
276                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(SECTIONS_CONTAINER_STYLE))
277                .with_children(DomVec::from_vec(items))
278        } else {
279            Dom::create_div()
280                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(SECTIONS_CONTAINER_STYLE))
281        };
282
283        Dom::create_div()
284            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(RIBBON_CONTAINER_STYLE))
285            .with_ids_and_classes({
286                const CLS: &[IdOrClass] = &[Class(AzString::from_const_str("__azul-native-ribbon"))];
287                IdOrClassVec::from_const_slice(CLS)
288            })
289            .with_children(DomVec::from_vec(vec![tab_bar, sections_dom]))
290    }
291}
292
293struct TabClickData {
294    tab_idx: usize,
295    on_tab_click: OptionRibbonOnTabClick,
296}
297
298extern "C" fn on_ribbon_tab_click(mut refany: RefAny, info: CallbackInfo) -> Update {
299    let Some(mut data) = refany.downcast_mut::<TabClickData>() else {
300        return Update::DoNothing;
301    };
302    let idx = data.tab_idx;
303    match data.on_tab_click.as_mut() {
304        Some(RibbonOnTabClick { refany, callback }) => {
305            (callback.cb)(refany.clone(), info, idx)
306        }
307        None => Update::DoNothing,
308    }
309}
310
311impl From<Ribbon> for Dom {
312    fn from(r: Ribbon) -> Self { r.dom() }
313}
314
315#[cfg(test)]
316mod autotest_generated {
317    use std::{
318        collections::BTreeMap,
319        sync::{Arc, Mutex},
320    };
321
322    use azul_core::{
323        dom::{DomId, DomNodeId, NodeId, NodeType},
324        geom::OptionLogicalPosition,
325        gl::OptionGlContextPtr,
326        hit_test::ScrollPosition,
327        refany::OptionRefAny,
328        resources::RendererResources,
329        styled_dom::NodeHierarchyItemId,
330        window::{MonitorVec, RawWindowHandle},
331    };
332    use azul_css::{props::property::CssProperty, system::SystemStyle};
333    use rust_fontconfig::FcFontCache;
334
335    use super::*;
336    #[cfg(feature = "icu")]
337    use crate::icu::IcuLocalizerHandle;
338    use crate::{
339        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
340        window::LayoutWindow,
341        window_state::FullWindowState,
342    };
343
344    // ------------------------------------------------------------------
345    // Helpers
346    // ------------------------------------------------------------------
347
348    /// Pathological label/title inputs reused across the string tests: empty,
349    /// whitespace-only, interior NUL, emoji-with-ZWJ, RTL, stacked combining
350    /// marks, zero-width + BOM + RTL-override, and a 100k-char string.
351    fn nasty_strings() -> Vec<String> {
352        vec![
353            String::new(),
354            "   ".to_string(),
355            "a\u{0}b".to_string(),
356            "👨‍👩‍👧‍👦🇩🇪".to_string(),
357            "مرحبا שלום".to_string(),
358            "e\u{0301}\u{0327}\u{0301}".to_string(),
359            "\u{200b}\u{feff}\u{202e}rtl-override".to_string(),
360            "x".repeat(100_000),
361        ]
362    }
363
364    /// True if `node` carries the CSS class `name`.
365    fn has_class(node: &Dom, name: &str) -> bool {
366        node.root
367            .get_ids_and_classes()
368            .as_ref()
369            .iter()
370            .any(|c| matches!(c, IdOrClass::Class(s) if s.as_str() == name))
371    }
372
373    /// The text of a `NodeType::Text` node (`None` for any other node type).
374    fn text_of(node: &Dom) -> Option<&str> {
375        match node.root.get_node_type() {
376            NodeType::Text(s) => Some(s.as_ref().as_str()),
377            _ => None,
378        }
379    }
380
381    /// The node's inline style, flattened back to the property list that
382    /// `with_css_props` was handed.
383    fn inline_props(node: &Dom) -> Vec<CssProperty> {
384        node.root
385            .style
386            .iter_inline_properties()
387            .map(|(p, _)| p.clone())
388            .collect()
389    }
390
391    /// The property list of one of this module's `static &[Cond]` style tables.
392    fn style_props(style: &[Cond]) -> Vec<CssProperty> {
393        style.iter().map(|c| c.property.clone()).collect()
394    }
395
396    /// The true recursive descendant count of a `Dom` — what
397    /// `estimated_total_children` is documented to cache.
398    fn recursive_descendants(node: &Dom) -> usize {
399        node.children
400            .as_ref()
401            .iter()
402            .map(|c| 1 + recursive_descendants(c))
403            .sum()
404    }
405
406    /// `(tab bar, sections container)` of a rendered ribbon DOM.
407    fn parts(dom: &Dom) -> (&Dom, &Dom) {
408        let ch = dom.children.as_ref();
409        assert_eq!(ch.len(), 2, "a ribbon DOM is exactly [tab bar, sections]");
410        (&ch[0], &ch[1])
411    }
412
413    /// `(content wrapper, title)` of the `n`-th rendered section.
414    fn section_parts(sections: &Dom, n: usize) -> (&Dom, &Dom) {
415        let sec = &sections.children.as_ref()[n];
416        let ch = sec.children.as_ref();
417        assert_eq!(ch.len(), 2, "a section is exactly [content, title]");
418        (&ch[0], &ch[1])
419    }
420
421    /// `n` tabs labelled `t0 … t{n-1}`, each with `sections_per_tab` sections
422    /// titled `t{i}s{j}` wrapping a text node `t{i}c{j}`.
423    fn tabs(n: usize, sections_per_tab: usize) -> RibbonTabVec {
424        let mut v = Vec::with_capacity(n);
425        for i in 0..n {
426            let mut tab = RibbonTab::new(AzString::from(format!("t{i}")));
427            for j in 0..sections_per_tab {
428                tab.add_section(RibbonSection::new(
429                    AzString::from(format!("t{i}s{j}")),
430                    Dom::create_text(format!("t{i}c{j}")),
431                ));
432            }
433            v.push(tab);
434        }
435        RibbonTabVec::from_vec(v)
436    }
437
438    /// A `RefAny` payload recording every tab index a user `on_tab_click` sees.
439    struct TabLog {
440        seen: Vec<usize>,
441    }
442
443    extern "C" fn record_tab(mut data: RefAny, _: CallbackInfo, index: usize) -> Update {
444        if let Some(mut log) = data.downcast_mut::<TabLog>() {
445            log.seen.push(index);
446        }
447        Update::RefreshDom
448    }
449
450    extern "C" fn tab_do_nothing(_: RefAny, _: CallbackInfo, _: usize) -> Update {
451        Update::DoNothing
452    }
453
454    extern "C" fn tab_refresh_all(_: RefAny, _: CallbackInfo, _: usize) -> Update {
455        Update::RefreshDomAllWindows
456    }
457
458    /// Forces the `fn`-item -> `fn`-pointer coercion the `Into` bound needs.
459    fn tab_cb(f: RibbonOnTabClickCallbackType) -> RibbonOnTabClickCallback {
460        f.into()
461    }
462
463    fn log_indices(data: &mut RefAny) -> Vec<usize> {
464        data.downcast_ref::<TabLog>()
465            .expect("payload must still be a TabLog")
466            .seen
467            .clone()
468    }
469
470    /// Invokes `on_ribbon_tab_click` with `hit` as the hit node. The handler
471    /// never reads the DOM, so the `LayoutWindow` deliberately holds no layout
472    /// results at all — if it ever starts touching them, these tests notice.
473    /// Returns the `Update` plus every recorded `CallbackChange`.
474    fn run_click(hit: usize, data: RefAny) -> (Update, Vec<CallbackChange>) {
475        let layout_window =
476            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
477
478        let renderer_resources = RendererResources::default();
479        let previous_window_state: Option<FullWindowState> = None;
480        let current_window_state = FullWindowState::default();
481        let gl_context = OptionGlContextPtr::None;
482        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
483            BTreeMap::new();
484        let window_handle = RawWindowHandle::Unsupported;
485        let system_callbacks = ExternalSystemCallbacks::rust_internal();
486
487        let ref_data = CallbackInfoRefData {
488            layout_window: &layout_window,
489            renderer_resources: &renderer_resources,
490            previous_window_state: &previous_window_state,
491            current_window_state: &current_window_state,
492            gl_context: &gl_context,
493            current_scroll_manager: &scroll_states,
494            current_window_handle: &window_handle,
495            system_callbacks: &system_callbacks,
496            system_style: Arc::new(SystemStyle::default()),
497            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
498            #[cfg(feature = "icu")]
499            icu_localizer: IcuLocalizerHandle::default(),
500            ctx: OptionRefAny::None,
501        };
502
503        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
504
505        let info = CallbackInfo::new(
506            &ref_data,
507            &changes,
508            DomNodeId {
509                dom: DomId::ROOT_ID,
510                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(hit))),
511            },
512            OptionLogicalPosition::None,
513            OptionLogicalPosition::None,
514        );
515
516        let update = on_ribbon_tab_click(data, info);
517        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
518        (update, recorded)
519    }
520
521    // ------------------------------------------------------------------
522    // RibbonTab::new  (constructor: no_panic + invariants)
523    // ------------------------------------------------------------------
524
525    #[test]
526    fn tab_new_stores_label_verbatim_and_starts_section_less() {
527        for label in nasty_strings() {
528            let tab = RibbonTab::new(AzString::from(label.clone()));
529
530            assert_eq!(
531                tab.label.as_str(),
532                label.as_str(),
533                "the label must survive byte-for-byte"
534            );
535            assert!(tab.sections.is_empty(), "a fresh tab has no sections");
536            assert_eq!(tab.sections.len(), 0);
537            assert_eq!(
538                tab.sections.capacity(),
539                0,
540                "the const-slice-backed empty vec must report cap 0"
541            );
542            assert!(tab.sections.as_ref().is_empty());
543        }
544    }
545
546    #[test]
547    fn tab_new_with_a_100k_char_label_keeps_every_byte() {
548        let huge = "ab".repeat(50_000);
549        let tab = RibbonTab::new(AzString::from(huge.clone()));
550        assert_eq!(tab.label.as_str().len(), 100_000);
551        assert_eq!(tab.label.as_str(), huge);
552    }
553
554    // ------------------------------------------------------------------
555    // RibbonTab::add_section / with_section
556    // ------------------------------------------------------------------
557
558    #[test]
559    fn add_section_grows_the_const_backed_vec_without_freeing_static_memory() {
560        // `RibbonTab::new` seeds `sections` from `from_const_slice(&[])`
561        // (destructor = NoDestructor, ptr = &'static). The very first `push`
562        // therefore has to take the "fresh allocation" branch rather than
563        // realloc'ing static memory. If it took the realloc path this test
564        // aborts inside the allocator.
565        let mut tab = RibbonTab::new(AzString::from("t"));
566        for i in 0..1000usize {
567            tab.add_section(RibbonSection::new(
568                AzString::from(format!("s{i}")),
569                Dom::create_text(format!("c{i}")),
570            ));
571            assert_eq!(tab.sections.len(), i + 1);
572            assert!(
573                tab.sections.capacity() >= tab.sections.len(),
574                "capacity must never fall below len"
575            );
576        }
577
578        for (i, s) in tab.sections.as_ref().iter().enumerate() {
579            assert_eq!(s.title.as_str(), format!("s{i}"), "push must append in order");
580        }
581
582        // ...and the grown buffer is now genuinely owned: cloning it must deep
583        // copy, so a drop of both halves cannot double-free.
584        let cloned = tab.clone();
585        assert_eq!(cloned.sections.len(), 1000);
586        assert_ne!(
587            cloned.sections.as_ptr(),
588            tab.sections.as_ptr(),
589            "a library-owned vec must deep-clone, not alias"
590        );
591        drop(cloned);
592        assert_eq!(tab.sections.as_ref()[999].title.as_str(), "s999");
593    }
594
595    #[test]
596    fn add_section_accepts_extreme_titles_and_deeply_nested_content() {
597        let mut deep = Dom::create_text("leaf");
598        for _ in 0..256 {
599            deep = Dom::create_div().with_child(deep);
600        }
601
602        let mut tab = RibbonTab::new(AzString::from(""));
603        tab.add_section(RibbonSection::new(AzString::from(""), Dom::create_div()));
604        tab.add_section(RibbonSection::new(
605            AzString::from("x".repeat(100_000)),
606            deep.clone(),
607        ));
608
609        assert_eq!(tab.sections.len(), 2);
610        assert_eq!(tab.sections.as_ref()[0].title.as_str(), "");
611        assert_eq!(tab.sections.as_ref()[1].title.as_str().len(), 100_000);
612        assert_eq!(tab.sections.as_ref()[1].content, deep);
613    }
614
615    #[test]
616    fn with_section_matches_add_section() {
617        let make = || RibbonSection::new(AzString::from("s"), Dom::create_text("c"));
618
619        let built = RibbonTab::new(AzString::from("t"))
620            .with_section(make())
621            .with_section(make());
622
623        let mut mutated = RibbonTab::new(AzString::from("t"));
624        mutated.add_section(make());
625        mutated.add_section(make());
626
627        assert_eq!(built.sections.len(), mutated.sections.len());
628        assert_eq!(built.label.as_str(), mutated.label.as_str());
629        for (a, b) in built.sections.as_ref().iter().zip(mutated.sections.as_ref()) {
630            assert_eq!(a.title.as_str(), b.title.as_str());
631            assert_eq!(a.content, b.content);
632        }
633        // the builder form must not disturb the label
634        assert_eq!(built.label.as_str(), "t");
635    }
636
637    // ------------------------------------------------------------------
638    // RibbonSection::new  (constructor: no_panic + invariants)
639    // ------------------------------------------------------------------
640
641    #[test]
642    fn section_new_stores_both_args_unchanged() {
643        let content = Dom::create_div()
644            .with_child(Dom::create_text("a"))
645            .with_child(Dom::create_text("b"));
646
647        for title in nasty_strings() {
648            let sec = RibbonSection::new(AzString::from(title.clone()), content.clone());
649            assert_eq!(sec.title.as_str(), title.as_str());
650            assert_eq!(sec.content, content, "content must be stored verbatim");
651        }
652    }
653
654    #[test]
655    fn section_new_accepts_an_empty_and_a_pathologically_deep_content_dom() {
656        let empty = RibbonSection::new(AzString::from("t"), Dom::create_div());
657        assert!(empty.content.children.as_ref().is_empty());
658        assert_eq!(empty.content.estimated_total_children, 0);
659
660        let mut deep = Dom::create_text("leaf");
661        for _ in 0..512 {
662            deep = Dom::create_div().with_child(deep);
663        }
664        let sec = RibbonSection::new(AzString::from("t"), deep);
665        assert_eq!(
666            sec.content.estimated_total_children,
667            recursive_descendants(&sec.content),
668            "the cached descendant count must survive the move into the section"
669        );
670    }
671
672    // ------------------------------------------------------------------
673    // Ribbon::new  (constructor: no_panic + invariants)
674    // ------------------------------------------------------------------
675
676    #[test]
677    fn ribbon_new_defaults_to_tab_zero_and_installs_no_callback() {
678        for count in [0usize, 1, 2, 7, 500] {
679            let r = Ribbon::new(tabs(count, 1));
680
681            assert_eq!(r.tabs.len(), count, "new must not drop or duplicate tabs");
682            assert_eq!(r.active_tab, 0, "a fresh ribbon starts on tab 0");
683            assert!(
684                r.on_tab_click.is_none(),
685                "Ribbon::new must not install a callback"
686            );
687            for (i, t) in r.tabs.as_ref().iter().enumerate() {
688                assert_eq!(t.label.as_str(), format!("t{i}"));
689                assert_eq!(t.sections.len(), 1);
690            }
691        }
692    }
693
694    #[test]
695    fn ribbon_new_on_an_empty_vec_leaves_a_zero_active_tab_that_dom_survives() {
696        // active_tab == 0 is *out of range* for a tab-less ribbon. That is the
697        // documented default; the invariant that matters is that `dom()` does
698        // not index with it.
699        let r = Ribbon::new(RibbonTabVec::from_vec(Vec::new()));
700        assert_eq!(r.active_tab, 0);
701        assert!(r.tabs.is_empty());
702
703        let dom = r.dom();
704        let (bar, sections) = parts(&dom);
705        assert!(bar.children.as_ref().is_empty());
706        assert!(sections.children.as_ref().is_empty());
707    }
708
709    // ------------------------------------------------------------------
710    // Ribbon::set_active_tab  (numeric: zero / min / max / overflow)
711    // ------------------------------------------------------------------
712
713    #[test]
714    fn set_active_tab_on_an_empty_ribbon_always_lands_on_zero() {
715        // `len().saturating_sub(1)` is 0 for an empty vec — the clamp must not
716        // underflow-panic in a debug build.
717        let mut r = Ribbon::new(RibbonTabVec::from_vec(Vec::new()));
718        for index in [0usize, 1, 2, usize::MAX / 2, usize::MAX - 1, usize::MAX] {
719            r.set_active_tab(index);
720            assert_eq!(r.active_tab, 0, "empty ribbon must clamp {index} to 0");
721        }
722    }
723
724    #[test]
725    fn set_active_tab_clamps_to_the_last_valid_index() {
726        for count in [1usize, 2, 3, 8] {
727            let last = count - 1;
728            let mut r = Ribbon::new(tabs(count, 0));
729
730            for index in 0..count {
731                r.set_active_tab(index);
732                assert_eq!(r.active_tab, index, "in-range index must pass through");
733            }
734            for index in [count, count + 1, count * 2, usize::MAX] {
735                r.set_active_tab(index);
736                assert_eq!(
737                    r.active_tab, last,
738                    "out-of-range {index} must clamp to the last tab"
739                );
740            }
741        }
742    }
743
744    #[test]
745    fn set_active_tab_at_usize_min_and_max_does_not_overflow() {
746        let mut r = Ribbon::new(tabs(3, 0));
747
748        r.set_active_tab(usize::MIN);
749        assert_eq!(r.active_tab, 0);
750
751        r.set_active_tab(usize::MAX);
752        assert_eq!(r.active_tab, 2);
753
754        // `usize::MAX` is also what a negative index looks like after the
755        // wrap-around a C / FFI caller would perform, so it must clamp too.
756        r.set_active_tab(-1i64 as usize);
757        assert_eq!(r.active_tab, 2);
758        r.set_active_tab(-3i32 as usize);
759        assert_eq!(r.active_tab, 2);
760    }
761
762    #[test]
763    fn set_active_tab_is_idempotent_and_never_touches_the_tabs() {
764        let mut r = Ribbon::new(tabs(4, 2));
765
766        for _ in 0..3 {
767            r.set_active_tab(usize::MAX);
768            assert_eq!(r.active_tab, 3);
769        }
770        for _ in 0..3 {
771            r.set_active_tab(1);
772            assert_eq!(r.active_tab, 1);
773        }
774
775        assert_eq!(r.tabs.len(), 4, "clamping must not resize the tab list");
776        for (i, t) in r.tabs.as_ref().iter().enumerate() {
777            assert_eq!(t.label.as_str(), format!("t{i}"));
778            assert_eq!(t.sections.len(), 2);
779        }
780    }
781
782    // ------------------------------------------------------------------
783    // Ribbon::set_on_tab_click / with_on_tab_click
784    // ------------------------------------------------------------------
785
786    #[test]
787    fn set_on_tab_click_last_call_wins() {
788        let mut r = Ribbon::new(tabs(2, 0));
789
790        r.set_on_tab_click(RefAny::new(1u8), tab_cb(tab_do_nothing));
791        assert!(r.on_tab_click.is_some());
792        assert_eq!(
793            r.on_tab_click.as_ref().unwrap().callback,
794            tab_cb(tab_do_nothing)
795        );
796
797        // a second call must *replace*, not append / leak / panic
798        r.set_on_tab_click(RefAny::new(9i64), tab_cb(record_tab));
799        let set = r.on_tab_click.as_ref().expect("still Some");
800        assert_eq!(set.callback, tab_cb(record_tab));
801        assert_ne!(set.callback, tab_cb(tab_do_nothing));
802        assert_eq!(set.refany.get_type_id(), RefAny::new(0i64).get_type_id());
803
804        // ...and it leaves the rest of the widget alone
805        assert_eq!(r.tabs.len(), 2);
806        assert_eq!(r.active_tab, 0);
807    }
808
809    #[test]
810    fn set_on_tab_click_shares_rather_than_copies_the_caller_payload() {
811        let mut kept = RefAny::new(TabLog { seen: Vec::new() });
812        let mut r = Ribbon::new(tabs(1, 0));
813        r.set_on_tab_click(kept.clone(), tab_cb(record_tab));
814
815        // writing through the widget's handle is visible through the caller's
816        *r.on_tab_click
817            .as_mut()
818            .unwrap()
819            .refany
820            .downcast_mut::<TabLog>()
821            .expect("payload type preserved") = TabLog { seen: vec![42] };
822
823        assert_eq!(log_indices(&mut kept), vec![42]);
824    }
825
826    #[test]
827    fn with_on_tab_click_matches_set_on_tab_click() {
828        let built = Ribbon::new(tabs(3, 1)).with_on_tab_click(RefAny::new(7u32), tab_cb(record_tab));
829
830        let mut mutated = Ribbon::new(tabs(3, 1));
831        mutated.set_on_tab_click(RefAny::new(7u32), tab_cb(record_tab));
832
833        assert_eq!(
834            built.on_tab_click.as_ref().unwrap().callback,
835            mutated.on_tab_click.as_ref().unwrap().callback
836        );
837        // the builder form must not disturb the tabs or the active index
838        assert_eq!(built.tabs.len(), 3);
839        assert_eq!(built.active_tab, 0);
840    }
841
842    #[test]
843    fn with_on_tab_click_preserves_a_previously_clamped_active_tab() {
844        let mut r = Ribbon::new(tabs(4, 0));
845        r.set_active_tab(usize::MAX);
846        let r = r.with_on_tab_click(RefAny::new(0u8), tab_cb(record_tab));
847
848        assert_eq!(r.active_tab, 3, "installing a callback must not reset state");
849        assert!(r.on_tab_click.is_some());
850    }
851
852    // ------------------------------------------------------------------
853    // Ribbon::dom
854    // ------------------------------------------------------------------
855
856    #[test]
857    fn dom_of_an_empty_ribbon_is_a_classed_container_with_two_empty_children() {
858        let dom = Ribbon::new(RibbonTabVec::from_vec(Vec::new())).dom();
859
860        assert!(has_class(&dom, "__azul-native-ribbon"));
861        assert_eq!(inline_props(&dom), style_props(RIBBON_CONTAINER_STYLE));
862
863        let (bar, sections) = parts(&dom);
864        assert_eq!(inline_props(bar), style_props(TAB_BAR_STYLE));
865        assert_eq!(
866            inline_props(sections),
867            style_props(SECTIONS_CONTAINER_STYLE)
868        );
869        assert!(bar.children.as_ref().is_empty());
870        assert!(sections.children.as_ref().is_empty());
871        assert_eq!(dom.estimated_total_children, 2);
872    }
873
874    #[test]
875    fn dom_styles_exactly_the_active_tab() {
876        let count = 5usize;
877        for active in 0..count {
878            let mut r = Ribbon::new(tabs(count, 0));
879            r.set_active_tab(active);
880            let dom = r.dom();
881            let (bar, _) = parts(&dom);
882
883            assert_eq!(bar.children.as_ref().len(), count);
884            for (i, tab) in bar.children.as_ref().iter().enumerate() {
885                let expected = if i == active {
886                    style_props(TAB_ACTIVE_STYLE)
887                } else {
888                    style_props(TAB_INACTIVE_STYLE)
889                };
890                assert_eq!(inline_props(tab), expected, "tab {i} (active = {active})");
891                let want = format!("t{i}");
892                assert_eq!(text_of(tab), Some(want.as_str()));
893            }
894        }
895    }
896
897    #[test]
898    fn dom_with_an_out_of_range_active_tab_highlights_nothing_and_renders_no_sections() {
899        // `active_tab` is a public field, so it can hold a value `set_active_tab`
900        // would have clamped away. `dom()` must not index with it.
901        for active in [3usize, 4, usize::MAX / 2, usize::MAX - 1, usize::MAX] {
902            let mut r = Ribbon::new(tabs(3, 2));
903            r.active_tab = active;
904
905            let dom = r.dom();
906            let (bar, sections) = parts(&dom);
907
908            assert_eq!(bar.children.as_ref().len(), 3, "every tab is still shown");
909            for tab in bar.children.as_ref() {
910                assert_eq!(
911                    inline_props(tab),
912                    style_props(TAB_INACTIVE_STYLE),
913                    "no tab may be styled active when active_tab is out of range"
914                );
915            }
916            assert!(
917                sections.children.as_ref().is_empty(),
918                "an out-of-range active tab renders an empty section container"
919            );
920        }
921    }
922
923    #[test]
924    fn dom_survives_a_stale_active_tab_after_the_tab_list_shrinks() {
925        let mut r = Ribbon::new(tabs(3, 1));
926        r.set_active_tab(2);
927        // the public `tabs` field is swapped out from under the clamped index
928        r.tabs = tabs(1, 1);
929
930        let dom = r.dom();
931        let (bar, sections) = parts(&dom);
932        assert_eq!(bar.children.as_ref().len(), 1);
933        assert!(sections.children.as_ref().is_empty());
934    }
935
936    #[test]
937    fn dom_renders_only_the_active_tabs_sections_in_content_then_title_order() {
938        let mut r = Ribbon::new(tabs(3, 4));
939        r.set_active_tab(1);
940        let dom = r.dom();
941        let (_, sections) = parts(&dom);
942
943        assert_eq!(sections.children.as_ref().len(), 4);
944        for j in 0..4 {
945            let section = &sections.children.as_ref()[j];
946            assert_eq!(inline_props(section), style_props(SECTION_STYLE));
947
948            let (content, title) = section_parts(sections, j);
949            assert_eq!(inline_props(content), style_props(SECTION_CONTENT_STYLE));
950            assert_eq!(inline_props(title), style_props(SECTION_TITLE_STYLE));
951
952            // the title is rendered *after* the content, as documented
953            let want_title = format!("t1s{j}");
954            let want_content = format!("t1c{j}");
955            assert_eq!(text_of(title), Some(want_title.as_str()));
956            let inner = content.children.as_ref();
957            assert_eq!(inner.len(), 1, "the wrapper holds exactly the user content");
958            assert_eq!(text_of(&inner[0]), Some(want_content.as_str()));
959        }
960    }
961
962    #[test]
963    fn dom_round_trips_pathological_labels_and_titles_byte_for_byte() {
964        let strings = nasty_strings();
965        let mut v = Vec::new();
966        for s in &strings {
967            v.push(
968                RibbonTab::new(AzString::from(s.clone())).with_section(RibbonSection::new(
969                    AzString::from(s.clone()),
970                    Dom::create_text(s.clone()),
971                )),
972            );
973        }
974        let dom = Ribbon::new(RibbonTabVec::from_vec(v)).dom();
975        let (bar, sections) = parts(&dom);
976
977        assert_eq!(bar.children.as_ref().len(), strings.len());
978        for (i, s) in strings.iter().enumerate() {
979            assert_eq!(
980                text_of(&bar.children.as_ref()[i]),
981                Some(s.as_str()),
982                "tab label {i} must survive the DOM round trip"
983            );
984        }
985
986        // active_tab defaults to 0 -> only the first tab's section is rendered
987        assert_eq!(sections.children.as_ref().len(), 1);
988        let (content, title) = section_parts(sections, 0);
989        assert_eq!(text_of(title), Some(strings[0].as_str()));
990        assert_eq!(
991            text_of(&content.children.as_ref()[0]),
992            Some(strings[0].as_str())
993        );
994    }
995
996    #[test]
997    fn dom_without_a_callback_attaches_no_callbacks_at_all() {
998        let dom = Ribbon::new(tabs(6, 2)).dom();
999        let (bar, sections) = parts(&dom);
1000
1001        for tab in bar.children.as_ref() {
1002            assert!(
1003                tab.root.get_callbacks().as_ref().is_empty(),
1004                "no user callback -> no MouseUp handler"
1005            );
1006        }
1007        for section in sections.children.as_ref() {
1008            assert!(section.root.get_callbacks().as_ref().is_empty());
1009        }
1010        assert!(dom.root.get_callbacks().as_ref().is_empty());
1011    }
1012
1013    #[test]
1014    fn dom_gives_every_tab_one_mouseup_callback_carrying_its_own_index() {
1015        let count = 64usize;
1016        let dom = Ribbon::new(tabs(count, 1))
1017            .with_on_tab_click(RefAny::new(TabLog { seen: Vec::new() }), tab_cb(record_tab))
1018            .dom();
1019        let (bar, _) = parts(&dom);
1020
1021        assert_eq!(bar.children.as_ref().len(), count);
1022        for (i, tab) in bar.children.as_ref().iter().enumerate() {
1023            let cbs = tab.root.get_callbacks();
1024            assert_eq!(cbs.as_ref().len(), 1, "exactly one callback per tab");
1025            assert_eq!(
1026                cbs.as_ref()[0].event,
1027                EventFilter::Hover(HoverEventFilter::MouseUp)
1028            );
1029            assert_eq!(cbs.as_ref()[0].callback.cb, on_ribbon_tab_click as usize);
1030
1031            let mut payload = cbs.as_ref()[0].refany.clone();
1032            let data = payload
1033                .downcast_ref::<TabClickData>()
1034                .expect("tab payload is a TabClickData");
1035            assert_eq!(data.tab_idx, i, "each tab must know its own index");
1036            assert!(data.on_tab_click.is_some());
1037        }
1038    }
1039
1040    #[test]
1041    fn dom_shares_the_user_payload_with_every_tab_and_keeps_the_caller_handle_alive() {
1042        let mut kept = RefAny::new(TabLog { seen: Vec::new() });
1043        let dom = Ribbon::new(tabs(3, 0))
1044            .with_on_tab_click(kept.clone(), tab_cb(record_tab))
1045            .dom();
1046        let (bar, _) = parts(&dom);
1047
1048        // write through tab 2's copy of the shared payload...
1049        let mut payload = bar.children.as_ref()[2].root.get_callbacks().as_ref()[0]
1050            .refany
1051            .clone();
1052        {
1053            let mut data = payload
1054                .downcast_mut::<TabClickData>()
1055                .expect("tab payload is a TabClickData");
1056            data.on_tab_click
1057                .as_mut()
1058                .unwrap()
1059                .refany
1060                .downcast_mut::<TabLog>()
1061                .expect("user payload type preserved")
1062                .seen
1063                .push(7);
1064        }
1065
1066        // ...and the caller's handle sees it (RefAny shares, it does not deep-copy)
1067        assert_eq!(log_indices(&mut kept), vec![7]);
1068    }
1069
1070    #[test]
1071    fn dom_child_count_cache_stays_consistent_for_deeply_nested_content() {
1072        let mut deep = Dom::create_text("leaf");
1073        for _ in 0..128 {
1074            deep = Dom::create_div().with_child(deep);
1075        }
1076
1077        let tab = RibbonTab::new(AzString::from("t"))
1078            .with_section(RibbonSection::new(AzString::from("deep"), deep))
1079            .with_section(RibbonSection::new(AzString::from(""), Dom::create_div()));
1080
1081        let dom = Ribbon::new(RibbonTabVec::from_vec(vec![tab])).dom();
1082
1083        // a too-small cache makes `convert_dom_into_compact_dom` under-allocate
1084        // its arenas and panic on an out-of-bounds write later
1085        assert_eq!(
1086            dom.estimated_total_children,
1087            recursive_descendants(&dom),
1088            "cached descendant count desynced from the real tree"
1089        );
1090        assert_eq!(
1091            dom.estimated_total_children,
1092            dom.recompute_estimated_total_children()
1093        );
1094    }
1095
1096    #[test]
1097    fn dom_with_many_tabs_and_sections_does_not_panic() {
1098        let mut r = Ribbon::new(tabs(500, 20));
1099        r.set_active_tab(499);
1100        let dom = r.dom();
1101        let (bar, sections) = parts(&dom);
1102
1103        assert_eq!(bar.children.as_ref().len(), 500);
1104        assert_eq!(sections.children.as_ref().len(), 20);
1105        assert_eq!(
1106            inline_props(&bar.children.as_ref()[499]),
1107            style_props(TAB_ACTIVE_STYLE)
1108        );
1109    }
1110
1111    #[test]
1112    fn dom_of_a_tab_with_no_sections_yields_an_empty_but_styled_container() {
1113        let dom = Ribbon::new(tabs(2, 0)).dom();
1114        let (bar, sections) = parts(&dom);
1115
1116        assert_eq!(bar.children.as_ref().len(), 2);
1117        assert!(sections.children.as_ref().is_empty());
1118        assert_eq!(
1119            inline_props(sections),
1120            style_props(SECTIONS_CONTAINER_STYLE),
1121            "the empty branch must still carry the container style"
1122        );
1123    }
1124
1125    #[test]
1126    fn from_ribbon_for_dom_matches_dom() {
1127        // Only meaningful without a callback: every `dom()` call mints fresh
1128        // per-tab `RefAny`s and two distinct `RefAny`s never compare equal.
1129        assert_eq!(Dom::from(Ribbon::new(tabs(3, 2))), Ribbon::new(tabs(3, 2)).dom());
1130        assert_eq!(
1131            Dom::from(Ribbon::new(RibbonTabVec::from_vec(Vec::new()))),
1132            Ribbon::new(RibbonTabVec::from_vec(Vec::new())).dom()
1133        );
1134    }
1135
1136    // ------------------------------------------------------------------
1137    // on_ribbon_tab_click
1138    // ------------------------------------------------------------------
1139
1140    #[test]
1141    fn tab_click_with_a_foreign_payload_is_a_noop() {
1142        let (update, changes) = run_click(0, RefAny::new(0xdead_beef_u64));
1143
1144        assert_eq!(update, Update::DoNothing);
1145        assert!(
1146            changes.is_empty(),
1147            "a foreign payload must not touch the window"
1148        );
1149    }
1150
1151    #[test]
1152    fn tab_click_without_a_user_callback_is_a_noop() {
1153        let data = RefAny::new(TabClickData {
1154            tab_idx: 3,
1155            on_tab_click: None.into(),
1156        });
1157
1158        let (update, changes) = run_click(0, data);
1159
1160        assert_eq!(update, Update::DoNothing);
1161        assert!(changes.is_empty(), "the handler never restyles by itself");
1162    }
1163
1164    #[test]
1165    fn tab_click_forwards_the_index_and_propagates_the_user_update() {
1166        let mut log = RefAny::new(TabLog { seen: Vec::new() });
1167        let data = RefAny::new(TabClickData {
1168            tab_idx: 17,
1169            on_tab_click: Some(RibbonOnTabClick {
1170                callback: tab_cb(record_tab),
1171                refany: log.clone(),
1172            })
1173            .into(),
1174        });
1175
1176        let (update, changes) = run_click(0, data.clone());
1177
1178        assert_eq!(update, Update::RefreshDom, "the user's Update must win");
1179        assert!(changes.is_empty());
1180        assert_eq!(log_indices(&mut log), vec![17]);
1181
1182        // the handler is stateless: a second click reports the same index again
1183        let (update, _) = run_click(0, data);
1184        assert_eq!(update, Update::RefreshDom);
1185        assert_eq!(log_indices(&mut log), vec![17, 17]);
1186    }
1187
1188    #[test]
1189    fn tab_click_forwards_extreme_indices_verbatim() {
1190        let mut log = RefAny::new(TabLog { seen: Vec::new() });
1191        let indices = [0usize, 1, usize::MAX / 2, usize::MAX - 1, usize::MAX];
1192
1193        for idx in indices {
1194            let data = RefAny::new(TabClickData {
1195                tab_idx: idx,
1196                on_tab_click: Some(RibbonOnTabClick {
1197                    callback: tab_cb(record_tab),
1198                    refany: log.clone(),
1199                })
1200                .into(),
1201            });
1202            let (update, _) = run_click(0, data);
1203            assert_eq!(update, Update::RefreshDom);
1204        }
1205
1206        assert_eq!(
1207            log_indices(&mut log),
1208            indices.to_vec(),
1209            "indices must reach the user callback without clamping or wrapping"
1210        );
1211    }
1212
1213    #[test]
1214    fn tab_click_propagates_every_update_variant() {
1215        for (cb, expected) in [
1216            (tab_cb(tab_do_nothing), Update::DoNothing),
1217            (tab_cb(tab_refresh_all), Update::RefreshDomAllWindows),
1218        ] {
1219            let data = RefAny::new(TabClickData {
1220                tab_idx: 0,
1221                on_tab_click: Some(RibbonOnTabClick {
1222                    callback: cb,
1223                    refany: RefAny::new(0u8),
1224                })
1225                .into(),
1226            });
1227            let (update, changes) = run_click(0, data);
1228            assert_eq!(update, expected);
1229            assert!(changes.is_empty());
1230        }
1231    }
1232
1233    #[test]
1234    fn tab_click_ignores_the_hit_node_entirely() {
1235        // the handler is a pure forwarder — a hit node that does not exist in
1236        // any layout result must behave exactly like a valid one.
1237        let mut log = RefAny::new(TabLog { seen: Vec::new() });
1238        for hit in [0usize, 1, 999, usize::MAX / 4] {
1239            let data = RefAny::new(TabClickData {
1240                tab_idx: 5,
1241                on_tab_click: Some(RibbonOnTabClick {
1242                    callback: tab_cb(record_tab),
1243                    refany: log.clone(),
1244                })
1245                .into(),
1246            });
1247            let (update, changes) = run_click(hit, data);
1248            assert_eq!(update, Update::RefreshDom);
1249            assert!(changes.is_empty());
1250        }
1251        assert_eq!(log_indices(&mut log), vec![5, 5, 5, 5]);
1252    }
1253
1254    #[test]
1255    fn tab_click_from_a_real_dom_payload_reports_the_clicked_tab() {
1256        let mut log = RefAny::new(TabLog { seen: Vec::new() });
1257        let dom = Ribbon::new(tabs(4, 1))
1258            .with_on_tab_click(log.clone(), tab_cb(record_tab))
1259            .dom();
1260        let (bar, _) = parts(&dom);
1261
1262        for i in [3usize, 0, 2, 1] {
1263            let payload = bar.children.as_ref()[i].root.get_callbacks().as_ref()[0]
1264                .refany
1265                .clone();
1266            let (update, changes) = run_click(i, payload);
1267            assert_eq!(update, Update::RefreshDom);
1268            assert!(changes.is_empty());
1269        }
1270
1271        assert_eq!(
1272            log_indices(&mut log),
1273            vec![3, 0, 2, 1],
1274            "each tab's payload must report that tab's own index"
1275        );
1276    }
1277}