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//!
7//! [`SplitterRatio`] is the optional two-way channel a host uses to
8//! *persist* a divider position: the widget publishes every drag into it
9//! and adopts any value written from outside on its next layout. Without
10//! it there is no way to either read the ratio back out of a boxed
11//! widget tree or restore one — the field is only reachable while the
12//! host still owns the concrete `Splitter`.
13
14use std::sync::{Arc, Mutex};
15
16use crate::draw_ctx::DrawCtx;
17use crate::event::{Event, EventResult, MouseButton};
18use crate::geometry::{Point, Rect, Size};
19use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
20use crate::widget::Widget;
21
22/// Shared, clonable divider position — the seam between a [`Splitter`]
23/// buried in a widget tree and a host that wants to save or restore it.
24///
25/// Two-way by design: the splitter writes the cell on every drag, and
26/// reads it at the start of each `layout()` so a host write (restoring a
27/// document's saved layout) lands on the next frame. They never fight —
28/// the value the splitter adopts is the value it would publish.
29///
30/// Poisoning is swallowed: the payload is one `f64` that no panic can
31/// leave logically inconsistent, so both accessors take the inner value
32/// rather than propagating a panic into the UI thread.
33#[derive(Clone, Debug)]
34pub struct SplitterRatio(Arc<Mutex<f64>>);
35
36impl SplitterRatio {
37    /// New channel seeded with `ratio` (clamped like
38    /// [`Splitter::with_ratio`]; a non-finite seed falls back to a even
39    /// split, see [`Self::set`]).
40    pub fn new(ratio: f64) -> Self {
41        Self(Arc::new(Mutex::new(clamp_ratio(ratio, 0.5))))
42    }
43
44    /// Current divider position as a fraction of the total length going
45    /// to `children[0]` (left / top pane).
46    pub fn get(&self) -> f64 {
47        *self.0.lock().unwrap_or_else(|e| e.into_inner())
48    }
49
50    /// Ask the splitter to move. Applied on its next layout pass.
51    ///
52    /// A non-finite ratio is **ignored** — `f64::clamp` passes `NaN`
53    /// through, and a `NaN` divider makes both panes zero-sized with no
54    /// way for the user to drag it back (the drag maths derives the new
55    /// ratio from the old one). Restoring a corrupt document therefore
56    /// leaves the splitter where it is rather than destroying the
57    /// layout. Returns `true` when the value was accepted.
58    pub fn set(&self, ratio: f64) -> bool {
59        if !ratio.is_finite() {
60            return false;
61        }
62        *self.0.lock().unwrap_or_else(|e| e.into_inner()) = ratio.clamp(RATIO_MIN, RATIO_MAX);
63        crate::animation::request_draw();
64        true
65    }
66}
67
68/// Range the divider position is clamped to, at every entry point.
69///
70/// This is the *drag* range — how far the user may push the divider.
71/// A host persisting the position may narrow it further (AtomArtist
72/// clamps a saved divider to NodeDesigner's 20…80 % window before
73/// writing it); the widget deliberately does not, because a pane
74/// dragged to 5 % is a legitimate thing to do while working.
75pub const RATIO_MIN: f64 = 0.05;
76pub const RATIO_MAX: f64 = 0.95;
77
78/// Clamp `ratio` into the drag range, substituting `fallback` for a
79/// non-finite input (which `f64::clamp` would pass through untouched).
80fn clamp_ratio(ratio: f64, fallback: f64) -> f64 {
81    if ratio.is_finite() {
82        ratio.clamp(RATIO_MIN, RATIO_MAX)
83    } else {
84        fallback
85    }
86}
87
88/// A draggable divider that splits its two children along one axis.
89///
90/// Horizontal: `children[0]` = left, `children[1]` = right; `ratio` is
91/// the fraction of width going to `children[0]`.
92///
93/// Vertical: `children[0]` = top, `children[1]` = bottom; `ratio` is
94/// the fraction of height going to `children[0]` (the top pane).
95pub struct Splitter {
96    bounds: Rect,
97    children: Vec<Box<dyn Widget>>, // exactly 2
98    base: WidgetBase,
99    /// Split position as a fraction of total length along the split axis.
100    /// Clamped to [0.05, 0.95].
101    pub ratio: f64,
102    /// Width of the draggable divider strip (perpendicular to the split axis).
103    pub divider_width: f64,
104    /// `true` for top/bottom (Y-axis) split, `false` (default) for
105    /// left/right (X-axis) split.
106    pub vertical: bool,
107
108    hovered: bool,
109    dragging: bool,
110    /// Optional host channel (see [`SplitterRatio`]).
111    ratio_handle: Option<SplitterRatio>,
112}
113
114impl Splitter {
115    /// Horizontal split: `left` | `right`.
116    pub fn new(left: Box<dyn Widget>, right: Box<dyn Widget>) -> Self {
117        Self {
118            bounds: Rect::default(),
119            children: vec![left, right],
120            base: WidgetBase::new(),
121            ratio: 0.5,
122            divider_width: 6.0,
123            vertical: false,
124            hovered: false,
125            dragging: false,
126            ratio_handle: None,
127        }
128    }
129
130    /// Vertical split: `top` (visually upper, higher Y in agg-gui's Y-up
131    /// coords) over `bottom`. `ratio` is the fraction of total height
132    /// allocated to the top pane.
133    pub fn vertical(top: Box<dyn Widget>, bottom: Box<dyn Widget>) -> Self {
134        Self {
135            bounds: Rect::default(),
136            children: vec![top, bottom],
137            base: WidgetBase::new(),
138            ratio: 0.5,
139            divider_width: 6.0,
140            vertical: true,
141            hovered: false,
142            dragging: false,
143            ratio_handle: None,
144        }
145    }
146
147    pub fn with_ratio(mut self, ratio: f64) -> Self {
148        self.set_ratio(ratio);
149        self
150    }
151
152    /// Install a shared [`SplitterRatio`] and adopt its current value.
153    ///
154    /// Call it *after* [`with_ratio`](Self::with_ratio): the handle is
155    /// the authority once installed, which is what lets a host seed the
156    /// default in one place (the handle's constructor) and have a
157    /// restored document overwrite it later.
158    pub fn with_ratio_handle(mut self, handle: SplitterRatio) -> Self {
159        self.ratio = handle.get();
160        self.ratio_handle = Some(handle);
161        self
162    }
163
164    /// Clamp, store, and publish a new divider position. A non-finite
165    /// ratio (an `x / 0` in a caller's own layout maths) leaves the
166    /// divider where it is.
167    fn set_ratio(&mut self, ratio: f64) {
168        self.ratio = clamp_ratio(ratio, self.ratio);
169        if let Some(handle) = &self.ratio_handle {
170            handle.set(self.ratio);
171        }
172    }
173
174    /// Adopt a host-written ratio, if the handle disagrees with ours.
175    /// Called at the top of `layout()`.
176    fn adopt_handle_ratio(&mut self) {
177        if let Some(handle) = &self.ratio_handle {
178            let wanted = handle.get();
179            if wanted != self.ratio {
180                self.ratio = wanted;
181            }
182        }
183    }
184
185    pub fn with_divider_width(mut self, w: f64) -> Self {
186        self.divider_width = w;
187        self
188    }
189
190    pub fn with_margin(mut self, m: Insets) -> Self {
191        self.base.margin = m;
192        self
193    }
194    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
195        self.base.h_anchor = h;
196        self
197    }
198    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
199        self.base.v_anchor = v;
200        self
201    }
202    pub fn with_min_size(mut self, s: Size) -> Self {
203        self.base.min_size = s;
204        self
205    }
206    pub fn with_max_size(mut self, s: Size) -> Self {
207        self.base.max_size = s;
208        self
209    }
210
211    /// Length of the bounds along the splitting axis.
212    fn axis_length(&self) -> f64 {
213        if self.vertical {
214            self.bounds.height
215        } else {
216            self.bounds.width
217        }
218    }
219
220    /// Position of the divider along the splitting axis. For horizontal
221    /// splits this is the X of the divider's left edge (so children[0]
222    /// occupies x in [0, divider_pos]). For vertical splits in Y-up
223    /// coords, children[0] is the top pane — its bottom edge is at
224    /// `axis_length - divider_width - divider_pos_from_bottom` ... see
225    /// the layout / paint / event branches for the worked-out coords.
226    fn divider_pos(&self) -> f64 {
227        (self.axis_length() - self.divider_width) * self.ratio
228    }
229}
230
231impl Widget for Splitter {
232    fn type_name(&self) -> &'static str {
233        "Splitter"
234    }
235    fn bounds(&self) -> Rect {
236        self.bounds
237    }
238    fn set_bounds(&mut self, b: Rect) {
239        self.bounds = b;
240    }
241    fn children(&self) -> &[Box<dyn Widget>] {
242        &self.children
243    }
244    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
245        &mut self.children
246    }
247
248    fn margin(&self) -> Insets {
249        self.base.margin
250    }
251    fn widget_base(&self) -> Option<&WidgetBase> {
252        Some(&self.base)
253    }
254    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
255        Some(&mut self.base)
256    }
257    fn h_anchor(&self) -> HAnchor {
258        self.base.h_anchor
259    }
260    fn v_anchor(&self) -> VAnchor {
261        self.base.v_anchor
262    }
263    fn min_size(&self) -> Size {
264        self.base.min_size
265    }
266    fn max_size(&self) -> Size {
267        self.base.max_size
268    }
269
270    fn hit_test(&self, local_pos: Point) -> bool {
271        // Capture all events during drag, even if cursor leaves bounds.
272        if self.dragging {
273            return true;
274        }
275        let b = self.bounds();
276        local_pos.x >= 0.0
277            && local_pos.x <= b.width
278            && local_pos.y >= 0.0
279            && local_pos.y <= b.height
280    }
281
282    fn layout(&mut self, available: Size) -> Size {
283        self.adopt_handle_ratio();
284        let div = self.divider_width;
285
286        if self.children.len() < 2 {
287            return available;
288        }
289
290        if self.vertical {
291            // Y-up: children[0] = top, children[1] = bottom. ratio is
292            // the fraction of height going to the TOP pane.
293            let top_h = ((available.height - div) * self.ratio).max(0.0);
294            let bot_h = (available.height - div - top_h).max(0.0);
295            let w = available.width;
296
297            // Top pane sits above the divider — its bottom edge is at
298            // bot_h + div, height extends up to bot_h + div + top_h.
299            self.children[0].layout(Size::new(w, top_h));
300            self.children[0].set_bounds(Rect::new(0.0, bot_h + div, w, top_h));
301
302            // Bottom pane sits at y = 0, height bot_h.
303            self.children[1].layout(Size::new(w, bot_h));
304            self.children[1].set_bounds(Rect::new(0.0, 0.0, w, bot_h));
305        } else {
306            let left_w = ((available.width - div) * self.ratio).max(0.0);
307            let right_w = (available.width - div - left_w).max(0.0);
308            let h = available.height;
309
310            self.children[0].layout(Size::new(left_w, h));
311            self.children[0].set_bounds(Rect::new(0.0, 0.0, left_w, h));
312
313            let right_x = left_w + div;
314            self.children[1].layout(Size::new(right_w, h));
315            self.children[1].set_bounds(Rect::new(right_x, 0.0, right_w, h));
316        }
317
318        available
319    }
320
321    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
322        let v = ctx.visuals();
323        let color = if self.dragging {
324            v.accent.with_alpha(0.6)
325        } else if self.hovered {
326            v.text_color.with_alpha(0.15)
327        } else {
328            v.text_color.with_alpha(0.08)
329        };
330
331        let grip_color = if self.hovered || self.dragging {
332            v.accent.with_alpha(0.7)
333        } else {
334            v.text_color.with_alpha(0.25)
335        };
336
337        ctx.set_fill_color(color);
338
339        if self.vertical {
340            // Divider is a horizontal strip at Y = bottom_pane_height.
341            let bot_h = ((self.bounds.height - self.divider_width) * (1.0 - self.ratio)).max(0.0);
342            let div_y = bot_h;
343            let w = self.bounds.width;
344            ctx.begin_path();
345            ctx.rect(0.0, div_y, w, self.divider_width);
346            ctx.fill();
347
348            // Grip dots horizontally centered across the divider.
349            if w > 30.0 {
350                ctx.set_fill_color(grip_color);
351                let cy = div_y + self.divider_width * 0.5;
352                let cx = w * 0.5;
353                for i in -1i32..=1 {
354                    ctx.begin_path();
355                    ctx.circle(cx + i as f64 * 5.0, cy, 1.5);
356                    ctx.fill();
357                }
358            }
359        } else {
360            let div_x = self.divider_pos();
361            let h = self.bounds.height;
362            ctx.begin_path();
363            ctx.rect(div_x, 0.0, self.divider_width, h);
364            ctx.fill();
365
366            if h > 30.0 {
367                ctx.set_fill_color(grip_color);
368                let cx = div_x + self.divider_width * 0.5;
369                let cy = h * 0.5;
370                for i in -1i32..=1 {
371                    ctx.begin_path();
372                    ctx.circle(cx, cy + i as f64 * 5.0, 1.5);
373                    ctx.fill();
374                }
375            }
376        }
377    }
378
379    fn on_event(&mut self, event: &Event) -> EventResult {
380        if self.vertical {
381            let div = self.divider_width;
382            let total = self.bounds.height;
383            // Bottom pane height; divider's bottom edge is at this Y.
384            let bot_h = ((total - div) * (1.0 - self.ratio)).max(0.0);
385            let div_y = bot_h;
386            let div_end = div_y + div;
387
388            match event {
389                Event::MouseMove { pos } => {
390                    let over_div = pos.y >= div_y - 2.0 && pos.y <= div_end + 2.0;
391                    let was = self.hovered;
392                    self.hovered = over_div;
393                    if self.dragging {
394                        if total > div {
395                            // ratio is fraction going to top — that's the
396                            // upper portion above the divider midline.
397                            // Convert pos.y (Y-up) into top fraction.
398                            let div_mid = pos.y;
399                            let top_h = (total - div_mid).max(0.0);
400                            self.set_ratio(top_h / total);
401                        }
402                        crate::animation::request_draw();
403                        EventResult::Consumed
404                    } else {
405                        if was != self.hovered {
406                            crate::animation::request_draw();
407                            return EventResult::Consumed;
408                        }
409                        EventResult::Ignored
410                    }
411                }
412                Event::MouseDown {
413                    pos,
414                    button: MouseButton::Left,
415                    ..
416                } => {
417                    if pos.y >= div_y - 2.0 && pos.y <= div_end + 2.0 {
418                        self.dragging = true;
419                        EventResult::Consumed
420                    } else {
421                        EventResult::Ignored
422                    }
423                }
424                Event::MouseUp {
425                    button: MouseButton::Left,
426                    ..
427                } => {
428                    let was_dragging = self.dragging;
429                    self.dragging = false;
430                    if was_dragging {
431                        crate::animation::request_draw();
432                        EventResult::Consumed
433                    } else {
434                        EventResult::Ignored
435                    }
436                }
437                _ => EventResult::Ignored,
438            }
439        } else {
440            let div_x = self.divider_pos();
441            let div_end = div_x + self.divider_width;
442
443            match event {
444                Event::MouseMove { pos } => {
445                    let over_div = pos.x >= div_x - 2.0 && pos.x <= div_end + 2.0;
446                    let was = self.hovered;
447                    self.hovered = over_div;
448                    if self.dragging {
449                        let total = self.bounds.width;
450                        if total > self.divider_width {
451                            self.set_ratio(pos.x / total);
452                        }
453                        crate::animation::request_draw();
454                        EventResult::Consumed
455                    } else {
456                        if was != self.hovered {
457                            crate::animation::request_draw();
458                            return EventResult::Consumed;
459                        }
460                        EventResult::Ignored
461                    }
462                }
463                Event::MouseDown {
464                    pos,
465                    button: MouseButton::Left,
466                    ..
467                } => {
468                    if pos.x >= div_x - 2.0 && pos.x <= div_end + 2.0 {
469                        self.dragging = true;
470                        EventResult::Consumed
471                    } else {
472                        EventResult::Ignored
473                    }
474                }
475                Event::MouseUp {
476                    button: MouseButton::Left,
477                    ..
478                } => {
479                    let was_dragging = self.dragging;
480                    self.dragging = false;
481                    if was_dragging {
482                        crate::animation::request_draw();
483                        EventResult::Consumed
484                    } else {
485                        EventResult::Ignored
486                    }
487                }
488                _ => EventResult::Ignored,
489            }
490        }
491    }
492}