druid/widget/
split.rs

1// Copyright 2019 The Druid Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! A widget which splits an area in two, with a settable ratio, and optional draggable resizing.
16
17use crate::debug_state::DebugState;
18use crate::kurbo::Line;
19use crate::widget::flex::Axis;
20use crate::widget::prelude::*;
21use crate::{theme, Color, Cursor, Data, Point, Rect, WidgetPod};
22use tracing::{instrument, trace, warn};
23
24/// A container containing two other widgets, splitting the area either horizontally or vertically.
25pub struct Split<T> {
26    split_axis: Axis,
27    split_point_chosen: f64,
28    split_point_effective: f64,
29    min_size: (f64, f64), // Integers only
30    bar_size: f64,        // Integers only
31    min_bar_area: f64,    // Integers only
32    solid: bool,
33    draggable: bool,
34    /// The split bar is hovered by the mouse. This state is locked to `true` if the
35    /// widget is active (the bar is being dragged) to avoid cursor and painting jitter
36    /// if the mouse moves faster than the layout and temporarily gets outside of the
37    /// bar area while still being dragged.
38    is_bar_hover: bool,
39    /// Offset from the split point (bar center) to the actual mouse position when the
40    /// bar was clicked. This is used to ensure a click without mouse move is a no-op,
41    /// instead of re-centering the bar on the mouse.
42    click_offset: f64,
43    child1: WidgetPod<T, Box<dyn Widget<T>>>,
44    old_bc_1: BoxConstraints,
45    child2: WidgetPod<T, Box<dyn Widget<T>>>,
46    old_bc_2: BoxConstraints,
47}
48
49impl<T> Split<T> {
50    /// Create a new split panel, with the specified axis being split in two.
51    ///
52    /// Horizontal split axis means that the children are left and right.
53    /// Vertical split axis means that the children are up and down.
54    fn new(
55        split_axis: Axis,
56        child1: impl Widget<T> + 'static,
57        child2: impl Widget<T> + 'static,
58    ) -> Self {
59        Split {
60            split_axis,
61            split_point_chosen: 0.5,
62            split_point_effective: 0.5,
63            min_size: (0.0, 0.0),
64            bar_size: 6.0,
65            min_bar_area: 6.0,
66            solid: false,
67            draggable: false,
68            is_bar_hover: false,
69            click_offset: 0.0,
70            child1: WidgetPod::new(child1).boxed(),
71            old_bc_1: BoxConstraints::tight(Size::ZERO),
72            child2: WidgetPod::new(child2).boxed(),
73            old_bc_2: BoxConstraints::tight(Size::ZERO),
74        }
75    }
76
77    /// Create a new split panel, with the horizontal axis split in two by a vertical bar.
78    pub fn columns(
79        left_child: impl Widget<T> + 'static,
80        right_child: impl Widget<T> + 'static,
81    ) -> Self {
82        Self::new(Axis::Horizontal, left_child, right_child)
83    }
84
85    /// Create a new split panel, with the vertical axis split in two by a horizontal bar.
86    pub fn rows(
87        upper_child: impl Widget<T> + 'static,
88        lower_child: impl Widget<T> + 'static,
89    ) -> Self {
90        Self::new(Axis::Vertical, upper_child, lower_child)
91    }
92
93    /// Builder-style method to set the split point as a fraction of the split axis.
94    ///
95    /// The value must be between `0.0` and `1.0`, inclusive.
96    /// The default split point is `0.5`.
97    pub fn split_point(mut self, split_point: f64) -> Self {
98        assert!(
99            (0.0..=1.0).contains(&split_point),
100            "split_point must be in the range [0.0-1.0]!"
101        );
102        self.split_point_chosen = split_point;
103        self
104    }
105
106    /// Builder-style method to set the minimum size for both sides of the split axis.
107    ///
108    /// The value must be greater than or equal to `0.0`.
109    /// The value will be rounded up to the nearest integer.
110    pub fn min_size(mut self, first: f64, second: f64) -> Self {
111        assert!(first >= 0.0);
112        assert!(second >= 0.0);
113        self.min_size = (first.ceil(), second.ceil());
114        self
115    }
116
117    /// Builder-style method to set the size of the splitter bar.
118    ///
119    /// The value must be positive or zero.
120    /// The value will be rounded up to the nearest integer.
121    /// The default splitter bar size is `6.0`.
122    pub fn bar_size(mut self, bar_size: f64) -> Self {
123        assert!(bar_size >= 0.0, "bar_size must be 0.0 or greater!");
124        self.bar_size = bar_size.ceil();
125        self
126    }
127
128    /// Builder-style method to set the minimum size of the splitter bar area.
129    ///
130    /// The minimum splitter bar area defines the minimum size of the area
131    /// where mouse hit detection is done for the splitter bar.
132    /// The final area is either this or the splitter bar size, whichever is greater.
133    ///
134    /// This can be useful when you want to use a very narrow visual splitter bar,
135    /// but don't want to sacrifice user experience by making it hard to click on.
136    ///
137    /// The value must be positive or zero.
138    /// The value will be rounded up to the nearest integer.
139    /// The default minimum splitter bar area is `6.0`.
140    pub fn min_bar_area(mut self, min_bar_area: f64) -> Self {
141        assert!(min_bar_area >= 0.0, "min_bar_area must be 0.0 or greater!");
142        self.min_bar_area = min_bar_area.ceil();
143        self
144    }
145
146    /// Builder-style method to set whether the split point can be changed by dragging.
147    pub fn draggable(mut self, draggable: bool) -> Self {
148        self.draggable = draggable;
149        self
150    }
151
152    /// Builder-style method to set whether the splitter bar is drawn as a solid rectangle.
153    ///
154    /// If this is `false` (the default), the bar will be drawn as two parallel lines.
155    pub fn solid_bar(mut self, solid: bool) -> Self {
156        self.solid = solid;
157        self
158    }
159
160    /// Returns the size of the splitter bar area.
161    #[inline]
162    fn bar_area(&self) -> f64 {
163        self.bar_size.max(self.min_bar_area)
164    }
165
166    /// Returns the padding size added to each side of the splitter bar.
167    #[inline]
168    fn bar_padding(&self) -> f64 {
169        (self.bar_area() - self.bar_size) / 2.0
170    }
171
172    /// Returns the position of the split point (split bar center).
173    fn bar_position(&self, size: Size) -> f64 {
174        let bar_area = self.bar_area();
175        match self.split_axis {
176            Axis::Horizontal => {
177                let reduced_width = size.width - bar_area;
178                let edge1 = (reduced_width * self.split_point_effective).floor();
179                edge1 + bar_area / 2.0
180            }
181            Axis::Vertical => {
182                let reduced_height = size.height - bar_area;
183                let edge1 = (reduced_height * self.split_point_effective).floor();
184                edge1 + bar_area / 2.0
185            }
186        }
187    }
188
189    /// Returns the location of the edges of the splitter bar area,
190    /// given the specified total size.
191    fn bar_edges(&self, size: Size) -> (f64, f64) {
192        let bar_area = self.bar_area();
193        match self.split_axis {
194            Axis::Horizontal => {
195                let reduced_width = size.width - bar_area;
196                let edge1 = (reduced_width * self.split_point_effective).floor();
197                let edge2 = edge1 + bar_area;
198                (edge1, edge2)
199            }
200            Axis::Vertical => {
201                let reduced_height = size.height - bar_area;
202                let edge1 = (reduced_height * self.split_point_effective).floor();
203                let edge2 = edge1 + bar_area;
204                (edge1, edge2)
205            }
206        }
207    }
208
209    /// Returns true if the provided mouse position is inside the splitter bar area.
210    fn bar_hit_test(&self, size: Size, mouse_pos: Point) -> bool {
211        let (edge1, edge2) = self.bar_edges(size);
212        match self.split_axis {
213            Axis::Horizontal => mouse_pos.x >= edge1 && mouse_pos.x <= edge2,
214            Axis::Vertical => mouse_pos.y >= edge1 && mouse_pos.y <= edge2,
215        }
216    }
217
218    /// Returns the minimum and maximum split coordinate of the provided size.
219    fn split_side_limits(&self, size: Size) -> (f64, f64) {
220        let split_axis_size = self.split_axis.major(size);
221
222        let (mut min_limit, min_second) = self.min_size;
223        let mut max_limit = (split_axis_size - min_second).max(0.0);
224
225        if min_limit > max_limit {
226            min_limit = 0.5 * (min_limit + max_limit);
227            max_limit = min_limit;
228        }
229
230        (min_limit, max_limit)
231    }
232
233    /// Set a new chosen split point.
234    fn update_split_point(&mut self, size: Size, mouse_pos: Point) {
235        let (min_limit, max_limit) = self.split_side_limits(size);
236        self.split_point_chosen = match self.split_axis {
237            Axis::Horizontal => mouse_pos.x.clamp(min_limit, max_limit) / size.width,
238            Axis::Vertical => mouse_pos.y.clamp(min_limit, max_limit) / size.height,
239        }
240    }
241
242    /// Returns the color of the splitter bar.
243    fn bar_color(&self, env: &Env) -> Color {
244        if self.draggable {
245            env.get(theme::BORDER_LIGHT)
246        } else {
247            env.get(theme::BORDER_DARK)
248        }
249    }
250
251    fn paint_solid_bar(&mut self, ctx: &mut PaintCtx, env: &Env) {
252        let size = ctx.size();
253        let (edge1, edge2) = self.bar_edges(size);
254        let padding = self.bar_padding();
255        let rect = match self.split_axis {
256            Axis::Horizontal => Rect::from_points(
257                Point::new(edge1 + padding.ceil(), 0.0),
258                Point::new(edge2 - padding.floor(), size.height),
259            ),
260            Axis::Vertical => Rect::from_points(
261                Point::new(0.0, edge1 + padding.ceil()),
262                Point::new(size.width, edge2 - padding.floor()),
263            ),
264        };
265        let splitter_color = self.bar_color(env);
266        ctx.fill(rect, &splitter_color);
267    }
268
269    fn paint_stroked_bar(&mut self, ctx: &mut PaintCtx, env: &Env) {
270        let size = ctx.size();
271        // Set the line width to a third of the splitter bar size,
272        // because we'll paint two equal lines at the edges.
273        let line_width = (self.bar_size / 3.0).floor();
274        let line_midpoint = line_width / 2.0;
275        let (edge1, edge2) = self.bar_edges(size);
276        let padding = self.bar_padding();
277        let (line1, line2) = match self.split_axis {
278            Axis::Horizontal => (
279                Line::new(
280                    Point::new(edge1 + line_midpoint + padding.ceil(), 0.0),
281                    Point::new(edge1 + line_midpoint + padding.ceil(), size.height),
282                ),
283                Line::new(
284                    Point::new(edge2 - line_midpoint - padding.floor(), 0.0),
285                    Point::new(edge2 - line_midpoint - padding.floor(), size.height),
286                ),
287            ),
288            Axis::Vertical => (
289                Line::new(
290                    Point::new(0.0, edge1 + line_midpoint + padding.ceil()),
291                    Point::new(size.width, edge1 + line_midpoint + padding.ceil()),
292                ),
293                Line::new(
294                    Point::new(0.0, edge2 - line_midpoint - padding.floor()),
295                    Point::new(size.width, edge2 - line_midpoint - padding.floor()),
296                ),
297            ),
298        };
299        let splitter_color = self.bar_color(env);
300        ctx.stroke(line1, &splitter_color, line_width);
301        ctx.stroke(line2, &splitter_color, line_width);
302    }
303}
304
305impl<T: Data> Widget<T> for Split<T> {
306    #[instrument(name = "Split", level = "trace", skip(self, ctx, event, data, env))]
307    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {
308        if self.child1.is_active() {
309            self.child1.event(ctx, event, data, env);
310            if ctx.is_handled() {
311                return;
312            }
313        }
314        if self.child2.is_active() {
315            self.child2.event(ctx, event, data, env);
316            if ctx.is_handled() {
317                return;
318            }
319        }
320        if self.draggable {
321            match event {
322                Event::MouseDown(mouse) => {
323                    if mouse.button.is_left() && self.bar_hit_test(ctx.size(), mouse.pos) {
324                        ctx.set_handled();
325                        ctx.set_active(true);
326                        // Save the delta between the mouse click position and the split point
327                        self.click_offset = match self.split_axis {
328                            Axis::Horizontal => mouse.pos.x,
329                            Axis::Vertical => mouse.pos.y,
330                        } - self.bar_position(ctx.size());
331                        // If not already hovering, force and change cursor appropriately
332                        if !self.is_bar_hover {
333                            self.is_bar_hover = true;
334                            match self.split_axis {
335                                Axis::Horizontal => ctx.set_cursor(&Cursor::ResizeLeftRight),
336                                Axis::Vertical => ctx.set_cursor(&Cursor::ResizeUpDown),
337                            };
338                        }
339                    }
340                }
341                Event::MouseUp(mouse) => {
342                    if mouse.button.is_left() && ctx.is_active() {
343                        ctx.set_handled();
344                        ctx.set_active(false);
345                        // Dependending on where the mouse cursor is when the button is released,
346                        // the cursor might or might not need to be changed
347                        self.is_bar_hover =
348                            ctx.is_hot() && self.bar_hit_test(ctx.size(), mouse.pos);
349                        if !self.is_bar_hover {
350                            ctx.clear_cursor()
351                        }
352                    }
353                }
354                Event::MouseMove(mouse) => {
355                    if ctx.is_active() {
356                        // If active, assume always hover/hot
357                        let effective_pos = match self.split_axis {
358                            Axis::Horizontal => {
359                                Point::new(mouse.pos.x - self.click_offset, mouse.pos.y)
360                            }
361                            Axis::Vertical => {
362                                Point::new(mouse.pos.x, mouse.pos.y - self.click_offset)
363                            }
364                        };
365                        self.update_split_point(ctx.size(), effective_pos);
366                        ctx.request_layout();
367                    } else {
368                        // If not active, set cursor when hovering state changes
369                        let hover = ctx.is_hot() && self.bar_hit_test(ctx.size(), mouse.pos);
370                        if hover != self.is_bar_hover {
371                            self.is_bar_hover = hover;
372                            if hover {
373                                match self.split_axis {
374                                    Axis::Horizontal => ctx.set_cursor(&Cursor::ResizeLeftRight),
375                                    Axis::Vertical => ctx.set_cursor(&Cursor::ResizeUpDown),
376                                };
377                            } else {
378                                ctx.clear_cursor();
379                            }
380                        }
381                    }
382                }
383                _ => {}
384            }
385        }
386        if !self.child1.is_active() {
387            self.child1.event(ctx, event, data, env);
388        }
389        if !self.child2.is_active() {
390            self.child2.event(ctx, event, data, env);
391        }
392    }
393
394    #[instrument(name = "Split", level = "trace", skip(self, ctx, event, data, env))]
395    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {
396        self.child1.lifecycle(ctx, event, data, env);
397        self.child2.lifecycle(ctx, event, data, env);
398    }
399
400    #[instrument(name = "Split", level = "trace", skip(self, ctx, _old_data, data, env))]
401    fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) {
402        self.child1.update(ctx, data, env);
403        self.child2.update(ctx, data, env);
404    }
405
406    #[instrument(name = "Split", level = "trace", skip(self, ctx, bc, data, env))]
407    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {
408        bc.debug_check("Split");
409
410        match self.split_axis {
411            Axis::Horizontal => {
412                if !bc.is_width_bounded() {
413                    warn!("A Split widget was given an unbounded width to split.")
414                }
415            }
416            Axis::Vertical => {
417                if !bc.is_height_bounded() {
418                    warn!("A Split widget was given an unbounded height to split.")
419                }
420            }
421        }
422
423        let mut my_size = bc.max();
424        let bar_area = self.bar_area();
425        let reduced_size = Size::new(
426            (my_size.width - bar_area).max(0.),
427            (my_size.height - bar_area).max(0.),
428        );
429
430        // Update our effective split point to respect our constraints
431        self.split_point_effective = {
432            let (min_limit, max_limit) = self.split_side_limits(reduced_size);
433            let reduced_axis_size = self.split_axis.major(reduced_size);
434            if reduced_axis_size.is_infinite() || reduced_axis_size <= std::f64::EPSILON {
435                0.5
436            } else {
437                self.split_point_chosen
438                    .clamp(min_limit / reduced_axis_size, max_limit / reduced_axis_size)
439            }
440        };
441
442        let (child1_bc, child2_bc) = match self.split_axis {
443            Axis::Horizontal => {
444                let child1_width = (reduced_size.width * self.split_point_effective)
445                    .floor()
446                    .max(0.0);
447                let child2_width = (reduced_size.width - child1_width).max(0.0);
448                (
449                    BoxConstraints::new(
450                        Size::new(child1_width, bc.min().height),
451                        Size::new(child1_width, bc.max().height),
452                    ),
453                    BoxConstraints::new(
454                        Size::new(child2_width, bc.min().height),
455                        Size::new(child2_width, bc.max().height),
456                    ),
457                )
458            }
459            Axis::Vertical => {
460                let child1_height = (reduced_size.height * self.split_point_effective)
461                    .floor()
462                    .max(0.0);
463                let child2_height = (reduced_size.height - child1_height).max(0.0);
464                (
465                    BoxConstraints::new(
466                        Size::new(bc.min().width, child1_height),
467                        Size::new(bc.max().width, child1_height),
468                    ),
469                    BoxConstraints::new(
470                        Size::new(bc.min().width, child2_height),
471                        Size::new(bc.max().width, child2_height),
472                    ),
473                )
474            }
475        };
476
477        let child1_size = if self.old_bc_1 != child1_bc || self.child1.layout_requested() {
478            self.child1.layout(ctx, &child1_bc, data, env)
479        } else {
480            self.child1.layout_rect().size()
481        };
482        self.old_bc_1 = child1_bc;
483        let child2_size = if self.old_bc_2 != child2_bc || self.child2.layout_requested() {
484            self.child2.layout(ctx, &child2_bc, data, env)
485        } else {
486            self.child2.layout_rect().size()
487        };
488        self.old_bc_2 = child2_bc;
489
490        // Top-left align for both children, out of laziness.
491        // Reduce our unsplit direction to the larger of the two widgets
492        let child1_pos = Point::ORIGIN;
493        let child2_pos = match self.split_axis {
494            Axis::Horizontal => {
495                my_size.height = child1_size.height.max(child2_size.height);
496                Point::new(child1_size.width + bar_area, 0.0)
497            }
498            Axis::Vertical => {
499                my_size.width = child1_size.width.max(child2_size.width);
500                Point::new(0.0, child1_size.height + bar_area)
501            }
502        };
503        self.child1.set_origin(ctx, child1_pos);
504        self.child2.set_origin(ctx, child2_pos);
505
506        let paint_rect = self.child1.paint_rect().union(self.child2.paint_rect());
507        let insets = paint_rect - my_size.to_rect();
508        ctx.set_paint_insets(insets);
509
510        trace!("Computed layout: size={}, insets={:?}", my_size, insets);
511        my_size
512    }
513
514    #[instrument(name = "Split", level = "trace", skip(self, ctx, data, env))]
515    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {
516        if self.solid {
517            self.paint_solid_bar(ctx, env);
518        } else {
519            self.paint_stroked_bar(ctx, env);
520        }
521        self.child1.paint(ctx, data, env);
522        self.child2.paint(ctx, data, env);
523    }
524
525    fn debug_state(&self, data: &T) -> DebugState {
526        DebugState {
527            display_name: self.short_type_name().to_string(),
528            children: vec![
529                self.child1.widget().debug_state(data),
530                self.child2.widget().debug_state(data),
531            ],
532            ..Default::default()
533        }
534    }
535}