Skip to main content

fui/controls/
tab_view.rs

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}