Skip to main content

slt/context/widgets_display/
split.rs

1// Split-pane container widgets — `split_pane` (horizontal) and
2// `vsplit_pane` (vertical). Each renders two panes separated by a 1-cell
3// drag handle. Mouse drag and arrow-key adjustment update the stored ratio.
4//
5// Introduced in v0.20.0 (#223).
6
7use super::*;
8
9/// Keyboard step applied to `state.ratio` per arrow-key press when the handle is focused.
10const KEY_STEP: f64 = 0.05;
11
12/// Scale factor applied to the `[0.0, 1.0]` ratio to produce a `u16` flexbox
13/// `grow` weight. 1000 gives ~0.1% precision in pane sizes — finer than any
14/// terminal cell can render at typical widths, while staying well below
15/// `u16::MAX` so the two-pane sum can never overflow.
16const RATIO_GROW_SCALE: f64 = 1000.0;
17
18/// Direction of the split. Internal helper — public API is the `split_pane`
19/// (horizontal) / `vsplit_pane` (vertical) entry points.
20#[derive(Debug, Clone, Copy)]
21enum SplitOrientation {
22    /// Horizontal split: left | handle | right.
23    Horizontal,
24    /// Vertical split: top / handle / bottom.
25    Vertical,
26}
27
28impl Context {
29    /// Horizontal split container with a draggable handle.
30    ///
31    /// Renders `left | │ | right`, where `│` is a 1-cell wide drag handle.
32    /// The handle is focusable; arrow keys (`Left`/`Right`) adjust the
33    /// ratio by 5% per press, and dragging the handle with the mouse
34    /// updates the ratio proportionally to the cursor's x position.
35    ///
36    /// # Example
37    ///
38    /// ```no_run
39    /// # use slt::SplitPaneState;
40    /// # let mut split = SplitPaneState::new(0.5);
41    /// # slt::run(|ui: &mut slt::Context| {
42    /// ui.split_pane(
43    ///     &mut split,
44    ///     |ui| { ui.text("left pane"); },
45    ///     |ui| { ui.text("right pane"); },
46    /// );
47    /// # });
48    /// ```
49    pub fn split_pane<L, R>(
50        &mut self,
51        state: &mut SplitPaneState,
52        left: L,
53        right: R,
54    ) -> SplitPaneResponse
55    where
56        L: FnOnce(&mut Context),
57        R: FnOnce(&mut Context),
58    {
59        self.split_pane_impl(SplitOrientation::Horizontal, state, left, right)
60    }
61
62    /// Vertical split container with a draggable handle.
63    ///
64    /// Mirrors [`Self::split_pane`] but stacks the panes vertically with a
65    /// 1-row horizontal divider (`─`) between them. The handle is focusable;
66    /// arrow keys (`Up`/`Down`) adjust the ratio by 5% per press.
67    ///
68    /// # Example
69    ///
70    /// ```no_run
71    /// # use slt::SplitPaneState;
72    /// # let mut split = SplitPaneState::new(0.5);
73    /// # slt::run(|ui: &mut slt::Context| {
74    /// ui.vsplit_pane(
75    ///     &mut split,
76    ///     |ui| { ui.text("top pane"); },
77    ///     |ui| { ui.text("bottom pane"); },
78    /// );
79    /// # });
80    /// ```
81    pub fn vsplit_pane<T, B>(
82        &mut self,
83        state: &mut SplitPaneState,
84        top: T,
85        bottom: B,
86    ) -> SplitPaneResponse
87    where
88        T: FnOnce(&mut Context),
89        B: FnOnce(&mut Context),
90    {
91        self.split_pane_impl(SplitOrientation::Vertical, state, top, bottom)
92    }
93
94    fn split_pane_impl<A, B>(
95        &mut self,
96        orientation: SplitOrientation,
97        state: &mut SplitPaneState,
98        first: A,
99        second: B,
100    ) -> SplitPaneResponse
101    where
102        A: FnOnce(&mut Context),
103        B: FnOnce(&mut Context),
104    {
105        // Reserve the focusable slot for the handle BEFORE the panes so
106        // tab order stays stable across frames regardless of pane content.
107        let handle_focused = self.register_focusable();
108
109        // Process keyboard input (arrow keys) when the handle is focused.
110        if handle_focused {
111            self.consume_split_pane_keys(state, orientation);
112        }
113
114        // Process mouse drag against the previous-frame handle rect.
115        // The handle interaction id is the next slot to be allocated when
116        // we render the splitter cell below; record it now so we can match
117        // mouse events with the prior frame's rect.
118        let handle_interaction_id = self.rollback.interaction_count;
119        self.consume_split_pane_drag(state, handle_interaction_id, orientation);
120
121        let theme = self.theme;
122        let ratio = state.ratio.clamp(state.min_ratio, 1.0 - state.min_ratio);
123        let left_grow = ((ratio * RATIO_GROW_SCALE).round() as u16).max(1);
124        let right_grow = (((1.0 - ratio) * RATIO_GROW_SCALE).round() as u16).max(1);
125
126        let drag_active = state.dragging;
127
128        let response = match orientation {
129            SplitOrientation::Horizontal => self.row(|ui| {
130                let _ = ui.container().grow(left_grow).col(first);
131                let handle_color = if handle_focused || drag_active {
132                    theme.accent
133                } else {
134                    theme.border
135                };
136                let _ = ui.container().w(1).grow(0).col(|ui| {
137                    ui.styled("│", Style::new().fg(handle_color));
138                });
139                let _ = ui.container().grow(right_grow).col(second);
140            }),
141            SplitOrientation::Vertical => self.col(|ui| {
142                let _ = ui.container().grow(left_grow).col(first);
143                let handle_color = if handle_focused || drag_active {
144                    theme.accent
145                } else {
146                    theme.border
147                };
148                let _ = ui.container().h(1).grow(0).col(|ui| {
149                    ui.styled("─", Style::new().fg(handle_color));
150                });
151                let _ = ui.container().grow(right_grow).col(second);
152            }),
153        };
154
155        SplitPaneResponse {
156            response,
157            ratio,
158            drag_active,
159        }
160    }
161
162    fn consume_split_pane_keys(
163        &mut self,
164        state: &mut SplitPaneState,
165        orientation: SplitOrientation,
166    ) {
167        // Hoist the orientation-dependent key codes outside the per-event
168        // loop so the match runs once per call, not once per pending key.
169        let (neg, pos) = match orientation {
170            SplitOrientation::Horizontal => (KeyCode::Left, KeyCode::Right),
171            SplitOrientation::Vertical => (KeyCode::Up, KeyCode::Down),
172        };
173        let mut consumed: Vec<usize> = Vec::new();
174        let mut delta = 0.0_f64;
175        for (i, key) in self.available_key_presses() {
176            if key.code == neg {
177                delta -= KEY_STEP;
178                consumed.push(i);
179            } else if key.code == pos {
180                delta += KEY_STEP;
181                consumed.push(i);
182            }
183        }
184        // Use abs/EPSILON instead of `!= 0.0` for clarity; behavior is
185        // unchanged for the realistic input range (delta is a sum of exact
186        // 0.05 increments, so any non-zero result is well above EPSILON).
187        if delta.abs() > f64::EPSILON {
188            state.set_ratio(state.ratio + delta);
189        }
190        self.consume_indices(consumed);
191    }
192
193    fn consume_split_pane_drag(
194        &mut self,
195        state: &mut SplitPaneState,
196        handle_interaction_id: usize,
197        orientation: SplitOrientation,
198    ) {
199        // The container that owns the panes has its own interaction id
200        // allocated by `row()` / `col()` later in this method. To compute the
201        // ratio we need the bounds of THAT container, but at this point in
202        // execution we haven't pushed it yet. Instead, we track drag activity
203        // against the handle's own rect from the previous frame and use the
204        // larger axis bound from the previous handle position to anchor the
205        // drag math. Concretely:
206        //
207        //   - On Mouse::Down inside the handle rect → enter drag mode.
208        //   - While dragging, update ratio based on the cursor's position
209        //     within the previous outer container rect (interaction_id - 1
210        //     for the row/col that hosts the handle).
211        //   - On Mouse::Up → exit drag mode.
212        //
213        // The container's interaction id is allocated AFTER the handle, but
214        // the previous-frame `prev_hit_map` already has both. We resolve the
215        // outer container by `handle_interaction_id - 1` — the splitter ran
216        // last frame too and the slots are stable.
217        let outer_id = handle_interaction_id.saturating_sub(1);
218        let outer_rect = self.prev_hit_map.get(outer_id).copied();
219        let handle_rect = self.prev_hit_map.get(handle_interaction_id).copied();
220
221        let mut consumed: Vec<usize> = Vec::new();
222        let events: Vec<(usize, crate::event::MouseEvent)> = self
223            .events
224            .iter()
225            .enumerate()
226            .filter_map(|(i, e)| match e {
227                Event::Mouse(m) if !self.consumed[i] => Some((i, m.clone())),
228                _ => None,
229            })
230            .collect();
231
232        for (i, mouse) in events {
233            match mouse.kind {
234                MouseKind::Down(MouseButton::Left) => {
235                    if let Some(rect) = handle_rect {
236                        if rect.width > 0
237                            && mouse.x >= rect.x
238                            && mouse.x < rect.right()
239                            && mouse.y >= rect.y
240                            && mouse.y < rect.bottom()
241                        {
242                            state.dragging = true;
243                            consumed.push(i);
244                        }
245                    }
246                }
247                MouseKind::Drag(MouseButton::Left) if state.dragging => {
248                    if let Some(outer) = outer_rect {
249                        let new_ratio = match orientation {
250                            SplitOrientation::Horizontal => {
251                                if outer.width <= 1 {
252                                    state.ratio
253                                } else {
254                                    let rel = mouse
255                                        .x
256                                        .saturating_sub(outer.x)
257                                        .min(outer.width.saturating_sub(1));
258                                    f64::from(rel) / f64::from(outer.width)
259                                }
260                            }
261                            SplitOrientation::Vertical => {
262                                if outer.height <= 1 {
263                                    state.ratio
264                                } else {
265                                    let rel = mouse
266                                        .y
267                                        .saturating_sub(outer.y)
268                                        .min(outer.height.saturating_sub(1));
269                                    f64::from(rel) / f64::from(outer.height)
270                                }
271                            }
272                        };
273                        state.set_ratio(new_ratio);
274                    }
275                    consumed.push(i);
276                }
277                MouseKind::Up(MouseButton::Left) if state.dragging => {
278                    state.dragging = false;
279                    consumed.push(i);
280                }
281                _ => {}
282            }
283        }
284        self.consume_indices(consumed);
285    }
286}