Skip to main content

sequoia_layout/
dock.rs

1//! Docking sides, thickness resolution, and preview rectangles.
2
3use sequoia_core::{Length, Rect};
4
5use crate::container::Axis;
6
7/// Side of a container a child is docked to.
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9pub enum Dock {
10    /// Dock to the left edge.
11    Left,
12    /// Dock to the right edge.
13    Right,
14    /// Dock to the top edge.
15    Top,
16    /// Dock to the bottom edge.
17    Bottom,
18    /// Fill the remaining space.
19    #[default]
20    Fill,
21}
22
23impl Dock {
24    /// Returns `true` for [`Dock::Left`] and [`Dock::Right`].
25    pub const fn is_horizontal(self) -> bool {
26        matches!(self, Self::Left | Self::Right)
27    }
28
29    /// Returns `true` for [`Dock::Top`] and [`Dock::Bottom`].
30    pub const fn is_vertical(self) -> bool {
31        matches!(self, Self::Top | Self::Bottom)
32    }
33
34    /// Returns `true` for every side except [`Dock::Fill`].
35    pub const fn is_side(self) -> bool {
36        !matches!(self, Self::Fill)
37    }
38}
39
40/// Docking configuration for a set of children.
41#[derive(Clone, Debug, PartialEq)]
42pub struct DockStyle {
43    /// Dock side for each child, by index.
44    pub docks: Vec<Dock>,
45    /// Gap between docked children.
46    pub gap: f32,
47    /// Whether the last child should fill the remaining space.
48    pub last_child_fill: bool,
49    /// Lay out children in declaration order instead of grouping sides first.
50    pub ordered: bool,
51}
52
53impl Default for DockStyle {
54    fn default() -> Self {
55        Self {
56            docks: Vec::new(),
57            gap: 0.0,
58            last_child_fill: false,
59            ordered: false,
60        }
61    }
62}
63
64impl DockStyle {
65    /// Creates a style from the given dock sides.
66    pub fn new(docks: impl IntoIterator<Item = Dock>) -> Self {
67        Self {
68            docks: docks.into_iter().collect(),
69            gap: 0.0,
70            last_child_fill: false,
71            ordered: false,
72        }
73    }
74
75    /// Sets the gap between docked children.
76    pub fn gap(mut self, gap: f32) -> Self {
77        self.gap = sanitize_gap(gap);
78        self
79    }
80
81    /// Makes the last child fill the remaining space.
82    pub fn last_child_fill(mut self, fill: bool) -> Self {
83        self.last_child_fill = fill;
84        self
85    }
86
87    /// Uses declaration order rather than grouping sides before the fill.
88    pub fn ordered(mut self, ordered: bool) -> Self {
89        self.ordered = ordered;
90        self
91    }
92
93    /// Enables or disables border-style ordering.
94    ///
95    /// This is the inverse of [`DockStyle::ordered`].
96    pub fn border(mut self, border: bool) -> Self {
97        self.ordered = !border;
98        self
99    }
100}
101
102/// Clamps a gap to a finite, non-negative value.
103pub fn sanitize_gap(gap: f32) -> f32 {
104    if gap.is_finite() { gap.max(0.0) } else { 0.0 }
105}
106
107/// Resolves the thickness of a docked side.
108///
109/// [`Length::Px`] is used directly, [`Length::Percent`] scales with
110/// `available`, and fit/fill fall back to the `intrinsic` thickness.
111pub fn dock_thickness(len: Length, intrinsic: f32, available: f32) -> f32 {
112    let intrinsic = if intrinsic.is_finite() {
113        intrinsic.max(0.0)
114    } else {
115        0.0
116    };
117    match len {
118        Length::Px(v) => {
119            if v.is_finite() {
120                v.max(0.0)
121            } else {
122                intrinsic
123            }
124        }
125        Length::Percent(p) => {
126            if available.is_finite() && p.is_finite() {
127                (available * p / 100.0).max(0.0)
128            } else {
129                intrinsic
130            }
131        }
132        Length::Fill(_) | Length::Fit => intrinsic,
133    }
134}
135
136/// Returns the dock for `index`, honouring `last_child_fill`.
137///
138/// A missing entry defaults to [`Dock::Fill`].
139pub fn effective_dock(docks: &[Dock], last_child_fill: bool, index: usize, count: usize) -> Dock {
140    if count > 0 && last_child_fill && index + 1 == count {
141        return Dock::Fill;
142    }
143    docks.get(index).copied().unwrap_or(Dock::Fill)
144}
145
146/// Resolves the dock for a child according to the layout mode.
147///
148/// In `ordered` mode this is [`effective_dock`]; otherwise the configured dock
149/// is used verbatim, so the last child is not forced to fill.
150pub fn resolve_dock(
151    docks: &[Dock],
152    last_child_fill: bool,
153    ordered: bool,
154    index: usize,
155    count: usize,
156) -> Dock {
157    if ordered {
158        effective_dock(docks, last_child_fill, index, count)
159    } else {
160        docks.get(index).copied().unwrap_or(Dock::Fill)
161    }
162}
163
164/// Returns the order in which docked children should be laid out.
165///
166/// In border mode, top and bottom docks are placed first, then left and right,
167/// and finally fill children. `ordered` preserves the declaration order.
168pub fn dock_layout_order(
169    docks: &[Dock],
170    last_child_fill: bool,
171    ordered: bool,
172    count: usize,
173) -> Vec<usize> {
174    let mut order: Vec<usize> = (0..count).collect();
175    if ordered || count == 0 {
176        return order;
177    }
178    order.sort_by_key(|&i| match docks.get(i).copied().unwrap_or(Dock::Fill) {
179        Dock::Top | Dock::Bottom => 0,
180        Dock::Left | Dock::Right => 1,
181        Dock::Fill => 2,
182    });
183    let _ = last_child_fill;
184    order
185}
186
187/// Clamps a split fraction to `0.0..=1.0`; non-finite values become `0.5`.
188pub fn sanitize_frac(frac: f32) -> f32 {
189    if frac.is_finite() {
190        frac.clamp(0.0, 1.0)
191    } else {
192        0.5
193    }
194}
195
196/// Clamps a preview fraction to `0.15..=0.85`; non-finite values become `0.5`.
197pub fn sanitize_preview_frac(frac: f32) -> f32 {
198    if frac.is_finite() {
199        frac.clamp(0.15, 0.85)
200    } else {
201        0.5
202    }
203}
204
205/// Returns the split axis for a dock and whether the docked child comes first.
206///
207/// Returns `None` for [`Dock::Fill`].
208pub fn split_axis_for_dock(dock: Dock) -> Option<(Axis, bool)> {
209    match dock {
210        Dock::Left => Some((Axis::Horizontal, true)),
211        Dock::Right => Some((Axis::Horizontal, false)),
212        Dock::Top => Some((Axis::Vertical, true)),
213        Dock::Bottom => Some((Axis::Vertical, false)),
214        Dock::Fill => None,
215    }
216}
217
218/// Returns the rectangle a docked side would occupy for the given `frac`.
219///
220/// The fraction is clamped by [`sanitize_preview_frac`]; [`Dock::Fill`]
221/// returns the whole host rectangle.
222pub fn dock_preview_rect(host: Rect, dock: Dock, frac: f32) -> Rect {
223    let f = sanitize_preview_frac(frac);
224    let x = host.origin.x;
225    let y = host.origin.y;
226    let w = host.size.width.max(0.0);
227    let h = host.size.height.max(0.0);
228    match dock {
229        Dock::Left => Rect::from_xywh(x, y, w * f, h),
230        Dock::Right => Rect::from_xywh(x + w * (1.0 - f), y, w * f, h),
231        Dock::Top => Rect::from_xywh(x, y, w, h * f),
232        Dock::Bottom => Rect::from_xywh(x, y + h * (1.0 - f), w, h * f),
233        Dock::Fill => host,
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn approx(a: f32, b: f32) -> bool {
242        (a - b).abs() < 0.01
243    }
244
245    #[test]
246    fn px_stays_and_percent_scales_with_available() {
247        assert!(approx(
248            dock_thickness(Length::Px(100.0), 40.0, 400.0),
249            100.0
250        ));
251        assert!(approx(
252            dock_thickness(Length::Percent(25.0), 40.0, 400.0),
253            100.0
254        ));
255    }
256
257    #[test]
258    fn auto_and_fill_take_intrinsic() {
259        assert!(approx(dock_thickness(Length::Fit, 70.0, 400.0), 70.0));
260        assert!(approx(dock_thickness(Length::Fill(1.0), 70.0, 400.0), 70.0));
261    }
262
263    #[test]
264    fn unbounded_axis_falls_back_to_content() {
265        assert!(approx(
266            dock_thickness(Length::Percent(50.0), 80.0, f32::INFINITY),
267            80.0
268        ));
269    }
270
271    #[test]
272    fn negative_and_non_finite_inputs_clamp_to_zero() {
273        assert!(approx(dock_thickness(Length::Px(-5.0), 10.0, 400.0), 0.0));
274        assert!(approx(dock_thickness(Length::Fit, -3.0, 400.0), 0.0));
275        assert!(approx(dock_thickness(Length::Fit, f32::NAN, 400.0), 0.0));
276    }
277
278    #[test]
279    fn effective_dock_last_child_fill_wins() {
280        let docks = [Dock::Left, Dock::Left];
281        assert_eq!(effective_dock(&docks, true, 0, 2), Dock::Left);
282        assert_eq!(effective_dock(&docks, true, 1, 2), Dock::Fill);
283        assert_eq!(effective_dock(&docks, false, 1, 2), Dock::Left);
284
285        assert_eq!(effective_dock(&[], true, 0, 1), Dock::Fill);
286        assert_eq!(effective_dock(&[Dock::Top], false, 3, 4), Dock::Fill);
287    }
288
289    #[test]
290    fn gap_sanitizes() {
291        assert!(approx(sanitize_gap(10.0), 10.0));
292        assert!(approx(sanitize_gap(-2.0), 0.0));
293        assert!(approx(sanitize_gap(f32::NAN), 0.0));
294        assert!(approx(sanitize_gap(f32::INFINITY), 0.0));
295    }
296
297    #[test]
298    fn frac_sanitizes_like_split_panel() {
299        assert_eq!(sanitize_frac(2.0), 1.0);
300        assert_eq!(sanitize_frac(-1.0), 0.0);
301        assert_eq!(sanitize_frac(f32::NAN), 0.5);
302        assert_eq!(sanitize_preview_frac(99.0), 0.85);
303        assert_eq!(sanitize_preview_frac(f32::NAN), 0.5);
304    }
305
306    #[test]
307    fn split_axis_maps_sides() {
308        use crate::container::Axis;
309        assert_eq!(
310            split_axis_for_dock(Dock::Left),
311            Some((Axis::Horizontal, true))
312        );
313        assert_eq!(
314            split_axis_for_dock(Dock::Right),
315            Some((Axis::Horizontal, false))
316        );
317        assert_eq!(split_axis_for_dock(Dock::Top), Some((Axis::Vertical, true)));
318        assert_eq!(
319            split_axis_for_dock(Dock::Bottom),
320            Some((Axis::Vertical, false))
321        );
322        assert_eq!(split_axis_for_dock(Dock::Fill), None);
323    }
324
325    #[test]
326    fn preview_rect_splits_host() {
327        use sequoia_core::Rect;
328        let host = Rect::from_xywh(0.0, 0.0, 400.0, 300.0);
329        let l = dock_preview_rect(host, Dock::Left, 0.5);
330        assert!(approx(l.size.width, 200.0));
331        assert!(approx(l.size.height, 300.0));
332        let b = dock_preview_rect(host, Dock::Bottom, 0.25);
333        assert!(approx(b.size.height, 75.0));
334        assert_eq!(dock_preview_rect(host, Dock::Fill, 0.5), host);
335    }
336
337    #[test]
338    fn border_order_groups_sides_before_fill() {
339        use Dock::*;
340        let docks = [Left, Fill, Bottom, Right, Top];
341
342        assert_eq!(
343            dock_layout_order(&docks, false, false, 5),
344            vec![2, 4, 0, 3, 1]
345        );
346
347        assert_eq!(
348            dock_layout_order(&docks, false, true, 5),
349            vec![0, 1, 2, 3, 4]
350        );
351    }
352
353    #[test]
354    fn border_ignores_last_child_fill() {
355        use Dock::*;
356        let docks = [Left, Left];
357        assert_eq!(resolve_dock(&docks, true, false, 1, 2), Left);
358        assert_eq!(resolve_dock(&docks, true, true, 1, 2), Fill);
359        assert!(!DockStyle::default().ordered);
360        assert!(!DockStyle::default().last_child_fill);
361    }
362}