Skip to main content

frus_widgets/
twopane.rs

1//! [`TwoPane`]: the responsive **master-detail** pattern. In `Expanded`, the list
2//! and the detail sit **side by side**; otherwise a **single pane** is shown (the
3//! list, or the detail if `show_detail` — the app decides as it navigates).
4
5use frus_core::{Rect, Scene, SizeClass};
6use frus_layout::{FlexDirection, Style};
7
8use crate::flex::Flex;
9use crate::interaction::Status;
10use crate::theme::Theme;
11use crate::widget::Widget;
12
13/// An adaptive master-detail layout.
14pub struct TwoPane<Msg = crate::callback::Callback> {
15    class: SizeClass,
16    ratio: f32,
17    show_detail: bool,
18    list: Option<Box<dyn Widget<Msg>>>,
19    row: bool,
20    children: Vec<Box<dyn Widget<Msg>>>,
21}
22
23impl<Msg: Clone + 'static> TwoPane<Msg> {
24    /// Creates a layout for size class `class` (the list ratio defaults to 0.38).
25    pub fn new(class: SizeClass) -> Self {
26        Self {
27            class,
28            ratio: 0.38,
29            show_detail: false,
30            list: None,
31            row: false,
32            children: Vec::new(),
33        }
34    }
35
36    /// Fraction of the width given to the list in side-by-side mode (`0.1..=0.9`).
37    /// Set this **before** [`detail`](TwoPane::detail).
38    pub fn ratio(mut self, ratio: f32) -> Self {
39        self.ratio = ratio.clamp(0.1, 0.9);
40        self
41    }
42
43    /// In single-pane mode, shows the **detail** rather than the list (the app sets
44    /// this to `true` when it navigates to an item).
45    pub fn show_detail(mut self, show: bool) -> Self {
46        self.show_detail = show;
47        self
48    }
49
50    /// Sets the **list** (the master pane). Call before [`detail`](TwoPane::detail).
51    pub fn list(mut self, list: impl Widget<Msg> + 'static) -> Self {
52        self.list = Some(Box::new(list));
53        self
54    }
55
56    /// Sets the **detail** and **finalises** the layout (call this last).
57    pub fn detail(mut self, detail: impl Widget<Msg> + 'static) -> Self {
58        let detail: Box<dyn Widget<Msg>> = Box::new(detail);
59        if self.class == SizeClass::Expanded {
60            // Side by side: proportional widths via flex_grow.
61            let list = self
62                .list
63                .take()
64                .expect("list() before detail() in side-by-side mode");
65            self.children = vec![
66                Box::new(Flex::column().flex(self.ratio).child(list)),
67                Box::new(Flex::column().flex(1.0 - self.ratio).child(detail)),
68            ];
69            self.row = true;
70        } else {
71            // A single pane: the detail if asked for, otherwise the list.
72            let single = if self.show_detail {
73                detail
74            } else {
75                self.list
76                    .take()
77                    .expect("single-pane mode needs list() or show_detail")
78            };
79            self.children = vec![Box::new(Flex::column().flex(1.0).child(single))];
80            self.row = false;
81        }
82        self
83    }
84}
85
86impl<Msg: Clone> Widget<Msg> for TwoPane<Msg> {
87    /// It asks to fill **both** axes rather than declaring `100%` on either — see
88    /// [`FillAxes`](crate::widget::FillAxes). A percentage resolves against the parent's
89    /// *resolved* size, which a parent that shrink-wraps has not got yet, so a shell
90    /// nested in one vanished entirely (milestone 404).
91    fn style(&self) -> Style {
92        Style {
93            flex_direction: if self.row {
94                FlexDirection::Row
95            } else {
96                FlexDirection::Column
97            },
98            ..Default::default()
99        }
100    }
101
102    /// A shell takes everything it is offered, on both axes.
103    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
104        crate::widget::FillAxes::BOTH
105    }
106
107    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
108        &self.children
109    }
110
111    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
112
113    fn on_click(&self) -> Option<Msg> {
114        None
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::Container;
122
123    #[test]
124    fn expanded_shows_both_panes_side_by_side() {
125        let tp = TwoPane::<()>::new(SizeClass::Expanded)
126            .ratio(0.4)
127            .list(Container::new())
128            .detail(Container::new());
129        assert_eq!(Widget::<()>::style(&tp).flex_direction, FlexDirection::Row);
130        assert_eq!(Widget::<()>::children(&tp).len(), 2);
131        // ListView pane = flex 0.4, detail pane = flex 0.6.
132        let panes = Widget::<()>::children(&tp);
133        assert_eq!(Widget::<()>::style(&*panes[0]).flex_grow, 0.4);
134        assert!((Widget::<()>::style(&*panes[1]).flex_grow - 0.6).abs() < 1e-6);
135    }
136
137    #[test]
138    fn compact_shows_a_single_pane() {
139        let list_only = TwoPane::<()>::new(SizeClass::Compact)
140            .list(Container::new())
141            .detail(Container::new());
142        assert_eq!(
143            Widget::<()>::style(&list_only).flex_direction,
144            FlexDirection::Column
145        );
146        assert_eq!(Widget::<()>::children(&list_only).len(), 1);
147
148        // With show_detail, it is the detail that occupies the single pane.
149        let detail_shown = TwoPane::<()>::new(SizeClass::Compact)
150            .show_detail(true)
151            .list(Container::new())
152            .detail(Container::new());
153        assert_eq!(Widget::<()>::children(&detail_shown).len(), 1);
154    }
155}