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}