Skip to main content

agg_gui/widgets/
splitter.rs

1//! `Splitter` — draggable divider between two side-by-side children.
2//!
3//! Phase 5: horizontal split only (left panel | right panel).
4
5use crate::color::Color;
6use crate::event::{Event, EventResult, MouseButton};
7use crate::geometry::{Point, Rect, Size};
8use crate::draw_ctx::DrawCtx;
9use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
10use crate::widget::Widget;
11
12/// A draggable divider that splits its two children horizontally.
13///
14/// `children[0]` = left panel, `children[1]` = right panel.
15pub struct Splitter {
16    bounds: Rect,
17    children: Vec<Box<dyn Widget>>,  // exactly 2
18    base: WidgetBase,
19    /// Split position as a fraction of total width. Clamped to [0.05, 0.95].
20    pub ratio: f64,
21    /// Width of the draggable divider strip.
22    pub divider_width: f64,
23
24    hovered: bool,
25    dragging: bool,
26}
27
28impl Splitter {
29    pub fn new(left: Box<dyn Widget>, right: Box<dyn Widget>) -> Self {
30        Self {
31            bounds: Rect::default(),
32            children: vec![left, right],
33            base: WidgetBase::new(),
34            ratio: 0.5,
35            divider_width: 6.0,
36            hovered: false,
37            dragging: false,
38        }
39    }
40
41    pub fn with_ratio(mut self, ratio: f64) -> Self {
42        self.ratio = ratio.clamp(0.05, 0.95);
43        self
44    }
45
46    pub fn with_divider_width(mut self, w: f64) -> Self {
47        self.divider_width = w;
48        self
49    }
50
51    pub fn with_margin(mut self, m: Insets)    -> Self { self.base.margin   = m; self }
52    pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
53    pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
54    pub fn with_min_size(mut self, s: Size)    -> Self { self.base.min_size = s; self }
55    pub fn with_max_size(mut self, s: Size)    -> Self { self.base.max_size = s; self }
56
57    fn divider_x(&self) -> f64 {
58        (self.bounds.width - self.divider_width) * self.ratio
59    }
60}
61
62impl Widget for Splitter {
63    fn type_name(&self) -> &'static str { "Splitter" }
64    fn bounds(&self) -> Rect { self.bounds }
65    fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
66    fn children(&self) -> &[Box<dyn Widget>] { &self.children }
67    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
68
69    fn margin(&self)   -> Insets  { self.base.margin }
70    fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
71    fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
72    fn min_size(&self) -> Size    { self.base.min_size }
73    fn max_size(&self) -> Size    { self.base.max_size }
74
75    fn hit_test(&self, local_pos: Point) -> bool {
76        // Capture all events during drag, even if cursor leaves bounds.
77        if self.dragging { return true; }
78        let b = self.bounds();
79        local_pos.x >= 0.0 && local_pos.x <= b.width
80            && local_pos.y >= 0.0 && local_pos.y <= b.height
81    }
82
83    fn layout(&mut self, available: Size) -> Size {
84        let div = self.divider_width;
85        let left_w = ((available.width - div) * self.ratio).max(0.0);
86        let right_w = (available.width - div - left_w).max(0.0);
87        let h = available.height;
88
89        if self.children.len() >= 2 {
90            self.children[0].layout(Size::new(left_w, h));
91            self.children[0].set_bounds(Rect::new(0.0, 0.0, left_w, h));
92
93            let right_x = left_w + div;
94            self.children[1].layout(Size::new(right_w, h));
95            self.children[1].set_bounds(Rect::new(right_x, 0.0, right_w, h));
96        }
97
98        available
99    }
100
101    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
102        let div_x = self.divider_x();
103        let h = self.bounds.height;
104
105        let color = if self.dragging {
106            Color::rgba(0.22, 0.45, 0.88, 0.6)
107        } else if self.hovered {
108            Color::rgba(0.0, 0.0, 0.0, 0.15)
109        } else {
110            Color::rgba(0.0, 0.0, 0.0, 0.08)
111        };
112        ctx.set_fill_color(color);
113        ctx.begin_path();
114        ctx.rect(div_x, 0.0, self.divider_width, h);
115        ctx.fill();
116
117        // Grip dots in the center of the divider
118        if h > 30.0 {
119            let grip_color = if self.hovered || self.dragging {
120                Color::rgba(0.22, 0.45, 0.88, 0.7)
121            } else {
122                Color::rgba(0.0, 0.0, 0.0, 0.25)
123            };
124            ctx.set_fill_color(grip_color);
125            let cx = div_x + self.divider_width * 0.5;
126            let cy = h * 0.5;
127            for i in -1i32..=1 {
128                ctx.begin_path();
129                ctx.circle(cx, cy + i as f64 * 5.0, 1.5);
130                ctx.fill();
131            }
132        }
133    }
134
135    fn on_event(&mut self, event: &Event) -> EventResult {
136        let div_x = self.divider_x();
137        let div_end = div_x + self.divider_width;
138
139        match event {
140            Event::MouseMove { pos } => {
141                let over_div = pos.x >= div_x - 2.0 && pos.x <= div_end + 2.0;
142                let was = self.hovered;
143                self.hovered = over_div;
144                if self.dragging {
145                    let total = self.bounds.width;
146                    if total > self.divider_width {
147                        self.ratio = (pos.x / total).clamp(0.05, 0.95);
148                    }
149                    crate::animation::request_tick();
150                    EventResult::Consumed
151                } else {
152                    if was != self.hovered { crate::animation::request_tick(); }
153                    EventResult::Ignored
154                }
155            }
156            Event::MouseDown { pos, button: MouseButton::Left, .. } => {
157                if pos.x >= div_x - 2.0 && pos.x <= div_end + 2.0 {
158                    self.dragging = true;
159                    // No tick: `dragging = true` produces no immediate
160                    // visible change.  Subsequent MouseMove deltas will
161                    // tick as the split ratio actually shifts.
162                    EventResult::Consumed
163                } else {
164                    EventResult::Ignored
165                }
166            }
167            Event::MouseUp { button: MouseButton::Left, .. } => {
168                let was_dragging = self.dragging;
169                self.dragging = false;
170                if was_dragging {
171                    crate::animation::request_tick();
172                    EventResult::Consumed
173                } else {
174                    EventResult::Ignored
175                }
176            }
177            _ => EventResult::Ignored,
178        }
179    }
180}