Skip to main content

iced_cube/layout/
split_pane.rs

1//! Two views, a start and an end, with one handle between them.
2//!
3//! A split pane is a [resizable panel](mod@crate::layout::resizable_panel)
4//! group of exactly two panels, described by one ratio: the start view's
5//! share of the space. [`State`] wraps the group's state and speaks in that
6//! ratio, and the view is the same widget, so dragging, keyboard resizing,
7//! collapsing and stacking on narrow screens all behave identically. The
8//! events, outputs, [`Action`]s and [`default_keymap`] are the resizable
9//! panel's.
10
11use iced::widget;
12use iced::{Element, Length};
13
14use crate::keys::Keymap;
15use crate::layout::resizable_panel::{self, Panel, panel};
16
17pub use crate::layout::resizable_panel::{Action, Axis, Event, Extent, Output, default_keymap};
18
19/// The start view's share of the space and the two panels' limits.
20#[derive(Debug, Clone, PartialEq)]
21pub struct State {
22    group: resizable_panel::State,
23}
24
25impl State {
26    /// A split with the start view taking `ratio` of the space, from 0.0
27    /// to 1.0. Out of range ratios are clamped.
28    pub fn new(ratio: f32) -> Self {
29        let ratio = clamp_ratio(ratio);
30        Self::with_panels(
31            panel(Extent::Fraction(ratio)),
32            panel(Extent::Fraction(1.0 - ratio)),
33        )
34    }
35
36    /// A split from two panels, for limits or collapsing beyond what
37    /// [`State::min`] and [`State::collapsible`] set.
38    pub fn with_panels(start: Panel, end: Panel) -> Self {
39        Self {
40            group: resizable_panel::State::new([start, end]),
41        }
42    }
43
44    /// The smallest each view may get.
45    pub fn min(self, start: Extent, end: Extent) -> Self {
46        self.map_panels(|[first, second]| [first.min(start), second.min(end)])
47    }
48
49    /// The largest each view may get.
50    pub fn max(self, start: Extent, end: Extent) -> Self {
51        self.map_panels(|[first, second]| [first.max(start), second.max(end)])
52    }
53
54    /// Lets the start view, the end view or both snap shut when dragged
55    /// below their threshold.
56    pub fn collapsible(self, start: bool, end: bool) -> Self {
57        self.map_panels(|[first, second]| [first.collapsible(start), second.collapsible(end)])
58    }
59
60    fn map_panels(self, change: impl FnOnce([Panel; 2]) -> [Panel; 2]) -> Self {
61        let panels = self.group.panels();
62        let (Some(first), Some(second)) = (panels.first(), panels.get(1)) else {
63            return self;
64        };
65        let ratio = self.ratio();
66        let [start, end] = change([*first, *second]);
67        let mut state = Self::with_panels(start, end);
68        if !self.group.sizes().is_empty() {
69            state.group.set_sizes([ratio, 1.0 - ratio]);
70        }
71        state
72    }
73
74    /// Starts from a saved ratio, as returned by [`State::ratio`].
75    pub fn with_ratio(mut self, ratio: f32) -> Self {
76        self.set_ratio(ratio);
77        self
78    }
79
80    /// Moves the handle so the start view takes `ratio` of the space,
81    /// within both views' limits once the split has been measured.
82    pub fn set_ratio(&mut self, ratio: f32) {
83        let ratio = clamp_ratio(ratio);
84        self.group.set_sizes([ratio, 1.0 - ratio]);
85    }
86
87    /// The start view's share of the space, from 0.0 to 1.0.
88    pub fn ratio(&self) -> f32 {
89        ratio_of(&self.group.sizes())
90    }
91
92    pub fn is_start_collapsed(&self) -> bool {
93        self.group.is_collapsed(0)
94    }
95
96    pub fn is_end_collapsed(&self) -> bool {
97        self.group.is_collapsed(1)
98    }
99
100    /// The panel group underneath, for anything the ratio does not cover.
101    pub fn group(&self) -> &resizable_panel::State {
102        &self.group
103    }
104
105    /// Applies an event from the view or a keyboard action.
106    pub fn update(&mut self, event: Event) -> Option<Output> {
107        self.group.update(event)
108    }
109}
110
111impl Default for State {
112    /// An even split.
113    fn default() -> Self {
114        Self::new(0.5)
115    }
116}
117
118fn clamp_ratio(ratio: f32) -> f32 {
119    if ratio.is_nan() {
120        return 0.5;
121    }
122    ratio.clamp(0.0, 1.0)
123}
124
125/// The start panel's share of `sizes`, or an even split when there is
126/// nothing to go on.
127pub fn ratio_of(sizes: &[f32]) -> f32 {
128    let total: f32 = sizes.iter().sum();
129    match sizes.first() {
130        Some(first) if total > 0.0 => clamp_ratio(first / total),
131        _ => 0.5,
132    }
133}
134
135/// A split pane builder. Convert it into an [`Element`] to render.
136pub struct SplitPane<'a, Message> {
137    state: &'a State,
138    start: Element<'a, Message>,
139    end: Element<'a, Message>,
140    axis: Axis,
141    stack_below: Option<f32>,
142    grip: bool,
143    width: Length,
144    height: Length,
145    id: Option<widget::Id>,
146    keymap: Keymap<Action>,
147    on_event: Option<Box<dyn Fn(Event) -> Message + 'a>>,
148}
149
150impl<Message> std::fmt::Debug for SplitPane<'_, Message> {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        f.debug_struct("SplitPane")
153            .field("state", self.state)
154            .field("axis", &self.axis)
155            .field("stack_below", &self.stack_below)
156            .field("grip", &self.grip)
157            .finish_non_exhaustive()
158    }
159}
160
161/// Shows `start` and `end` with a handle between them, side by side unless
162/// the [axis](SplitPane::axis) says otherwise. Without
163/// [`on_event`](SplitPane::on_event) the handle does nothing.
164pub fn split_pane<'a, Message>(
165    state: &'a State,
166    start: impl Into<Element<'a, Message>>,
167    end: impl Into<Element<'a, Message>>,
168) -> SplitPane<'a, Message> {
169    SplitPane {
170        state,
171        start: start.into(),
172        end: end.into(),
173        axis: Axis::default(),
174        stack_below: None,
175        grip: true,
176        width: Length::Fill,
177        height: Length::Fill,
178        id: None,
179        keymap: default_keymap(),
180        on_event: None,
181    }
182}
183
184impl<'a, Message> SplitPane<'a, Message> {
185    pub fn axis(mut self, axis: Axis) -> Self {
186        self.axis = axis;
187        self
188    }
189
190    /// Stacks a horizontal split into a column while it is narrower than
191    /// `width`.
192    pub fn stack_below(mut self, width: f32) -> Self {
193        self.stack_below = Some(width);
194        self
195    }
196
197    /// Whether the handle shows a grip. On by default, since a split pane
198    /// has a single handle to find.
199    pub fn grip(mut self, grip: bool) -> Self {
200        self.grip = grip;
201        self
202    }
203
204    pub fn width(mut self, width: impl Into<Length>) -> Self {
205        self.width = width.into();
206        self
207    }
208
209    pub fn height(mut self, height: impl Into<Length>) -> Self {
210        self.height = height.into();
211        self
212    }
213
214    /// The handle's id, for focusing it with `iced::widget::operation::focus`.
215    pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
216        self.id = Some(id.into());
217        self
218    }
219
220    pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
221        self.keymap = keymap;
222        self
223    }
224
225    pub fn on_event(mut self, on_event: impl Fn(Event) -> Message + 'a) -> Self {
226        self.on_event = Some(Box::new(on_event));
227        self
228    }
229}
230
231impl<'a, Message: 'a> From<SplitPane<'a, Message>> for Element<'a, Message> {
232    fn from(split: SplitPane<'a, Message>) -> Self {
233        let SplitPane {
234            state,
235            start,
236            end,
237            axis,
238            stack_below,
239            grip,
240            width,
241            height,
242            id,
243            keymap,
244            on_event,
245        } = split;
246
247        let mut group = resizable_panel::resizable_panel(&state.group, [start, end])
248            .axis(axis)
249            .grip(grip)
250            .width(width)
251            .height(height)
252            .keymap(keymap);
253        if let Some(breakpoint) = stack_below {
254            group = group.stack_below(breakpoint);
255        }
256        if let Some(id) = id {
257            group = group.id(id);
258        }
259        if let Some(on_event) = on_event {
260            group = group.on_event(on_event);
261        }
262        group.into()
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn close(a: f32, b: f32) -> bool {
271        (a - b).abs() < 1e-3
272    }
273
274    fn measured(state: State) -> State {
275        let mut state = state;
276        let _ = state.update(Event::Measure(1000.0));
277        state
278    }
279
280    #[test]
281    fn ratio_is_clamped_and_defaults_to_even() {
282        assert!(close(State::new(0.3).ratio(), 0.3));
283        assert!(close(State::new(1.5).ratio(), 1.0));
284        assert!(close(State::new(-1.0).ratio(), 0.0));
285        assert!(close(State::new(f32::NAN).ratio(), 0.5));
286        assert!(close(State::default().ratio(), 0.5));
287    }
288
289    #[test]
290    fn ratio_of_sizes() {
291        assert!(close(ratio_of(&[300.0, 700.0]), 0.3));
292        assert!(close(ratio_of(&[1.0, 3.0]), 0.25));
293        assert!(close(ratio_of(&[]), 0.5));
294        assert!(close(ratio_of(&[0.0, 0.0]), 0.5));
295    }
296
297    #[test]
298    fn dragging_changes_the_ratio() {
299        let mut state = measured(State::new(0.5));
300        let _ = state.update(Event::DragStart {
301            handle: 0,
302            position: 500.0,
303        });
304        assert_eq!(state.update(Event::Drag(650.0)), Some(Output::Resized));
305        assert!(close(state.ratio(), 0.65));
306    }
307
308    #[test]
309    fn limits_hold_the_ratio_in_range() {
310        let mut state = measured(State::new(0.5).min(Extent::Pixels(200.0), Extent::Fraction(0.4)));
311        let _ = state.update(Event::Resize {
312            handle: 0,
313            delta: 400.0,
314        });
315        assert!(close(state.ratio(), 0.6), "end keeps 40%");
316        let _ = state.update(Event::Resize {
317            handle: 0,
318            delta: -900.0,
319        });
320        assert!(close(state.ratio(), 0.2), "start keeps 200px");
321
322        let capped = measured(State::new(0.9).max(Extent::Fraction(0.7), Extent::Fraction(1.0)));
323        assert!(close(capped.ratio(), 0.7));
324    }
325
326    #[test]
327    fn set_ratio_fits_the_limits_once_measured() {
328        let mut state = measured(State::new(0.5).min(Extent::Pixels(100.0), Extent::Pixels(300.0)));
329        state.set_ratio(0.9);
330        assert!(close(state.ratio(), 0.7));
331        let restored = State::new(0.5).with_ratio(0.25);
332        assert!(close(restored.ratio(), 0.25));
333    }
334
335    #[test]
336    fn limits_keep_a_ratio_set_earlier() {
337        let state = State::new(0.5)
338            .with_ratio(0.3)
339            .min(Extent::Pixels(10.0), Extent::Pixels(10.0));
340        assert!(close(state.ratio(), 0.3));
341    }
342
343    #[test]
344    fn either_side_can_collapse() {
345        let mut state = measured(
346            State::new(0.5)
347                .min(Extent::Pixels(200.0), Extent::Pixels(200.0))
348                .collapsible(false, true),
349        );
350        assert_eq!(state.update(Event::Toggle(0)), None);
351        assert_eq!(state.update(Event::Toggle(1)), Some(Output::Collapsed(1)));
352        assert!(state.is_end_collapsed());
353        assert!(!state.is_start_collapsed());
354        assert!(close(state.ratio(), 1.0));
355    }
356
357    #[test]
358    fn builder_defaults_show_a_grip() {
359        let state = State::default();
360        let split: SplitPane<'_, ()> = split_pane(&state, "start", "end");
361        assert!(split.grip);
362        assert_eq!(split.axis, Axis::Horizontal);
363        assert_eq!(split.stack_below, None);
364        assert!(split.on_event.is_none());
365    }
366}