1use 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
10pub struct WidgetDock<Msg> {
26 model: Sections<Msg>,
27 empty: String,
28}
29
30impl<Msg: 'static> WidgetDock<Msg> {
31 #[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 #[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 #[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 #[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 #[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 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}