1use 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)] use 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
30pub 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
52const 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
59const 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#[derive(Debug, Clone)]
143#[repr(C)]
144pub struct Ribbon {
145 pub tabs: RibbonTabVec,
147 pub active_tab: usize,
149 pub on_tab_click: OptionRibbonOnTabClick,
151}
152
153#[derive(Debug, Clone)]
155#[repr(C)]
156pub struct RibbonTab {
157 pub label: AzString,
159 pub sections: RibbonSectionVec,
161}
162
163#[derive(Debug, Clone)]
165#[repr(C)]
166pub struct RibbonSection {
167 pub title: AzString,
169 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 #[must_use] pub const fn new(label: AzString) -> Self {
188 Self { label, sections: RibbonSectionVec::from_const_slice(&[]) }
189 }
190
191 pub fn add_section(&mut self, section: RibbonSection) {
193 self.sections.push(section);
194 }
195
196 #[must_use] pub fn with_section(mut self, section: RibbonSection) -> Self {
198 self.add_section(section);
199 self
200 }
201}
202
203impl RibbonSection {
204 #[must_use] pub const fn new(title: AzString, content: Dom) -> Self {
206 Self { title, content }
207 }
208}
209
210impl Ribbon {
211 #[must_use] pub fn new(tabs: RibbonTabVec) -> Self {
213 Self { tabs, active_tab: 0, on_tab_click: None.into() }
214 }
215
216 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 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 #[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 #[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 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 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 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 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 fn style_props(style: &[Cond]) -> Vec<CssProperty> {
393 style.iter().map(|c| c.property.clone()).collect()
394 }
395
396 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 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 fn section_parts(sections: &Dom, n: usize) -> (&Dom, &Dom) {
415 let sec = §ions.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 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 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 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 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: ¤t_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 #[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 #[test]
559 fn add_section_grows_the_const_backed_vec_without_freeing_static_memory() {
560 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 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 assert_eq!(built.label.as_str(), "t");
635 }
636
637 #[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 #[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 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 #[test]
714 fn set_active_tab_on_an_empty_ribbon_always_lands_on_zero() {
715 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 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 #[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 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 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 *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 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 #[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 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 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 = §ions.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 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 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 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 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 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 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 #[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 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 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}