Skip to main content

qframe/widgets/
app_shell.rs

1//! The application frame: header, sidebar, body and footer.
2
3use crate::geometry::{Rect, Size};
4use crate::widget::{Axis, Flex, Length, MeasureCx, Node, NodeMut, PaintCx, View, Widget};
5
6type Part<'a, Msg> = Box<dyn FnOnce(&mut View<'_, Msg>) + 'a>;
7
8/// Builds the common application frame: a header on top, a sidebar on the left, the body
9/// beside it and a footer at the bottom.
10///
11/// The parts are separated by surface colour only. Below `collapse_below` columns the sidebar
12/// is hidden to give the body room; while `sidebar_open` is true it is drawn over the body as a
13/// layer instead. Style keys: `shell-header`, `shell-sidebar`, `shell-body`, `shell-footer`
14/// (`bg`).
15pub struct AppShell<'a, Msg> {
16    header: Option<Part<'a, Msg>>,
17    sidebar: Option<Part<'a, Msg>>,
18    body: Option<Part<'a, Msg>>,
19    footer: Option<Part<'a, Msg>>,
20    sidebar_width: u16,
21    collapse_below: u16,
22    sidebar_open: bool,
23}
24
25impl<'a, Msg: 'static> AppShell<'a, Msg> {
26    /// A shell with a 28-column sidebar that collapses below 90 columns.
27    #[must_use]
28    pub fn new() -> Self {
29        Self {
30            header: None,
31            sidebar: None,
32            body: None,
33            footer: None,
34            sidebar_width: 28,
35            collapse_below: 90,
36            sidebar_open: false,
37        }
38    }
39
40    /// The top bar.
41    #[must_use]
42    pub fn header(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
43        self.header = Some(Box::new(build));
44        self
45    }
46
47    /// The navigation column.
48    #[must_use]
49    pub fn sidebar(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
50        self.sidebar = Some(Box::new(build));
51        self
52    }
53
54    /// The main content.
55    #[must_use]
56    pub fn body(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
57        self.body = Some(Box::new(build));
58        self
59    }
60
61    /// The bottom bar, usually [`KeyHints`](crate::widgets::KeyHints).
62    #[must_use]
63    pub fn footer(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
64        self.footer = Some(Box::new(build));
65        self
66    }
67
68    /// Sidebar width in columns.
69    #[must_use]
70    pub fn sidebar_width(mut self, columns: u16) -> Self {
71        self.sidebar_width = columns;
72        self
73    }
74
75    /// Screen width under which the sidebar collapses.
76    #[must_use]
77    pub fn collapse_below(mut self, columns: u16) -> Self {
78        self.collapse_below = columns;
79        self
80    }
81
82    /// Shows the collapsed sidebar as a layer over the body.
83    #[must_use]
84    pub fn sidebar_open(mut self, open: bool) -> Self {
85        self.sidebar_open = open;
86        self
87    }
88
89    /// Adds the shell to `ui`, filling the space it gets.
90    pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
91        // Each part takes its own place among the shell's children: with one place for all four,
92        // the four parts, and the first widget of each, had one identity, and a key or a stored
93        // state meant for the body reached the header instead.
94        let build = |part: Option<Part<'a, Msg>>, place: usize| {
95            let mut children = Vec::new();
96            if let Some(part) = part {
97                part(&mut ui.nested(&mut children));
98            }
99            let mut node = Node::new(Flex::new(Axis::Column, children), place);
100            node.layout.width = Length::Fill(1);
101            node.layout.height = Length::Fill(1);
102            node
103        };
104        let shell = Shell {
105            parts: vec![build(self.header, 0), build(self.sidebar, 1), build(self.body, 2), build(self.footer, 3)],
106            sidebar_width: self.sidebar_width,
107            collapse_below: self.collapse_below,
108            sidebar_open: self.sidebar_open,
109        };
110        ui.add(shell).fill()
111    }
112}
113
114impl<Msg: 'static> Default for AppShell<'_, Msg> {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120const HEADER: usize = 0;
121const SIDEBAR: usize = 1;
122const BODY: usize = 2;
123const FOOTER: usize = 3;
124
125struct Shell<Msg> {
126    parts: Vec<Node<Msg>>,
127    sidebar_width: u16,
128    collapse_below: u16,
129    sidebar_open: bool,
130}
131
132impl<Msg: 'static> Shell<Msg> {
133    fn part_height(&self, cx: &mut PaintCx<'_>, index: usize, area: Rect) -> u16 {
134        if self.parts[index].widget.children().is_empty() {
135            return 0;
136        }
137        cx.measure_child(&self.parts[index], area.size()).height
138    }
139
140    fn collapsed(&self, area: Rect) -> bool {
141        area.width < self.collapse_below
142    }
143
144    /// The heights of the header and the footer in `area`.
145    fn bars(&self, cx: &mut PaintCx<'_>, area: Rect) -> (u16, u16) {
146        (self.part_height(cx, HEADER, area), self.part_height(cx, FOOTER, area))
147    }
148
149    /// The row band between a header `header` rows tall and a footer `footer` rows tall.
150    fn middle(area: Rect, (header, footer): (u16, u16)) -> Rect {
151        Rect::new(
152            area.x,
153            area.y + i32::from(header),
154            area.width,
155            area.height.saturating_sub(header.saturating_add(footer)),
156        )
157    }
158
159    fn has_sidebar(&self) -> bool {
160        !self.parts[SIDEBAR].widget.children().is_empty()
161    }
162}
163
164impl<Msg: 'static> Widget<Msg> for Shell<Msg> {
165    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
166        available
167    }
168
169    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
170        let (header, footer) = self.bars(cx, area);
171        let middle = Self::middle(area, (header, footer));
172        if header > 0 {
173            let rect = Rect::new(area.x, area.y, area.width, header);
174            paint_part(cx, &self.parts[HEADER], rect, "shell-header");
175        }
176        let sidebar = self.has_sidebar() && !self.collapsed(area);
177        let sidebar_width = if sidebar { self.sidebar_width.min(area.width) } else { 0 };
178        if sidebar {
179            let rect = Rect::new(middle.x, middle.y, sidebar_width, middle.height);
180            paint_part(cx, &self.parts[SIDEBAR], rect, "shell-sidebar");
181        }
182        let body = Rect::new(
183            middle.x + i32::from(sidebar_width),
184            middle.y,
185            middle.width.saturating_sub(sidebar_width),
186            middle.height,
187        );
188        paint_part(cx, &self.parts[BODY], body, "shell-body");
189        if footer > 0 {
190            let rect = Rect::new(area.x, area.bottom() - i32::from(footer), area.width, footer);
191            paint_part(cx, &self.parts[FOOTER], rect, "shell-footer");
192        }
193        if self.has_sidebar() && self.collapsed(area) && self.sidebar_open {
194            cx.request_overlay(area);
195        }
196    }
197
198    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
199        let middle = Self::middle(anchor, self.bars(cx, anchor));
200        let width = self.sidebar_width.min(middle.width);
201        let rect = Rect::new(middle.x, middle.y, width, middle.height);
202        cx.register_hit(rect);
203        // Drawn over the body, the sidebar floats: it keeps apart from the body and the bars.
204        cx.floating(rect, |cx| paint_part(cx, &self.parts[SIDEBAR], rect, "shell-sidebar"));
205    }
206
207    fn children(&self) -> &[Node<Msg>] {
208        &self.parts
209    }
210
211    fn children_mut(&mut self) -> &mut [Node<Msg>] {
212        &mut self.parts
213    }
214}
215
216fn paint_part<Msg: 'static>(cx: &mut PaintCx<'_>, node: &Node<Msg>, rect: Rect, style: &str) {
217    let background = cx.style(style, None, &[]).text().bg;
218    if let Some(bg) = background {
219        cx.clear(rect, bg);
220    }
221    cx.paint_child(node, rect);
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::runtime::{App, Command, Harness};
228    use crate::widgets::Text;
229
230    struct Demo {
231        open: bool,
232    }
233
234    impl App for Demo {
235        type Msg = ();
236        fn update(&mut self, _: ()) -> Command<()> {
237            Command::none()
238        }
239        fn view(&self, ui: &mut View<'_, ()>) {
240            AppShell::new()
241                .sidebar_width(8)
242                .collapse_below(30)
243                .sidebar_open(self.open)
244                .header(|ui| {
245                    ui.add(Text::new("head"));
246                })
247                .sidebar(|ui| {
248                    ui.add(Text::new("menu"));
249                })
250                .body(|ui| {
251                    ui.add(Text::new("body"));
252                })
253                .footer(|ui| {
254                    ui.add(Text::new("foot"));
255                })
256                .show(ui);
257        }
258    }
259
260    #[test]
261    fn lays_out_parts_on_their_surfaces() {
262        let h = Harness::new(Demo { open: false }, 30, 4);
263        assert_eq!(h.screen(), "head\nmenu    body\n\nfoot\n");
264        let theme = h.env().theme();
265        assert_eq!(h.bg(0, 1), theme.color("surface"));
266        assert_eq!(h.bg(10, 1), theme.color("canvas"));
267    }
268
269    #[test]
270    fn collapses_sidebar_and_opens_it_as_a_layer() {
271        let closed = Harness::new(Demo { open: false }, 20, 4);
272        assert_eq!(closed.screen(), "head\nbody\n\nfoot\n");
273        let open = Harness::new(Demo { open: true }, 20, 4);
274        assert_eq!(open.screen(), "head\nmenu\n\nfoot\n");
275    }
276
277    /// A shell whose header holds a row and whose body claims the copy key, as a file explorer's
278    /// does with its file list.
279    #[derive(Default)]
280    struct Explorer {
281        copied: u32,
282        pressed: u32,
283    }
284
285    #[derive(Clone)]
286    enum Go {
287        Copied,
288        Pressed,
289    }
290
291    impl App for Explorer {
292        type Msg = Go;
293        fn update(&mut self, go: Go) -> Command<Go> {
294            match go {
295                Go::Copied => self.copied += 1,
296                Go::Pressed => self.pressed += 1,
297            }
298            Command::none()
299        }
300        fn view(&self, ui: &mut View<'_, Go>) {
301            AppShell::new()
302                .header(|ui| {
303                    ui.row(|ui| {
304                        ui.add(Text::new("~/Documents"));
305                    });
306                })
307                .body(|ui| {
308                    ui.column(|ui| {
309                        ui.add(crate::widgets::Button::new("notes.txt").on_press(Go::Pressed));
310                    })
311                    .on_clipboard(crate::widget::ClipboardKey::Copy, Go::Copied);
312                })
313                .footer(|ui| {
314                    ui.row(|ui| {
315                        ui.add(Text::new("1 item"));
316                    });
317                })
318                .show(ui);
319        }
320    }
321
322    #[test]
323    fn a_key_the_body_claims_reaches_it_while_the_header_holds_a_row() {
324        let mut h = Harness::new(Explorer::default(), 60, 10);
325        h.click_text("notes.txt");
326        assert_eq!(h.app().pressed, 1, "the body's button has the keyboard");
327        h.press("ctrl+c");
328        assert_eq!(h.app().copied, 1, "the body's claim on ctrl+c answered, not the header's row");
329    }
330}