Skip to main content

qframe/widgets/
widget_dock.rs

1//! Widget docks: a stack of titled widgets that open, close and are reordered by dragging.
2
3use crate::event::Event;
4use crate::geometry::{Rect, Size};
5use crate::style::CellStyle;
6use crate::widget::{Container, EventCx, MeasureCx, Node, PaintCx, Widget};
7
8use super::sections::{Flow, Section, Sections};
9
10/// A vertical stack of titled widgets filling the area it is given, like the side panel of an
11/// IDE: each widget opens and closes, and open widgets share the height between them.
12///
13/// Add one child per widget with [`View::add_with`](crate::widget::View::add_with), in the
14/// order they are shown. A widget that wants less than its share keeps its natural height; a
15/// taller one, such as a [`List`](super::List), gets an even share of what is left and scrolls
16/// inside it. The application owns the order and the open state, so both can be saved and
17/// restored: [`WidgetDock::on_toggle`] and [`WidgetDock::on_move`] report changes.
18///
19/// With `on_move`, dragging a title picks the widget up: its title follows the pointer as a
20/// ghost and a tinted row shows where it will land. Keys while focused: ↑/↓, Home/End move
21/// between titles, Enter or Space opens or closes, Ctrl+Shift+↑/↓ moves the widget.
22///
23/// Style keys are those of [`Accordion`](super::Accordion) plus `section-title.ghost` for the
24/// dragged title, `section-drop` (`bg`) for the landing row and `section-empty` (`fg`).
25pub struct WidgetDock<Msg> {
26    model: Sections<Msg>,
27    empty: String,
28}
29
30impl<Msg: 'static> WidgetDock<Msg> {
31    /// A dock of `sections` in this order, all closed.
32    #[must_use]
33    pub fn new(sections: impl IntoIterator<Item = impl Into<Section>>) -> Self {
34        Self { model: Sections::new(sections.into_iter().map(Into::into).collect()), empty: String::new() }
35    }
36
37    /// Which widgets are open; `open[i]` for widget `i`, missing entries are closed.
38    #[must_use]
39    pub fn open(mut self, open: impl IntoIterator<Item = bool>) -> Self {
40        self.model.open = open.into_iter().collect();
41        self
42    }
43
44    /// Message for opening (`true`) or closing (`false`) widget `index`.
45    #[must_use]
46    pub fn on_toggle(mut self, message: impl Fn(usize, bool) -> Msg + 'static) -> Self {
47        self.model.on_toggle = Some(Box::new(message));
48        self
49    }
50
51    /// Lets the user reorder widgets. The message carries the widget's index and the index it
52    /// should have afterwards: remove it at `from`, then insert it at `to`.
53    #[must_use]
54    pub fn on_move(mut self, message: impl Fn(usize, usize) -> Msg + 'static) -> Self {
55        self.model.on_move = Some(Box::new(message));
56        self
57    }
58
59    /// Text shown when the dock has no widgets.
60    #[must_use]
61    pub fn empty_text(mut self, text: impl Into<String>) -> Self {
62        self.empty = text.into();
63        self
64    }
65}
66
67impl<Msg: 'static> Container<Msg> for WidgetDock<Msg> {
68    fn set_children(&mut self, children: Vec<Node<Msg>>) {
69        self.model.bodies = children;
70    }
71}
72
73impl<Msg: 'static> Widget<Msg> for WidgetDock<Msg> {
74    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
75        if self.model.sections.is_empty() {
76            return Size::new(crate::text::width(&self.empty).saturating_add(4), 1).min(available);
77        }
78        self.model.measure(cx, available)
79    }
80
81    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
82        if self.model.sections.is_empty() {
83            let style = cx.style("section-empty", None, &[]).text();
84            cx.text(area.x + 2, area.y, &self.empty, CellStyle { bg: None, ..style }, area.width.saturating_sub(2));
85            return;
86        }
87        self.model.paint(cx, area, Flow::Share);
88    }
89
90    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
91        self.model.event(cx, event)
92    }
93
94    fn focusable(&self) -> bool {
95        !self.model.sections.is_empty()
96    }
97
98    fn children(&self) -> &[Node<Msg>] {
99        &self.model.bodies
100    }
101
102    fn children_mut(&mut self) -> &mut [Node<Msg>] {
103        &mut self.model.bodies
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::event::{MouseButton, MouseKind};
111    use crate::runtime::{App, Command, Harness};
112    use crate::widget::View;
113    use crate::widgets::{List, ListItem, Text};
114
115    struct Demo {
116        order: Vec<&'static str>,
117        open: Vec<bool>,
118    }
119
120    #[derive(Clone)]
121    enum Msg {
122        Toggle(usize, bool),
123        Move(usize, usize),
124    }
125
126    impl App for Demo {
127        type Msg = Msg;
128        fn update(&mut self, msg: Msg) -> Command<Msg> {
129            match msg {
130                Msg::Toggle(index, open) => self.open[index] = open,
131                Msg::Move(from, to) => {
132                    let name = self.order.remove(from);
133                    let open = self.open.remove(from);
134                    self.order.insert(to, name);
135                    self.open.insert(to, open);
136                }
137            }
138            Command::none()
139        }
140        fn view(&self, ui: &mut View<'_, Msg>) {
141            let dock = WidgetDock::new(self.order.clone())
142                .open(self.open.clone())
143                .on_toggle(Msg::Toggle)
144                .on_move(Msg::Move)
145                .empty_text("No widgets");
146            ui.add_with(dock, |ui| {
147                for name in &self.order {
148                    match *name {
149                        "Files" => {
150                            let rows = (1..=30).map(|n| ListItem::new(format!("file {n}")));
151                            ui.add(List::new(rows)).fill().id("files");
152                        }
153                        other => {
154                            ui.add(Text::new(format!("{other} body"))).id(other);
155                        }
156                    }
157                }
158            })
159            .fill()
160            .id("dock");
161        }
162    }
163
164    fn demo(open: [bool; 3]) -> Demo {
165        Demo { order: vec!["Git", "Files", "Ports"], open: open.to_vec() }
166    }
167
168    #[test]
169    fn open_widgets_share_the_height() {
170        let mut h = Harness::new(demo([true, true, true]), 24, 16);
171        h.set_reduced_motion(true);
172        let screen = h.screen();
173        let rows: Vec<&str> = screen.lines().collect();
174        assert_eq!(rows[0], "  ▾ Git");
175        assert_eq!(rows[2], "  Git body");
176        assert!(rows.iter().any(|row| row.starts_with("  ▾ Files")));
177        assert!(rows.contains(&"  Ports body"), "{screen}");
178        assert_eq!(rows.len(), 16, "{screen}");
179        assert!(screen.contains("file 1") && !screen.contains("file 30"), "{screen}");
180    }
181
182    #[test]
183    fn keyboard_reorders_and_toggles() {
184        let mut h = Harness::new(demo([false; 3]), 24, 10);
185        h.set_reduced_motion(true);
186        h.press("tab").press("ctrl+shift+down");
187        assert_eq!(h.app().order, vec!["Files", "Git", "Ports"]);
188        h.press("enter");
189        assert_eq!(h.app().open, vec![false, true, false]);
190        h.press("ctrl+shift+up");
191        assert_eq!(h.app().order, vec!["Git", "Files", "Ports"]);
192        assert_eq!(h.app().open, vec![true, false, false]);
193    }
194
195    #[test]
196    fn dragging_a_title_shows_a_ghost_and_drops_it_between_others() {
197        let mut h = Harness::new(demo([false; 3]), 24, 10);
198        h.set_reduced_motion(true);
199        let theme = h.env().theme();
200        let drop = theme.style("section-drop", None, &[]).paint("bg").map(|paint| paint.at(0.0));
201        h.mouse(MouseKind::Down(MouseButton::Left), 5, 0);
202        h.mouse(MouseKind::Drag(MouseButton::Left), 5, 3);
203        let screen = h.screen();
204        assert!(screen.contains("Git"), "the ghost title follows the pointer:\n{screen}");
205        assert!((0..10).any(|y| h.bg(20, y) == drop), "a tinted landing row:\n{screen}");
206        h.mouse(MouseKind::Drag(MouseButton::Left), 5, 5);
207        h.mouse(MouseKind::Up(MouseButton::Left), 5, 5);
208        assert_eq!(h.app().order, vec!["Files", "Ports", "Git"]);
209        assert_eq!(h.app().open, vec![false; 3], "a drag never toggles");
210    }
211
212    #[test]
213    fn click_without_moving_toggles() {
214        let mut h = Harness::new(demo([false; 3]), 24, 10);
215        h.set_reduced_motion(true);
216        h.click_text("Ports");
217        assert_eq!(h.app().open, vec![false, false, true]);
218        assert_eq!(h.app().order, vec!["Git", "Files", "Ports"]);
219    }
220
221    /// A dock whose height the application sets, to shrink it while a title is dragged.
222    struct Shrinking {
223        rows: u16,
224    }
225
226    impl App for Shrinking {
227        type Msg = u16;
228        fn update(&mut self, rows: u16) -> Command<u16> {
229            self.rows = rows;
230            Command::none()
231        }
232        fn view(&self, ui: &mut View<'_, u16>) {
233            let dock = WidgetDock::new(["Git", "Ports"]).on_toggle(|_, _| 0).on_move(|_, _| 0);
234            ui.add_with(dock, |ui| {
235                ui.add(Text::new("Git body"));
236                ui.add(Text::new("Ports body"));
237            })
238            .fill_width()
239            .height(crate::widget::Length::Cells(self.rows));
240        }
241    }
242
243    #[test]
244    fn a_drag_survives_the_dock_losing_its_height() {
245        let mut h = Harness::new(Shrinking { rows: 6 }, 24, 6);
246        h.mouse(MouseKind::Down(MouseButton::Left), 5, 0);
247        h.mouse(MouseKind::Drag(MouseButton::Left), 5, 3);
248        h.send(0);
249        h.mouse(MouseKind::Drag(MouseButton::Left), 5, 4);
250        h.mouse(MouseKind::Up(MouseButton::Left), 5, 4);
251        assert_eq!(h.screen(), "\n\n\n\n\n\n", "a dock with no rows paints nothing");
252    }
253
254    #[test]
255    fn empty_dock_says_so() {
256        let mut app = demo([false; 3]);
257        app.order.clear();
258        app.open.clear();
259        assert_eq!(Harness::new(app, 24, 3).screen(), "  No widgets\n\n\n");
260    }
261}