Skip to main content

tui_lipan/widgets/splitter/
mod.rs

1mod layout;
2mod node;
3mod reconcile;
4
5pub(crate) use layout::measure_splitter;
6pub(crate) use node::SplitterNode;
7pub(crate) use reconcile::{SplitterReconcile, reconcile_splitter};
8
9use std::sync::Arc;
10
11use crate::callback::Callback;
12use crate::core::element::{Element, ElementKind};
13use crate::style::{Length, Style};
14use crate::widgets::Orientation;
15
16/// Where a [`Splitter`] places its drag handles relative to pane borders.
17///
18/// This is independent of whether neighboring [`Frame`](crate::widgets::Frame)s
19/// merge their borders (`Frame::join_frame`). Border merging is a purely visual
20/// choice owned by the frames; the handle mode only decides where the splitter's
21/// drag target lives and how thick it is.
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
23pub enum SplitterHandleMode {
24    /// Reserve a gutter between panes and draw the handle glyph there.
25    #[default]
26    Gutter,
27    /// Drop the gutter and ride the pane border seam: the border cells between
28    /// panes become the drag target.
29    ///
30    /// Thickness follows the borders actually present:
31    /// - neighbors that merge their borders share one wall → a 1-cell handle,
32    /// - neighbors that keep separate borders expose two adjacent walls → a
33    ///   2-cell handle so both are grabbed together,
34    /// - borderless neighbors fall back to a synthetic 1-cell handle on the seam.
35    Border,
36}
37
38/// Emitted by splitter resize callbacks with normalized pane weights.
39#[derive(Clone, Debug)]
40pub struct SplitterResizeEvent {
41    /// Matches [`Splitter::split_id`] when set.
42    pub split_id: Option<Arc<str>>,
43    /// Normalized pane weights (sum ≈ 1).
44    pub weights: Vec<f32>,
45}
46
47/// A resizable splitter container with draggable handles.
48#[derive(Clone)]
49pub struct Splitter {
50    pub(crate) orientation: Orientation,
51    pub(crate) children: Vec<Element>,
52    pub(crate) weights: Vec<f32>,
53    pub(crate) weights_nonce: u32,
54    pub(crate) split_id: Option<Arc<str>>,
55    pub(crate) on_resize_live: Option<Callback<SplitterResizeEvent>>,
56    pub(crate) on_resize: Option<Callback<SplitterResizeEvent>>,
57    pub(crate) min_size: u16,
58    pub(crate) handle_size: u16,
59    pub(crate) handle_mode: SplitterHandleMode,
60    pub(crate) handle_symbol: char,
61    pub(crate) handle_style: Style,
62    pub(crate) handle_hover_style: Style,
63    pub(crate) handle_active_style: Style,
64    pub(crate) width: Length,
65    pub(crate) height: Length,
66}
67
68impl Splitter {
69    /// Create a splitter with a specific handle orientation.
70    pub fn new(orientation: Orientation) -> Self {
71        match orientation {
72            Orientation::Horizontal => Self::horizontal(),
73            Orientation::Vertical => Self::vertical(),
74        }
75    }
76
77    /// Create a horizontal splitter (handles are horizontal; panes stacked vertically).
78    pub fn horizontal() -> Self {
79        Self {
80            orientation: Orientation::Horizontal,
81            children: Vec::new(),
82            weights: Vec::new(),
83            weights_nonce: 0,
84            split_id: None,
85            on_resize_live: None,
86            on_resize: None,
87            min_size: 3,
88            handle_size: 1,
89            handle_mode: SplitterHandleMode::Gutter,
90            handle_symbol: '─',
91            handle_style: Style::default(),
92            handle_hover_style: Style::default(),
93            handle_active_style: Style::default(),
94            width: Length::Flex(1),
95            height: Length::Flex(1),
96        }
97    }
98
99    /// Create a vertical splitter (handles are vertical; panes laid out horizontally).
100    pub fn vertical() -> Self {
101        Self {
102            orientation: Orientation::Vertical,
103            children: Vec::new(),
104            weights: Vec::new(),
105            weights_nonce: 0,
106            split_id: None,
107            on_resize_live: None,
108            on_resize: None,
109            min_size: 3,
110            handle_size: 1,
111            handle_mode: SplitterHandleMode::Gutter,
112            handle_symbol: '│',
113            handle_style: Style::default(),
114            handle_hover_style: Style::default(),
115            handle_active_style: Style::default(),
116            width: Length::Flex(1),
117            height: Length::Flex(1),
118        }
119    }
120
121    /// Add a child pane.
122    pub fn child(mut self, child: impl Into<Element>) -> Self {
123        self.children.push(child.into());
124        self
125    }
126
127    /// Set handle orientation.
128    pub fn orientation(mut self, orientation: Orientation) -> Self {
129        if self.orientation != orientation {
130            self.orientation = orientation;
131            self.handle_symbol = match orientation {
132                Orientation::Horizontal => '─',
133                Orientation::Vertical => '│',
134            };
135        }
136        self
137    }
138
139    /// Replace all children, discarding anything already added with
140    /// [`child`](Self::child). Call `child` repeatedly to append instead.
141    pub fn children<I>(mut self, children: I) -> Self
142    where
143        I: IntoIterator<Item = Element>,
144    {
145        self.children = children.into_iter().collect();
146        self
147    }
148
149    /// Set pane weights (length must match number of panes).
150    pub fn weights(mut self, weights: impl Into<Vec<f32>>) -> Self {
151        self.weights = weights.into();
152        self
153    }
154
155    /// Bump when pane weights should override the last reconciled split.
156    pub fn weights_nonce(mut self, nonce: u32) -> Self {
157        self.weights_nonce = nonce;
158        self
159    }
160
161    /// Optional id included in [`SplitterResizeEvent`] after a drag.
162    pub fn split_id(mut self, id: impl Into<Arc<str>>) -> Self {
163        self.split_id = Some(id.into());
164        self
165    }
166
167    /// Called while a drag resize changes pane weights.
168    pub fn on_resize_live(mut self, cb: Callback<SplitterResizeEvent>) -> Self {
169        self.on_resize_live = Some(cb);
170        self
171    }
172
173    /// Called when a drag resize finishes with the final normalized pane weights.
174    pub fn on_resize(mut self, cb: Callback<SplitterResizeEvent>) -> Self {
175        self.on_resize = Some(cb);
176        self
177    }
178
179    /// Set minimum size per pane (in cells).
180    pub fn min_size(mut self, min_size: u16) -> Self {
181        self.min_size = min_size;
182        self
183    }
184
185    /// Set handle thickness (in cells).
186    pub fn handle_size(mut self, size: u16) -> Self {
187        self.handle_size = size.max(1);
188        self
189    }
190
191    /// Set how handles are placed relative to pane borders.
192    ///
193    /// [`SplitterHandleMode::Gutter`] (default) reserves a gutter and draws the
194    /// handle glyph there. [`SplitterHandleMode::Border`] drops the gutter and
195    /// rides the pane border seam, hit-testing the border cells between panes as
196    /// a single handle. This is orthogonal to whether the neighboring frames
197    /// merge their borders (`Frame::join_frame`): separate borders are grabbed
198    /// together as a 2-cell handle, a merged border as a 1-cell handle.
199    pub fn handle_mode(mut self, mode: SplitterHandleMode) -> Self {
200        self.handle_mode = mode;
201        self
202    }
203
204    /// Set handle symbol.
205    pub fn handle_symbol(mut self, symbol: char) -> Self {
206        self.handle_symbol = symbol;
207        self
208    }
209
210    /// Set handle style.
211    pub fn handle_style(mut self, style: Style) -> Self {
212        self.handle_style = style;
213        self
214    }
215
216    /// Set handle hover style.
217    pub fn handle_hover_style(mut self, style: Style) -> Self {
218        self.handle_hover_style = style;
219        self
220    }
221
222    /// Set handle active style (while dragging).
223    pub fn handle_active_style(mut self, style: Style) -> Self {
224        self.handle_active_style = style;
225        self
226    }
227
228    /// Override requested width.
229    pub fn width(mut self, width: Length) -> Self {
230        self.width = width;
231        self
232    }
233
234    /// Override requested height.
235    pub fn height(mut self, height: Length) -> Self {
236        self.height = height;
237        self
238    }
239}
240
241impl From<Splitter> for Element {
242    fn from(value: Splitter) -> Self {
243        Element::new(ElementKind::Splitter(value))
244    }
245}
246
247impl crate::layout::hash::LayoutHash for Splitter {
248    fn layout_hash(
249        &self,
250        hasher: &mut impl std::hash::Hasher,
251        recurse: &dyn Fn(&Element) -> Option<u64>,
252    ) -> Option<()> {
253        use std::hash::Hash;
254        self.width.hash(hasher);
255        self.height.hash(hasher);
256        self.orientation.hash(hasher);
257        self.min_size.hash(hasher);
258        self.handle_size.hash(hasher);
259        self.handle_mode.hash(hasher);
260        self.handle_symbol.hash(hasher);
261        self.weights.len().hash(hasher);
262        for weight in &self.weights {
263            weight.to_bits().hash(hasher);
264        }
265        self.weights_nonce.hash(hasher);
266
267        let needs_content =
268            matches!(self.width, Length::Auto) || matches!(self.height, Length::Auto);
269        if needs_content {
270            crate::layout::hash::hash_children(&self.children, hasher, recurse)?;
271        }
272        Some(())
273    }
274}
275
276impl Default for Splitter {
277    fn default() -> Self {
278        Self::horizontal()
279    }
280}
281
282pub(crate) fn resolve_weights(explicit: &[f32], previous: &[f32], len: usize) -> Vec<f32> {
283    let mut weights = if previous.len() == len && !previous.is_empty() {
284        previous.to_vec()
285    } else if explicit.len() == len && !explicit.is_empty() {
286        explicit.to_vec()
287    } else {
288        vec![1.0; len]
289    };
290
291    for weight in &mut weights {
292        if *weight < 0.0 {
293            *weight = 0.0;
294        }
295    }
296
297    let sum: f32 = weights.iter().sum();
298    if sum <= f32::EPSILON {
299        return vec![1.0; len];
300    }
301
302    for weight in &mut weights {
303        *weight /= sum;
304    }
305
306    weights
307}
308
309pub(crate) fn sizes_from_weights(weights: &[f32], available: u16, min_size: u16) -> Vec<u16> {
310    let count = weights.len();
311    if count == 0 {
312        return Vec::new();
313    }
314    if available == 0 {
315        return vec![0; count];
316    }
317
318    let total_weight: f32 = weights.iter().sum();
319    let total_weight = if total_weight <= f32::EPSILON {
320        count as f32
321    } else {
322        total_weight
323    };
324
325    let mut sizes = Vec::with_capacity(count);
326    let mut fractions = Vec::with_capacity(count);
327    for weight in weights {
328        let exact = (available as f32) * (*weight / total_weight);
329        let floored = exact.floor();
330        sizes.push(floored as u16);
331        fractions.push(exact - floored);
332    }
333
334    // Largest-remainder apportionment: each leftover column goes to the pane
335    // with the biggest dropped fraction, ties resolving to the lower index.
336    //
337    // Handing them out in plain index order instead would park a column on the
338    // leftmost pane that a later pane actually earned. Because a drag round
339    // trips through sizes -> weights -> sizes every frame, that misplacement
340    // reappears each tick and a pane the drag never touched visibly bounces by
341    // a column. Largest remainder makes the round trip exact, so panes only
342    // move when the drag moves them.
343    let mut order: Vec<usize> = (0..count).collect();
344    order.sort_by(|a, b| {
345        fractions[*b]
346            .partial_cmp(&fractions[*a])
347            .unwrap_or(std::cmp::Ordering::Equal)
348            .then(a.cmp(b))
349    });
350
351    let used: u16 = sizes.iter().sum();
352    let mut remaining = available.saturating_sub(used) as usize;
353    let mut idx = 0usize;
354    while remaining > 0 {
355        let target = order[idx % count];
356        sizes[target] = sizes[target].saturating_add(1);
357        remaining -= 1;
358        idx += 1;
359    }
360
361    if min_size == 0 {
362        return sizes;
363    }
364
365    let required = min_size.saturating_mul(count as u16);
366    if available < required {
367        return sizes;
368    }
369
370    loop {
371        let mut updated = false;
372        for idx in 0..count {
373            if sizes[idx] < min_size {
374                let deficit = min_size - sizes[idx];
375                sizes[idx] = min_size;
376                let mut remaining = deficit;
377                for size in sizes.iter_mut().take(count) {
378                    if remaining == 0 {
379                        break;
380                    }
381                    if *size > min_size {
382                        let take = (*size - min_size).min(remaining);
383                        *size = size.saturating_sub(take);
384                        remaining = remaining.saturating_sub(take);
385                    }
386                }
387                updated = true;
388                break;
389            }
390        }
391        if !updated {
392            break;
393        }
394    }
395
396    sizes
397}
398
399pub(crate) fn sizes_to_weights(sizes: &[u16]) -> Vec<f32> {
400    let total: u16 = sizes.iter().sum();
401    if total == 0 {
402        return vec![1.0; sizes.len()];
403    }
404    sizes
405        .iter()
406        .map(|size| (*size as f32) / (total as f32))
407        .collect()
408}
409
410#[cfg(test)]
411mod size_tests {
412    use super::{sizes_from_weights, sizes_to_weights};
413
414    /// A drag stores exact column counts, publishes them as weights, and the
415    /// next layout turns them back into columns. That round trip has to be the
416    /// identity, or panes drift by a column every frame while dragging.
417    #[test]
418    fn sizes_survive_a_round_trip_through_weights() {
419        let cases: &[&[u16]] = &[
420            &[39, 39, 79],
421            &[40, 38, 79],
422            &[1, 1, 155],
423            &[52, 52, 53],
424            &[10, 20, 30, 40],
425            &[7, 11, 13, 17, 19],
426            &[100, 1, 1],
427            &[3, 3],
428        ];
429
430        for sizes in cases {
431            let available: u16 = sizes.iter().sum();
432            let weights = sizes_to_weights(sizes);
433            let restored = sizes_from_weights(&weights, available, 0);
434            assert_eq!(
435                restored, *sizes,
436                "round trip changed {sizes:?} (available {available})"
437            );
438        }
439    }
440
441    /// The leftover column belongs to the pane that earned it, not to pane 0.
442    #[test]
443    fn leftover_columns_follow_the_largest_fraction() {
444        // Exact shares are 3.33, 3.33, 3.33 -> one leftover column, and with
445        // equal fractions the lowest index wins.
446        assert_eq!(sizes_from_weights(&[1.0, 1.0, 1.0], 10, 0), vec![4, 3, 3]);
447
448        // Exact shares are 1.0, 4.0, 5.0 - nothing is dropped, so nothing moves.
449        assert_eq!(sizes_from_weights(&[0.1, 0.4, 0.5], 10, 0), vec![1, 4, 5]);
450
451        // Exact shares are 0.9, 4.5, 4.6: floors 0, 4, 4 leave two columns for
452        // the two largest fractions (.9 and .6), not for the first two panes.
453        assert_eq!(
454            sizes_from_weights(&[0.09, 0.45, 0.46], 10, 0),
455            vec![1, 4, 5]
456        );
457    }
458
459    #[test]
460    fn sizes_always_fill_the_available_space() {
461        for available in [1u16, 7, 13, 80, 157, 999] {
462            for weights in [
463                vec![1.0, 1.0, 2.0],
464                vec![0.3333, 0.3333, 0.3334],
465                vec![0.01, 0.98, 0.01],
466            ] {
467                let sizes = sizes_from_weights(&weights, available, 0);
468                assert_eq!(
469                    sizes.iter().sum::<u16>(),
470                    available,
471                    "weights {weights:?} at {available} left a gap"
472                );
473            }
474        }
475    }
476}