Skip to main content

densa_widgets/
splitter.rs

1use densa_core::Rect;
2use densa_draw::Canvas;
3use densa_ui::{
4    AccessNode, Cursor, Event, EventCx, Handled, Length, PaintCx, PointerButton, PointerKind, Role,
5    Style, Widget,
6};
7
8/// The divider's thickness, in logical pixels: the gap between the panes.
9const HANDLE: f32 = 6.0;
10/// The smallest a pane gets by dragging, in logical pixels.
11const MIN_PANE: f32 = 48.0;
12
13/// Which way the panes sit.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15enum Split {
16    Row,
17    Column,
18}
19
20/// Two panes side by side (or one above the other) with a draggable divider between them.
21///
22/// Add two children: the first keeps the size the divider gives it, the second takes the
23/// rest. Give the first a starting size in its style (`width` for a row, `height` for a
24/// column) and the second `grow(1.0)`; [`Splitter::style`] spaces them for the divider.
25#[derive(Debug)]
26pub struct Splitter {
27    direction: Split,
28    /// Where on the divider the drag started, from the first pane's far edge.
29    grab: Option<f32>,
30}
31
32impl Splitter {
33    /// Panes left and right of a vertical divider.
34    pub fn row() -> Self {
35        Self {
36            direction: Split::Row,
37            grab: None,
38        }
39    }
40
41    /// Panes above and below a horizontal divider.
42    pub fn column() -> Self {
43        Self {
44            direction: Split::Column,
45            ..Self::row()
46        }
47    }
48
49    /// The splitter's style: its direction, the divider's gap, filling its parent's free
50    /// space and allowed to be smaller than its content (whose panes then scroll or clip).
51    pub fn style(&self) -> Style {
52        let base = match self.direction {
53            Split::Row => Style::row(),
54            Split::Column => Style::column(),
55        };
56        base.gap(HANDLE)
57            .grow(1.0)
58            .min_size(Length::Px(0.0), Length::Px(0.0))
59    }
60
61    fn cursor(&self) -> Cursor {
62        match self.direction {
63            Split::Row => Cursor::ResizeColumn,
64            Split::Column => Cursor::ResizeRow,
65        }
66    }
67
68    /// Along the splitter's axis: where `rect` starts and ends.
69    fn span(&self, rect: Rect) -> (f32, f32) {
70        match self.direction {
71            Split::Row => (rect.left(), rect.right()),
72            Split::Column => (rect.top(), rect.bottom()),
73        }
74    }
75}
76
77impl Widget for Splitter {
78    fn accessibility(&self, _: &PaintCx<'_>, node: &mut AccessNode) {
79        node.set_role(Role::Splitter);
80    }
81
82    fn paint(&mut self, cx: &PaintCx<'_>, canvas: &mut Canvas<'_>) {
83        let Some(pane) = cx.children().first().and_then(|&first| cx.rect_of(first)) else {
84            return;
85        };
86        // A line in the middle of the gap, in the accent color while pointed at or dragged.
87        let content = cx.content_rect();
88        let line = cx.px(1.0).round().max(1.0);
89        let middle = (self.span(pane).1 + cx.px(HANDLE) / 2.0 - line / 2.0).round();
90        let divider = match self.direction {
91            Split::Row => Rect::new(middle, content.top(), line, content.height()),
92            Split::Column => Rect::new(content.left(), middle, content.width(), line),
93        };
94        let theme = cx.theme();
95        let color = if cx.is_pressed() || cx.is_hover_target() {
96            theme.accent
97        } else {
98            theme.border
99        };
100        canvas.fill_rect(divider, color);
101    }
102
103    fn event(&mut self, cx: &mut EventCx<'_>, event: &Event<'_>) -> Handled {
104        let Event::Pointer(pointer) = event else {
105            return Handled::No;
106        };
107        let Some(&first) = cx.children().first() else {
108            return Handled::No;
109        };
110        let Some(pane) = cx.rect_of(first) else {
111            return Handled::No;
112        };
113        let at = match self.direction {
114            Split::Row => pointer.position.x,
115            Split::Column => pointer.position.y,
116        };
117        match pointer.kind {
118            // Over the splitter itself, not a pane: the pointer is on the divider.
119            PointerKind::Enter => {
120                cx.set_cursor(Some(self.cursor()));
121                cx.request_paint();
122                Handled::Yes
123            }
124            PointerKind::Leave => {
125                cx.set_cursor(None);
126                cx.request_paint();
127                Handled::Yes
128            }
129            PointerKind::Down(PointerButton::Primary) => {
130                // Presses in a pane that its widgets leave unhandled come up here too: only
131                // one on the divider, between the panes, starts a drag.
132                let position = pointer.position;
133                let on_pane = cx
134                    .children()
135                    .iter()
136                    .any(|&child| cx.rect_of(child).is_some_and(|r| r.contains(position)));
137                if on_pane {
138                    return Handled::No;
139                }
140                self.grab = Some(at - self.span(pane).1);
141                cx.request_paint();
142                Handled::Yes
143            }
144            PointerKind::Cancel => {
145                self.grab = None;
146                cx.request_paint();
147                Handled::Yes
148            }
149            PointerKind::Move => {
150                let Some(grab) = self.grab else {
151                    return Handled::No;
152                };
153                let (start, end) = self.span(cx.content_rect());
154                let (pane_start, _) = self.span(pane);
155                let most = (end - start - cx.px(HANDLE) - cx.px(MIN_PANE)).max(cx.px(MIN_PANE));
156                let size = (at - grab - pane_start).clamp(cx.px(MIN_PANE), most);
157                let logical = Length::Px(size / cx.scale());
158                let style = cx.style_of(first).cloned().unwrap_or_default().shrink(0.0);
159                let style = match self.direction {
160                    Split::Row => style.width(logical),
161                    Split::Column => style.height(logical),
162                };
163                cx.set_style(first, style);
164                Handled::Yes
165            }
166            PointerKind::Up(PointerButton::Primary) => {
167                self.grab = None;
168                cx.request_paint();
169                Handled::Yes
170            }
171            _ => Handled::No,
172        }
173    }
174}