Skip to main content

agg_gui/widgets/
splitter.rs

1//! `Splitter` — draggable divider between two children.
2//!
3//! Supports horizontal split (left | right) and vertical split (top / bottom).
4//! Use [`Splitter::new`] for the historical horizontal layout, or
5//! [`Splitter::vertical`] for the Y-axis variant.
6
7use crate::draw_ctx::DrawCtx;
8use crate::event::{Event, EventResult, MouseButton};
9use crate::geometry::{Point, Rect, Size};
10use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
11use crate::widget::Widget;
12
13/// A draggable divider that splits its two children along one axis.
14///
15/// Horizontal: `children[0]` = left, `children[1]` = right; `ratio` is
16/// the fraction of width going to `children[0]`.
17///
18/// Vertical: `children[0]` = top, `children[1]` = bottom; `ratio` is
19/// the fraction of height going to `children[0]` (the top pane).
20pub struct Splitter {
21    bounds: Rect,
22    children: Vec<Box<dyn Widget>>, // exactly 2
23    base: WidgetBase,
24    /// Split position as a fraction of total length along the split axis.
25    /// Clamped to [0.05, 0.95].
26    pub ratio: f64,
27    /// Width of the draggable divider strip (perpendicular to the split axis).
28    pub divider_width: f64,
29    /// `true` for top/bottom (Y-axis) split, `false` (default) for
30    /// left/right (X-axis) split.
31    pub vertical: bool,
32
33    hovered: bool,
34    dragging: bool,
35}
36
37impl Splitter {
38    /// Horizontal split: `left` | `right`.
39    pub fn new(left: Box<dyn Widget>, right: Box<dyn Widget>) -> Self {
40        Self {
41            bounds: Rect::default(),
42            children: vec![left, right],
43            base: WidgetBase::new(),
44            ratio: 0.5,
45            divider_width: 6.0,
46            vertical: false,
47            hovered: false,
48            dragging: false,
49        }
50    }
51
52    /// Vertical split: `top` (visually upper, higher Y in agg-gui's Y-up
53    /// coords) over `bottom`. `ratio` is the fraction of total height
54    /// allocated to the top pane.
55    pub fn vertical(top: Box<dyn Widget>, bottom: Box<dyn Widget>) -> Self {
56        Self {
57            bounds: Rect::default(),
58            children: vec![top, bottom],
59            base: WidgetBase::new(),
60            ratio: 0.5,
61            divider_width: 6.0,
62            vertical: true,
63            hovered: false,
64            dragging: false,
65        }
66    }
67
68    pub fn with_ratio(mut self, ratio: f64) -> Self {
69        self.ratio = ratio.clamp(0.05, 0.95);
70        self
71    }
72
73    pub fn with_divider_width(mut self, w: f64) -> Self {
74        self.divider_width = w;
75        self
76    }
77
78    pub fn with_margin(mut self, m: Insets) -> Self {
79        self.base.margin = m;
80        self
81    }
82    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
83        self.base.h_anchor = h;
84        self
85    }
86    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
87        self.base.v_anchor = v;
88        self
89    }
90    pub fn with_min_size(mut self, s: Size) -> Self {
91        self.base.min_size = s;
92        self
93    }
94    pub fn with_max_size(mut self, s: Size) -> Self {
95        self.base.max_size = s;
96        self
97    }
98
99    /// Length of the bounds along the splitting axis.
100    fn axis_length(&self) -> f64 {
101        if self.vertical {
102            self.bounds.height
103        } else {
104            self.bounds.width
105        }
106    }
107
108    /// Position of the divider along the splitting axis. For horizontal
109    /// splits this is the X of the divider's left edge (so children[0]
110    /// occupies x in [0, divider_pos]). For vertical splits in Y-up
111    /// coords, children[0] is the top pane — its bottom edge is at
112    /// `axis_length - divider_width - divider_pos_from_bottom` ... see
113    /// the layout / paint / event branches for the worked-out coords.
114    fn divider_pos(&self) -> f64 {
115        (self.axis_length() - self.divider_width) * self.ratio
116    }
117}
118
119impl Widget for Splitter {
120    fn type_name(&self) -> &'static str {
121        "Splitter"
122    }
123    fn bounds(&self) -> Rect {
124        self.bounds
125    }
126    fn set_bounds(&mut self, b: Rect) {
127        self.bounds = b;
128    }
129    fn children(&self) -> &[Box<dyn Widget>] {
130        &self.children
131    }
132    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
133        &mut self.children
134    }
135
136    fn margin(&self) -> Insets {
137        self.base.margin
138    }
139    fn widget_base(&self) -> Option<&WidgetBase> {
140        Some(&self.base)
141    }
142    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
143        Some(&mut self.base)
144    }
145    fn h_anchor(&self) -> HAnchor {
146        self.base.h_anchor
147    }
148    fn v_anchor(&self) -> VAnchor {
149        self.base.v_anchor
150    }
151    fn min_size(&self) -> Size {
152        self.base.min_size
153    }
154    fn max_size(&self) -> Size {
155        self.base.max_size
156    }
157
158    fn hit_test(&self, local_pos: Point) -> bool {
159        // Capture all events during drag, even if cursor leaves bounds.
160        if self.dragging {
161            return true;
162        }
163        let b = self.bounds();
164        local_pos.x >= 0.0
165            && local_pos.x <= b.width
166            && local_pos.y >= 0.0
167            && local_pos.y <= b.height
168    }
169
170    fn layout(&mut self, available: Size) -> Size {
171        let div = self.divider_width;
172
173        if self.children.len() < 2 {
174            return available;
175        }
176
177        if self.vertical {
178            // Y-up: children[0] = top, children[1] = bottom. ratio is
179            // the fraction of height going to the TOP pane.
180            let top_h = ((available.height - div) * self.ratio).max(0.0);
181            let bot_h = (available.height - div - top_h).max(0.0);
182            let w = available.width;
183
184            // Top pane sits above the divider — its bottom edge is at
185            // bot_h + div, height extends up to bot_h + div + top_h.
186            self.children[0].layout(Size::new(w, top_h));
187            self.children[0].set_bounds(Rect::new(0.0, bot_h + div, w, top_h));
188
189            // Bottom pane sits at y = 0, height bot_h.
190            self.children[1].layout(Size::new(w, bot_h));
191            self.children[1].set_bounds(Rect::new(0.0, 0.0, w, bot_h));
192        } else {
193            let left_w = ((available.width - div) * self.ratio).max(0.0);
194            let right_w = (available.width - div - left_w).max(0.0);
195            let h = available.height;
196
197            self.children[0].layout(Size::new(left_w, h));
198            self.children[0].set_bounds(Rect::new(0.0, 0.0, left_w, h));
199
200            let right_x = left_w + div;
201            self.children[1].layout(Size::new(right_w, h));
202            self.children[1].set_bounds(Rect::new(right_x, 0.0, right_w, h));
203        }
204
205        available
206    }
207
208    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
209        let v = ctx.visuals();
210        let color = if self.dragging {
211            v.accent.with_alpha(0.6)
212        } else if self.hovered {
213            v.text_color.with_alpha(0.15)
214        } else {
215            v.text_color.with_alpha(0.08)
216        };
217
218        let grip_color = if self.hovered || self.dragging {
219            v.accent.with_alpha(0.7)
220        } else {
221            v.text_color.with_alpha(0.25)
222        };
223
224        ctx.set_fill_color(color);
225
226        if self.vertical {
227            // Divider is a horizontal strip at Y = bottom_pane_height.
228            let bot_h = ((self.bounds.height - self.divider_width) * (1.0 - self.ratio)).max(0.0);
229            let div_y = bot_h;
230            let w = self.bounds.width;
231            ctx.begin_path();
232            ctx.rect(0.0, div_y, w, self.divider_width);
233            ctx.fill();
234
235            // Grip dots horizontally centered across the divider.
236            if w > 30.0 {
237                ctx.set_fill_color(grip_color);
238                let cy = div_y + self.divider_width * 0.5;
239                let cx = w * 0.5;
240                for i in -1i32..=1 {
241                    ctx.begin_path();
242                    ctx.circle(cx + i as f64 * 5.0, cy, 1.5);
243                    ctx.fill();
244                }
245            }
246        } else {
247            let div_x = self.divider_pos();
248            let h = self.bounds.height;
249            ctx.begin_path();
250            ctx.rect(div_x, 0.0, self.divider_width, h);
251            ctx.fill();
252
253            if h > 30.0 {
254                ctx.set_fill_color(grip_color);
255                let cx = div_x + self.divider_width * 0.5;
256                let cy = h * 0.5;
257                for i in -1i32..=1 {
258                    ctx.begin_path();
259                    ctx.circle(cx, cy + i as f64 * 5.0, 1.5);
260                    ctx.fill();
261                }
262            }
263        }
264    }
265
266    fn on_event(&mut self, event: &Event) -> EventResult {
267        if self.vertical {
268            let div = self.divider_width;
269            let total = self.bounds.height;
270            // Bottom pane height; divider's bottom edge is at this Y.
271            let bot_h = ((total - div) * (1.0 - self.ratio)).max(0.0);
272            let div_y = bot_h;
273            let div_end = div_y + div;
274
275            match event {
276                Event::MouseMove { pos } => {
277                    let over_div = pos.y >= div_y - 2.0 && pos.y <= div_end + 2.0;
278                    let was = self.hovered;
279                    self.hovered = over_div;
280                    if self.dragging {
281                        if total > div {
282                            // ratio is fraction going to top — that's the
283                            // upper portion above the divider midline.
284                            // Convert pos.y (Y-up) into top fraction.
285                            let div_mid = pos.y;
286                            let top_h = (total - div_mid).max(0.0);
287                            self.ratio = (top_h / total).clamp(0.05, 0.95);
288                        }
289                        crate::animation::request_draw();
290                        EventResult::Consumed
291                    } else {
292                        if was != self.hovered {
293                            crate::animation::request_draw();
294                            return EventResult::Consumed;
295                        }
296                        EventResult::Ignored
297                    }
298                }
299                Event::MouseDown {
300                    pos,
301                    button: MouseButton::Left,
302                    ..
303                } => {
304                    if pos.y >= div_y - 2.0 && pos.y <= div_end + 2.0 {
305                        self.dragging = true;
306                        EventResult::Consumed
307                    } else {
308                        EventResult::Ignored
309                    }
310                }
311                Event::MouseUp {
312                    button: MouseButton::Left,
313                    ..
314                } => {
315                    let was_dragging = self.dragging;
316                    self.dragging = false;
317                    if was_dragging {
318                        crate::animation::request_draw();
319                        EventResult::Consumed
320                    } else {
321                        EventResult::Ignored
322                    }
323                }
324                _ => EventResult::Ignored,
325            }
326        } else {
327            let div_x = self.divider_pos();
328            let div_end = div_x + self.divider_width;
329
330            match event {
331                Event::MouseMove { pos } => {
332                    let over_div = pos.x >= div_x - 2.0 && pos.x <= div_end + 2.0;
333                    let was = self.hovered;
334                    self.hovered = over_div;
335                    if self.dragging {
336                        let total = self.bounds.width;
337                        if total > self.divider_width {
338                            self.ratio = (pos.x / total).clamp(0.05, 0.95);
339                        }
340                        crate::animation::request_draw();
341                        EventResult::Consumed
342                    } else {
343                        if was != self.hovered {
344                            crate::animation::request_draw();
345                            return EventResult::Consumed;
346                        }
347                        EventResult::Ignored
348                    }
349                }
350                Event::MouseDown {
351                    pos,
352                    button: MouseButton::Left,
353                    ..
354                } => {
355                    if pos.x >= div_x - 2.0 && pos.x <= div_end + 2.0 {
356                        self.dragging = true;
357                        EventResult::Consumed
358                    } else {
359                        EventResult::Ignored
360                    }
361                }
362                Event::MouseUp {
363                    button: MouseButton::Left,
364                    ..
365                } => {
366                    let was_dragging = self.dragging;
367                    self.dragging = false;
368                    if was_dragging {
369                        crate::animation::request_draw();
370                        EventResult::Consumed
371                    } else {
372                        EventResult::Ignored
373                    }
374                }
375                _ => EventResult::Ignored,
376            }
377        }
378    }
379}