1use super::*;
2use crate::ffi::SemanticRole;
3use crate::node::{column, NodeRef};
4use crate::retained_view::{retained_view, RetainedView};
5use std::any::Any;
6use std::cell::{Cell, RefCell};
7use std::rc::{Rc, Weak};
8
9pub type TabContentFactory = Box<dyn FnOnce() -> RetainedView>;
10type TabSelectionChangedCallback = Rc<dyn Fn(TabSelectionChangedEventArgs)>;
11
12#[derive(Clone)]
13pub struct TabSelectionChangedEventArgs {
14 pub previous_index: i32,
15 pub selected_index: i32,
16 pub previous_item: Option<TabItem>,
17 pub selected_item: Option<TabItem>,
18}
19
20struct TabItemInner {
21 label: RefCell<String>,
22 description: RefCell<String>,
23 enabled: Cell<bool>,
24 factory: RefCell<Option<TabContentFactory>>,
25 prepared_view: RefCell<Option<RetainedView>>,
26 retained_view: RefCell<Option<RetainedView>>,
27 owner: RefCell<Weak<TabViewInner>>,
28}
29
30#[derive(Clone)]
31pub struct TabItem {
32 inner: Rc<TabItemInner>,
33}
34
35impl From<&TabItem> for TabItem {
36 fn from(item: &TabItem) -> Self {
37 item.clone()
38 }
39}
40
41impl TabItem {
42 pub fn new(label: impl Into<String>) -> Self {
43 Self {
44 inner: Rc::new(TabItemInner {
45 label: RefCell::new(label.into()),
46 description: RefCell::new(String::new()),
47 enabled: Cell::new(true),
48 factory: RefCell::new(None),
49 prepared_view: RefCell::new(None),
50 retained_view: RefCell::new(None),
51 owner: RefCell::new(Weak::new()),
52 }),
53 }
54 }
55
56 pub fn with_content(
57 label: impl Into<String>,
58 factory: impl FnOnce() -> RetainedView + 'static,
59 ) -> Self {
60 let item = Self::new(label);
61 item.content(factory);
62 item
63 }
64
65 pub fn with_content_view(label: impl Into<String>, view: RetainedView) -> Self {
66 let item = Self::new(label);
67 item.content_view(view);
68 item
69 }
70
71 pub fn label_text(&self) -> String {
72 self.inner.label.borrow().clone()
73 }
74
75 pub fn description_text(&self) -> String {
76 self.inner.description.borrow().clone()
77 }
78
79 pub fn is_enabled(&self) -> bool {
80 self.inner.enabled.get()
81 }
82
83 pub fn is_materialized(&self) -> bool {
84 self.inner.retained_view.borrow().is_some()
85 }
86
87 pub fn label(&self, value: impl Into<String>) -> &Self {
88 *self.inner.label.borrow_mut() = value.into();
89 self.notify_metadata_changed();
90 self
91 }
92
93 pub fn description(&self, value: impl Into<String>) -> &Self {
94 *self.inner.description.borrow_mut() = value.into();
95 self.notify_metadata_changed();
96 self
97 }
98
99 pub fn enabled(&self, enabled: bool) -> &Self {
100 if self.inner.enabled.replace(enabled) != enabled {
101 if let Some(owner) = self.inner.owner.borrow().upgrade() {
102 TabView { inner: owner }.item_enabled_changed(self);
103 }
104 }
105 self
106 }
107
108 pub fn content(&self, factory: impl FnOnce() -> RetainedView + 'static) -> &Self {
109 if self.is_materialized() {
110 crate::logger::warn(
111 "Lifecycle",
112 "TabItem.content() cannot replace content after the tab has been materialized.",
113 );
114 return self;
115 }
116 self.inner.prepared_view.borrow_mut().take();
117 *self.inner.factory.borrow_mut() = Some(Box::new(factory));
118 self
119 }
120
121 pub fn content_view(&self, view: RetainedView) -> &Self {
122 if self.is_materialized() {
123 crate::logger::warn(
124 "Lifecycle",
125 "TabItem.content_view() cannot replace content after the tab has been materialized.",
126 );
127 return self;
128 }
129 *self.inner.prepared_view.borrow_mut() = Some(view);
130 self.inner.factory.borrow_mut().take();
131 self
132 }
133
134 fn ptr_eq(&self, other: &Self) -> bool {
135 Rc::ptr_eq(&self.inner, &other.inner)
136 }
137
138 fn bind_owner(&self, owner: Weak<TabViewInner>) {
139 *self.inner.owner.borrow_mut() = owner;
140 }
141
142 fn notify_metadata_changed(&self) {
143 if let Some(owner) = self.inner.owner.borrow().upgrade() {
144 TabView { inner: owner }.item_metadata_changed(self);
145 }
146 }
147
148 fn resolve_view(&self) -> RetainedView {
149 if let Some(view) = self.inner.retained_view.borrow().clone() {
150 return view;
151 }
152 let resolved = if let Some(view) = self.inner.prepared_view.borrow_mut().take() {
153 view
154 } else if let Some(factory) = self.inner.factory.borrow_mut().take() {
155 factory()
156 } else {
157 let root = column();
158 root.fill_size();
159 retained_view(&root)
160 };
161 *self.inner.retained_view.borrow_mut() = Some(resolved.clone());
162 resolved
163 }
164
165 fn existing_view(&self) -> Option<RetainedView> {
166 self.inner.retained_view.borrow().clone()
167 }
168
169 fn dispose_content(&self) {
170 if let Some(view) = self.inner.retained_view.borrow_mut().take() {
171 view.dispose();
172 }
173 if let Some(view) = self.inner.prepared_view.borrow_mut().take() {
174 view.dispose();
175 }
176 self.inner.factory.borrow_mut().take();
177 }
178}
179
180#[derive(Clone, Copy)]
181struct SelectionRequest {
182 index: i32,
183 emit: bool,
184}
185
186struct TabViewInner {
187 root: FlexBox,
188 content_presenter: FlexBox,
189 items: RefCell<Vec<TabItem>>,
190 selected_index: Cell<i32>,
191 selecting: Cell<bool>,
192 pending: Cell<Option<SelectionRequest>>,
193 changed: RefCell<Option<TabSelectionChangedCallback>>,
194}
195
196impl Drop for TabViewInner {
197 fn drop(&mut self) {
198 for item in self.items.get_mut().drain(..) {
199 item.bind_owner(Weak::new());
200 item.dispose_content();
201 }
202 }
203}
204
205#[derive(Clone)]
206pub struct TabView {
207 inner: Rc<TabViewInner>,
208}
209
210impl TabView {
211 pub fn new() -> Self {
212 let root = column();
213 let content_presenter = column();
214 content_presenter
215 .fill_size()
216 .semantic_role(SemanticRole::TabPanel)
217 .semantic_label("Tab panel");
218 root.child(&content_presenter);
219 Self {
220 inner: Rc::new(TabViewInner {
221 root,
222 content_presenter,
223 items: RefCell::new(Vec::new()),
224 selected_index: Cell::new(-1),
225 selecting: Cell::new(false),
226 pending: Cell::new(None),
227 changed: RefCell::new(None),
228 }),
229 }
230 }
231
232 pub fn with_items<I>(items: I) -> Self
233 where
234 I: IntoIterator<Item = TabItem>,
235 {
236 let tabs = Self::new();
237 tabs.items(items);
238 tabs
239 }
240
241 pub fn selected_index(&self) -> i32 {
242 self.inner.selected_index.get()
243 }
244
245 pub fn selected_item(&self) -> Option<TabItem> {
246 self.item_at(self.selected_index())
247 }
248
249 pub fn item_count(&self) -> usize {
250 self.inner.items.borrow().len()
251 }
252
253 pub fn content_presenter(&self) -> FlexBox {
254 self.inner.content_presenter.clone()
255 }
256
257 pub fn item_at(&self, index: i32) -> Option<TabItem> {
258 if index < 0 {
259 return None;
260 }
261 self.inner.items.borrow().get(index as usize).cloned()
262 }
263
264 pub fn items<I>(&self, items: I) -> &Self
265 where
266 I: IntoIterator<Item = TabItem>,
267 {
268 self.clear_items();
269 for item in items {
270 self.add_item(item);
271 }
272 self
273 }
274
275 pub fn add_item(&self, item: TabItem) -> &Self {
276 if self.index_of_item(&item) >= 0 {
277 return self;
278 }
279 item.bind_owner(Rc::downgrade(&self.inner));
280 self.inner.items.borrow_mut().push(item.clone());
281 if self.selected_index() < 0 && item.is_enabled() {
282 self.select_index_internal(self.item_count() as i32 - 1, false, None, -1);
283 }
284 self
285 }
286
287 pub fn remove_item(&self, item: &TabItem) -> &Self {
288 let index = self.index_of_item(item);
289 if index >= 0 {
290 self.remove_item_at(index);
291 }
292 self
293 }
294
295 pub fn remove_item_at(&self, index: i32) -> &Self {
296 if index < 0 || index as usize >= self.item_count() {
297 return self;
298 }
299 let removed = self.item_at(index).expect("validated tab index");
300 let was_selected = index == self.selected_index();
301 if was_selected {
302 self.detach_active_view();
303 }
304 removed.bind_owner(Weak::new());
305 self.inner.items.borrow_mut().remove(index as usize);
306 if was_selected {
307 let previous_index = self.selected_index();
308 self.inner.selected_index.set(-1);
309 let replacement = self.find_replacement_index(index);
310 if replacement >= 0 {
311 self.select_index_internal(
312 replacement,
313 true,
314 Some(removed.clone()),
315 previous_index,
316 );
317 } else {
318 self.emit_changed(previous_index, -1, Some(removed.clone()), None);
319 }
320 } else if self.selected_index() > index {
321 self.inner.selected_index.set(self.selected_index() - 1);
322 }
323 removed.dispose_content();
324 self
325 }
326
327 pub fn clear_items(&self) -> &Self {
328 let previous_index = self.selected_index();
329 let previous_item = self.selected_item();
330 self.detach_active_view();
331 for item in self.inner.items.replace(Vec::new()) {
332 item.bind_owner(Weak::new());
333 item.dispose_content();
334 }
335 self.inner.selected_index.set(-1);
336 if previous_item.is_some() {
337 self.emit_changed(previous_index, -1, previous_item, None);
338 }
339 self
340 }
341
342 pub fn select_index(&self, index: i32) -> &Self {
343 self.select_index_internal(index, true, None, -1);
344 self
345 }
346
347 pub fn on_selection_changed(
348 &self,
349 handler: impl Fn(TabSelectionChangedEventArgs) + 'static,
350 ) -> &Self {
351 *self.inner.changed.borrow_mut() = Some(Rc::new(handler));
352 self
353 }
354
355 pub fn clear_selection_changed(&self) -> &Self {
356 self.inner.changed.borrow_mut().take();
357 self
358 }
359
360 fn item_enabled_changed(&self, item: &TabItem) {
361 let index = self.index_of_item(item);
362 if index < 0 {
363 return;
364 }
365 if !item.is_enabled() && index == self.selected_index() {
366 let previous_index = self.selected_index();
367 self.detach_active_view();
368 self.inner.selected_index.set(-1);
369 let replacement = self.find_replacement_index(index);
370 if replacement >= 0 {
371 self.select_index_internal(replacement, true, Some(item.clone()), previous_index);
372 } else {
373 self.emit_changed(previous_index, -1, Some(item.clone()), None);
374 }
375 } else if item.is_enabled() && self.selected_index() < 0 {
376 self.select_index_internal(index, true, None, -1);
377 }
378 }
379
380 fn item_metadata_changed(&self, item: &TabItem) {
381 if self.index_of_item(item) == self.selected_index() {
382 self.inner
383 .content_presenter
384 .semantic_label(format!("{} tab panel", item.label_text()));
385 }
386 }
387
388 fn select_index_internal(
389 &self,
390 index: i32,
391 emit: bool,
392 previous_item_override: Option<TabItem>,
393 previous_index_override: i32,
394 ) {
395 if !self.is_enabled_index(index) {
396 return;
397 }
398 if self.inner.selecting.replace(true) {
399 self.inner
400 .pending
401 .set(Some(SelectionRequest { index, emit }));
402 return;
403 }
404 let mut request = SelectionRequest { index, emit };
405 let mut override_item = previous_item_override;
406 let mut override_index = previous_index_override;
407 let mut iterations = 0usize;
408 loop {
409 if !self.is_enabled_index(request.index) {
410 break;
411 }
412 let previous_index = if override_item.is_some() {
413 override_index
414 } else {
415 self.selected_index()
416 };
417 let previous_item = override_item.take().or_else(|| self.selected_item());
418 override_index = -1;
419 if self.selected_index() != request.index {
420 self.detach_active_view();
421 self.inner.selected_index.set(request.index);
422 let item = self.item_at(request.index).expect("enabled tab exists");
423 let view = item.resolve_view();
424 self.inner
425 .content_presenter
426 .retained_node_ref()
427 .append_child_ref(&view.root());
428 self.inner
429 .content_presenter
430 .semantic_label(format!("{} tab panel", item.label_text()));
431 view.activate();
432 if request.emit {
433 self.emit_changed(previous_index, request.index, previous_item, Some(item));
434 }
435 }
436 let Some(next) = self.inner.pending.take() else {
437 break;
438 };
439 request = next;
440 iterations += 1;
441 if iterations > self.item_count() + 8 {
442 crate::logger::warn("Lifecycle", "TabView stopped a re-entrant selection loop.");
443 break;
444 }
445 }
446 self.inner.pending.set(None);
447 self.inner.selecting.set(false);
448 }
449
450 fn detach_active_view(&self) {
451 let Some(item) = self.selected_item() else {
452 return;
453 };
454 let Some(view) = item.existing_view() else {
455 return;
456 };
457 view.deactivate();
458 view.root().detach_from_parent();
459 }
460
461 fn emit_changed(
462 &self,
463 previous_index: i32,
464 selected_index: i32,
465 previous_item: Option<TabItem>,
466 selected_item: Option<TabItem>,
467 ) {
468 let callback = self.inner.changed.borrow().clone();
469 if let Some(callback) = callback {
470 callback(TabSelectionChangedEventArgs {
471 previous_index,
472 selected_index,
473 previous_item,
474 selected_item,
475 });
476 }
477 }
478
479 fn index_of_item(&self, item: &TabItem) -> i32 {
480 self.inner
481 .items
482 .borrow()
483 .iter()
484 .position(|candidate| candidate.ptr_eq(item))
485 .map_or(-1, |index| index as i32)
486 }
487
488 fn is_enabled_index(&self, index: i32) -> bool {
489 self.item_at(index).is_some_and(|item| item.is_enabled())
490 }
491
492 fn find_replacement_index(&self, index: i32) -> i32 {
493 for cursor in index..self.item_count() as i32 {
494 if self.is_enabled_index(cursor) {
495 return cursor;
496 }
497 }
498 for cursor in (0..index.min(self.item_count() as i32)).rev() {
499 if self.is_enabled_index(cursor) {
500 return cursor;
501 }
502 }
503 -1
504 }
505}
506
507impl Default for TabView {
508 fn default() -> Self {
509 Self::new()
510 }
511}
512
513impl Node for TabView {
514 fn retained_node_ref(&self) -> NodeRef {
515 self.inner.root.retained_node_ref()
516 }
517
518 fn retained_owner_attachment(&self) -> Option<Rc<dyn Any>> {
519 Some(self.inner.clone())
520 }
521
522 fn build_self(&self) {
523 self.inner.root.build_self();
524 }
525}
526
527impl HasFlexBoxRoot for TabView {
528 fn flex_box_root(&self) -> &FlexBox {
529 &self.inner.root
530 }
531}
532
533impl ThemeBindable for TabView {
534 fn theme_binding_node(&self) -> NodeRef {
535 self.inner.root.retained_node_ref()
536 }
537
538 fn weak_theme_target(&self) -> Box<dyn Fn() -> Option<Self>> {
539 let weak = Rc::downgrade(&self.inner);
540 Box::new(move || {
541 Some(Self {
542 inner: weak.upgrade()?,
543 })
544 })
545 }
546}
547
548pub fn tab_view() -> TabView {
549 TabView::new()
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use crate::ffi::{self, Call};
556 use crate::Application;
557
558 fn view(label: &str, events: Rc<RefCell<Vec<String>>>) -> RetainedView {
559 let root = column();
560 root.child(&crate::node::text(label));
561 retained_view(&root)
562 .on_activate({
563 let events = events.clone();
564 let label = label.to_string();
565 move |_| events.borrow_mut().push(format!("activate:{label}"))
566 })
567 .on_deactivate({
568 let events = events.clone();
569 let label = label.to_string();
570 move |_| events.borrow_mut().push(format!("deactivate:{label}"))
571 })
572 }
573
574 #[test]
575 fn lazy_content_materializes_once_and_lifecycle_follows_selection() {
576 ffi::test::reset();
577 let events = Rc::new(RefCell::new(Vec::new()));
578 let builds = Rc::new(Cell::new(0));
579 let first = TabItem::with_content("First", {
580 let events = events.clone();
581 let builds = builds.clone();
582 move || {
583 builds.set(builds.get() + 1);
584 view("first", events)
585 }
586 });
587 let second = TabItem::with_content_view("Second", view("second", events.clone()));
588 let tabs = TabView::with_items([first.clone(), second]);
589 assert_eq!(tabs.selected_index(), 0);
590 assert_eq!(builds.get(), 1);
591 tabs.select_index(1).select_index(0);
592 assert_eq!(builds.get(), 1);
593 assert_eq!(
594 &*events.borrow(),
595 &[
596 "activate:first",
597 "deactivate:first",
598 "activate:second",
599 "deactivate:second",
600 "activate:first"
601 ]
602 );
603 assert_eq!(
604 tabs.content_presenter()
605 .retained_node_ref()
606 .children()
607 .len(),
608 1
609 );
610 }
611
612 #[test]
613 fn retained_tree_contains_only_the_selected_content_host() {
614 ffi::test::reset();
615 let tabs = TabView::with_items([TabItem::new("Alpha"), TabItem::new("Beta")]);
616 Application::mount(tabs.clone());
617 assert_eq!(tabs.retained_node_ref().children().len(), 1);
618 let calls = ffi::test::take_calls();
619 assert!(calls.iter().any(|call| matches!(
620 call,
621 Call::SetSemanticRole { role_enum, .. } if *role_enum == SemanticRole::TabPanel as u32
622 )));
623 assert!(!calls.iter().any(|call| matches!(
624 call,
625 Call::SetSemanticRole { role_enum, .. }
626 if *role_enum == SemanticRole::TabList as u32 || *role_enum == SemanticRole::Tab as u32
627 )));
628 Application::unmount();
629 }
630
631 #[test]
632 fn disabled_removal_and_reentrant_selection_are_deterministic() {
633 let tabs = TabView::with_items([
634 TabItem::new("Alpha"),
635 TabItem::new("Beta"),
636 TabItem::new("Gamma"),
637 ]);
638 let callback_tabs = tabs.clone();
639 tabs.on_selection_changed(move |event| {
640 if event.selected_index == 1 {
641 callback_tabs.select_index(2);
642 }
643 });
644 tabs.select_index(1);
645 assert_eq!(tabs.selected_index(), 2);
646 tabs.remove_item_at(2);
647 assert_eq!(tabs.selected_index(), 1);
648 tabs.item_at(1).unwrap().enabled(false);
649 assert_eq!(tabs.selected_index(), 0);
650 }
651
652 #[test]
653 fn inline_composition_retains_owner_and_items_accept_fluent_values() {
654 let changes = Rc::new(Cell::new(0));
655 let first = TabItem::new("First");
656 let tabs = tab_view();
657 tabs.items(crate::tab_items![
658 &first,
659 TabItem::new("Second").content(|| retained_view(&crate::node::text("Second"))),
660 ])
661 .on_selection_changed({
662 let changes = changes.clone();
663 move |_| changes.set(changes.get() + 1)
664 });
665 let root = crate::ui! { column() { tabs } };
666 drop(tabs);
667 first.enabled(false);
668 assert_eq!(changes.get(), 1);
669 Application::mount(root);
670 Application::unmount();
671 }
672}