Skip to main content

qframe/widgets/
splitter.rs

1//! Splitters: two panes side by side or stacked, divided by a boundary that is never a line.
2
3use crate::event::Event;
4use crate::geometry::{Rect, Size};
5use crate::widget::{Axis as FlexAxis, EventCx, Flex, Length, MeasureCx, Node, NodeMut, PaintCx, View, Widget};
6
7use super::boundary::{self, Axis, Change};
8
9type Pane<'a, Msg> = Box<dyn FnOnce(&mut View<'_, Msg>) + 'a>;
10
11/// Builds a message from a new size in cells.
12type SizeMessage<Msg> = Box<dyn Fn(u16) -> Msg>;
13
14/// Two panes that share an area: side by side with [`Splitter::columns`], stacked with
15/// [`Splitter::rows`]. The application owns the size of the first pane; the second takes the
16/// rest.
17///
18/// Without [`Splitter::on_resize`] the panes simply sit next to each other. With it, a one-cell
19/// boundary separates them. The boundary is invisible until the pointer reaches it: then it
20/// brightens one step, and while dragged it takes the accent. It can also be focused with Tab
21/// and moved with the arrow keys (Shift moves five cells, Home and End jump to the limits).
22///
23/// Style keys: `split-handle` (`bg`, `fg`) with `hover`, `focus` and `active` (dragging).
24pub struct Splitter<'a, Msg> {
25    axis: Axis,
26    size: u16,
27    min: u16,
28    max: Option<u16>,
29    on_resize: Option<SizeMessage<Msg>>,
30    first: Option<Pane<'a, Msg>>,
31    second: Option<Pane<'a, Msg>>,
32}
33
34impl<'a, Msg: 'static> Splitter<'a, Msg> {
35    fn new(axis: Axis, size: u16) -> Self {
36        Self { axis, size, min: 1, max: None, on_resize: None, first: None, second: None }
37    }
38
39    /// Panes side by side; the first (left) pane is `width` columns wide.
40    #[must_use]
41    pub fn columns(width: u16) -> Self {
42        Self::new(Axis::Columns, width)
43    }
44
45    /// Stacked panes; the first (top) pane is `height` rows tall.
46    #[must_use]
47    pub fn rows(height: u16) -> Self {
48        Self::new(Axis::Rows, height)
49    }
50
51    /// The smallest and largest size of the first pane, in cells: `limits(16, 60)` for a range, or
52    /// `limits(16, None)` for no upper limit. By default 1 and no upper limit; whatever the limits,
53    /// the second pane keeps at least one cell. A `max` below `min` counts as `min`.
54    #[must_use]
55    pub fn limits(mut self, min: u16, max: impl Into<Option<u16>>) -> Self {
56        self.min = min;
57        self.max = max.into();
58        self
59    }
60
61    /// Makes the boundary draggable and focusable; the message carries the first pane's new
62    /// size, already within the limits.
63    #[must_use]
64    pub fn on_resize(mut self, message: impl Fn(u16) -> Msg + 'static) -> Self {
65        self.on_resize = Some(Box::new(message));
66        self
67    }
68
69    /// The left or top pane.
70    #[must_use]
71    pub fn first(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
72        self.first = Some(Box::new(build));
73        self
74    }
75
76    /// The right or bottom pane.
77    #[must_use]
78    pub fn second(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
79        self.second = Some(Box::new(build));
80        self
81    }
82
83    /// Adds the splitter to `ui`, filling the space it gets.
84    pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
85        // Each pane takes its own place, so the two panes and their widgets never share an identity.
86        let build = |pane: Option<Pane<'a, Msg>>, place: usize| {
87            let mut children = Vec::new();
88            if let Some(pane) = pane {
89                pane(&mut ui.nested(&mut children));
90            }
91            let mut node = Node::new(Flex::new(FlexAxis::Column, children), place);
92            node.layout.width = Length::Fill(1);
93            node.layout.height = Length::Fill(1);
94            node
95        };
96        let split = Split {
97            axis: self.axis,
98            size: self.size,
99            min: self.min,
100            max: self.max,
101            on_resize: self.on_resize,
102            panes: vec![build(self.first, 0), build(self.second, 1)],
103        };
104        ui.add(split).fill()
105    }
106}
107
108struct Split<Msg> {
109    axis: Axis,
110    size: u16,
111    min: u16,
112    /// The largest first pane the application allows; `None` for no limit.
113    max: Option<u16>,
114    on_resize: Option<SizeMessage<Msg>>,
115    panes: Vec<Node<Msg>>,
116}
117
118impl<Msg: 'static> Split<Msg> {
119    fn extent(&self, area: Rect) -> u16 {
120        match self.axis {
121            Axis::Columns => area.width,
122            Axis::Rows => area.height,
123        }
124    }
125
126    /// The largest first-pane size within the limits that leaves the handle and one cell of the
127    /// second pane.
128    fn max_size(&self, area: Rect) -> u16 {
129        let handle = u16::from(self.on_resize.is_some());
130        let room = self.extent(area).saturating_sub(handle + 1);
131        self.max.map_or(room, |max| max.min(room))
132    }
133
134    fn first_size(&self, area: Rect) -> u16 {
135        boundary::clamp_size(i32::from(self.size), self.min, self.max_size(area)).min(self.extent(area))
136    }
137
138    fn resize(&self, cx: &mut EventCx<'_, Msg>, target: i32) {
139        let area = cx.area();
140        let size = boundary::clamp_size(target, self.min, self.max_size(area));
141        if let Some(message) = &self.on_resize
142            && size != self.first_size(area)
143        {
144            cx.emit(message(size));
145        }
146    }
147}
148
149impl<Msg: 'static> Widget<Msg> for Split<Msg> {
150    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
151        available
152    }
153
154    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
155        let first = self.first_size(area);
156        let handle = u16::from(self.on_resize.is_some());
157        let rest = self.extent(area).saturating_sub(first + handle);
158        let offset = |cells: u16| i32::from(cells);
159        let (first_rect, handle_rect, second_rect) = match self.axis {
160            Axis::Columns => (
161                Rect::new(area.x, area.y, first, area.height),
162                Rect::new(area.x + offset(first), area.y, handle, area.height),
163                Rect::new(area.x + offset(first + handle), area.y, rest, area.height),
164            ),
165            Axis::Rows => (
166                Rect::new(area.x, area.y, area.width, first),
167                Rect::new(area.x, area.y + offset(first), area.width, handle),
168                Rect::new(area.x, area.y + offset(first + handle), area.width, rest),
169            ),
170        };
171        cx.paint_child(&self.panes[0], first_rect);
172        cx.paint_child(&self.panes[1], second_rect);
173        if self.on_resize.is_some() {
174            boundary::paint(cx, handle_rect, None);
175        }
176    }
177
178    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
179        if self.on_resize.is_none() {
180            return false;
181        }
182        let area = cx.area();
183        let current = i32::from(self.first_size(area));
184        match boundary::event(cx, event, self.axis) {
185            Change::Ignored => false,
186            Change::Used | Change::Activate => true,
187            Change::DragTo(at) => {
188                let start = match self.axis {
189                    Axis::Columns => area.x,
190                    Axis::Rows => area.y,
191                };
192                self.resize(cx, at - start);
193                true
194            }
195            Change::Nudge(cells) => {
196                self.resize(cx, current + cells);
197                true
198            }
199            Change::Jump(end) => {
200                self.resize(cx, if end { i32::MAX } else { 0 });
201                true
202            }
203        }
204    }
205
206    fn focusable(&self) -> bool {
207        self.on_resize.is_some()
208    }
209
210    fn children(&self) -> &[Node<Msg>] {
211        &self.panes
212    }
213
214    fn children_mut(&mut self) -> &mut [Node<Msg>] {
215        &mut self.panes
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::event::{MouseButton, MouseKind};
223    use crate::runtime::{App, Command, Harness};
224    use crate::theme::State;
225    use crate::widgets::Text;
226
227    struct Demo {
228        size: u16,
229        rows: bool,
230        resizable: bool,
231        max: Option<u16>,
232    }
233
234    impl App for Demo {
235        type Msg = u16;
236        fn update(&mut self, size: u16) -> Command<u16> {
237            self.size = size;
238            Command::none()
239        }
240        fn view(&self, ui: &mut View<'_, u16>) {
241            let split = if self.rows { Splitter::rows(self.size) } else { Splitter::columns(self.size) };
242            let split = if self.resizable { split.on_resize(|size| size) } else { split };
243            split
244                .limits(3, self.max)
245                .first(|ui| {
246                    ui.add(Text::new("left"));
247                })
248                .second(|ui| {
249                    ui.add(Text::new("right"));
250                })
251                .show(ui)
252                .id("split");
253        }
254    }
255
256    #[test]
257    fn plain_panes_sit_side_by_side_without_a_boundary() {
258        let h = Harness::new(Demo { size: 6, rows: false, resizable: false, max: Some(12) }, 20, 2);
259        assert_eq!(h.screen(), "left  right\n\n");
260    }
261
262    #[test]
263    fn boundary_is_invisible_until_hovered_and_takes_the_accent_while_dragged() {
264        let mut h = Harness::new(Demo { size: 6, rows: false, resizable: true, max: Some(12) }, 20, 3);
265        assert_eq!(h.screen(), "left   right\n\n\n");
266        let theme = h.env().theme();
267        let canvas = theme.color("canvas");
268        let hover = theme.style("split-handle", None, &[State::Hover]).paint("bg").map(|p| p.at(0.0));
269        let active = theme.style("split-handle", None, &[State::Active]).paint("bg").map(|p| p.at(0.0));
270        assert_eq!(h.bg(6, 1), canvas);
271        h.hover(6, 1);
272        assert_eq!(h.bg(6, 1), hover);
273        assert_ne!(hover, canvas);
274        h.mouse(MouseKind::Down(MouseButton::Left), 6, 1);
275        h.mouse(MouseKind::Drag(MouseButton::Left), 9, 1);
276        assert_eq!(h.app().size, 9);
277        assert_eq!(h.bg(9, 0), active);
278        h.mouse(MouseKind::Drag(MouseButton::Left), 30, 1);
279        assert_eq!(h.app().size, 12, "limited by max");
280        h.mouse(MouseKind::Up(MouseButton::Left), 30, 1);
281        assert_eq!(h.screen(), "left         right\n\n\n");
282    }
283
284    #[test]
285    fn keyboard_moves_the_focused_boundary() {
286        let mut h = Harness::new(Demo { size: 6, rows: true, resizable: true, max: Some(12) }, 20, 20);
287        h.press("tab");
288        assert!(h.is_focused("split"));
289        h.press("down");
290        assert_eq!(h.app().size, 7);
291        h.press("shift+up");
292        assert_eq!(h.app().size, 3, "limited by min");
293        h.press("end");
294        assert_eq!(h.app().size, 12);
295        assert_eq!(h.screen().lines().nth(13), Some("right"));
296    }
297
298    #[test]
299    fn without_an_upper_limit_only_the_second_pane_stops_the_boundary() {
300        let mut h = Harness::new(Demo { size: 6, rows: false, resizable: true, max: None }, 20, 3);
301        h.mouse(MouseKind::Down(MouseButton::Left), 6, 1);
302        h.mouse(MouseKind::Drag(MouseButton::Left), 30, 1);
303        assert_eq!(h.app().size, 18, "20 columns: the handle and one cell of the second pane stay");
304        h.mouse(MouseKind::Drag(MouseButton::Left), 0, 1);
305        assert_eq!(h.app().size, 3, "the lower limit still holds");
306        h.mouse(MouseKind::Up(MouseButton::Left), 0, 1);
307        h.press("end");
308        assert_eq!(h.app().size, 18);
309        assert_eq!(h.screen(), "left               r\n                   i\n                   g\n");
310        h.press("home");
311        assert_eq!(h.app().size, 3);
312    }
313
314    #[test]
315    fn a_max_below_min_counts_as_min() {
316        let mut h = Harness::new(Demo { size: 6, rows: false, resizable: true, max: Some(2) }, 20, 1);
317        assert_eq!(h.screen(), "lef right\n", "the first pane is held at the minimum of 3");
318        h.press("tab").press("right");
319        assert_eq!(h.app().size, 6, "already at the only allowed size: nothing to send");
320    }
321
322    #[test]
323    fn narrow_area_keeps_the_second_pane() {
324        let h = Harness::new(Demo { size: 12, rows: false, resizable: true, max: Some(12) }, 8, 1);
325        assert_eq!(h.screen(), "left   r\n");
326    }
327
328    /// Two panes whose first widgets are both rows, the second pane claiming the copy key.
329    #[derive(Default)]
330    struct TwoPanes {
331        copied: u32,
332    }
333
334    impl App for TwoPanes {
335        type Msg = bool;
336        fn update(&mut self, copied: bool) -> Command<bool> {
337            self.copied += u32::from(copied);
338            Command::none()
339        }
340        fn view(&self, ui: &mut View<'_, bool>) {
341            Splitter::columns(20)
342                .first(|ui| {
343                    ui.row(|ui| {
344                        ui.add(Text::new("places"));
345                    });
346                })
347                .second(|ui| {
348                    ui.row(|ui| {
349                        ui.add(crate::widgets::Button::new("notes.txt").on_press(false));
350                    })
351                    .on_clipboard(crate::widget::ClipboardKey::Copy, true);
352                })
353                .show(ui);
354        }
355    }
356
357    #[test]
358    fn a_key_the_second_pane_claims_reaches_it_and_not_the_first() {
359        let mut h = Harness::new(TwoPanes::default(), 60, 6);
360        h.click_text("notes.txt");
361        h.press("ctrl+c");
362        assert_eq!(h.app().copied, 1, "the second pane's claim answered");
363    }
364}