1use 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
8pub 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 #[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 #[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 #[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 #[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 #[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 #[must_use]
70 pub fn sidebar_width(mut self, columns: u16) -> Self {
71 self.sidebar_width = columns;
72 self
73 }
74
75 #[must_use]
77 pub fn collapse_below(mut self, columns: u16) -> Self {
78 self.collapse_below = columns;
79 self
80 }
81
82 #[must_use]
84 pub fn sidebar_open(mut self, open: bool) -> Self {
85 self.sidebar_open = open;
86 self
87 }
88
89 pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
91 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 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 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 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 #[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}