Skip to main content

teksilo_widgets/docking/
geometry.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Pure region-geometry engine for [`DockingLayout`](super::DockingLayout).
5//!
6//! A docking layout is a *border layout with configurable corners* — exactly
7//! Qt's `QMainWindow` corner model, which a nested-`Splitter` tree cannot
8//! express (in any splitter nesting the corners always belong to the outer
9//! axis). So the five region rectangles are computed directly here, honouring
10//! per-corner ownership, and the [`DockingLayout`](super::DockingLayout)
11//! widget places its children from the result.
12//!
13//! Each side contributes three sub-rectangles: an always-visible **rail** strip
14//! (the activity bar), a resizable/collapsible **content** rect, and a
15//! **handle** (resize gutter) between the content and the centre. For the
16//! **leading / trailing** columns the rail hugs the outer thickness edge with
17//! the content inboard. For the **top / bottom** bands the (always vertical)
18//! rail is instead a **column on the leading cross-edge** (left in LTR, right
19//! in RTL) with the content inboard to its side — so it does not add to the
20//! band depth. A hidden **leading / trailing** side keeps its rail (the reopen
21//! affordance) but drops its content and handle; a hidden **top / bottom** band
22//! collapses **completely** (rail included — a vertical rail can't stand alone
23//! in a zero-depth band), so the app reveals it again via an external button.
24//! Everything is clamped non-negative, so no container size — down to `0×0` or
25//! smaller-than-the-sum-of-minimums — can produce a negative or overlapping
26//! rectangle.
27
28use serde::{Deserialize, Serialize};
29use teksilo_canvas::Rect;
30
31/// Below this the content/gutter is treated as fully collapsed.
32const EPS: f32 = 0.01;
33
34/// One of the four dockable sides. `Leading`/`Trailing` are
35/// writing-direction-relative (mirrored under RTL by the caller); `Top`/
36/// `Bottom` never mirror.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub enum DockSide {
39    /// Left in LTR, right in RTL.
40    Leading,
41    /// Right in LTR, left in RTL.
42    Trailing,
43    Top,
44    Bottom,
45}
46
47impl DockSide {
48    /// All four sides, in a stable order.
49    pub const ALL: [DockSide; 4] = [
50        DockSide::Leading,
51        DockSide::Trailing,
52        DockSide::Top,
53        DockSide::Bottom,
54    ];
55
56    /// True for the vertical columns (leading / trailing), whose long axis
57    /// is vertical — they stack their dock content top-to-bottom.
58    pub fn is_horizontal_axis(self) -> bool {
59        matches!(self, DockSide::Leading | DockSide::Trailing)
60    }
61}
62
63/// One of the four corners of the container. Each corner is owned by exactly
64/// one of its two adjacent sides (Qt `setCorner`).
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub enum DockCorner {
67    TopLeading,
68    TopTrailing,
69    BottomLeading,
70    BottomTrailing,
71}
72
73impl DockCorner {
74    /// All four corners.
75    pub const ALL: [DockCorner; 4] = [
76        DockCorner::TopLeading,
77        DockCorner::TopTrailing,
78        DockCorner::BottomLeading,
79        DockCorner::BottomTrailing,
80    ];
81
82    /// The two sides adjacent to this corner: `(horizontal side, vertical
83    /// side)` — i.e. `(Leading|Trailing, Top|Bottom)`.
84    pub fn adjacent_sides(self) -> (DockSide, DockSide) {
85        match self {
86            DockCorner::TopLeading => (DockSide::Leading, DockSide::Top),
87            DockCorner::TopTrailing => (DockSide::Trailing, DockSide::Top),
88            DockCorner::BottomLeading => (DockSide::Leading, DockSide::Bottom),
89            DockCorner::BottomTrailing => (DockSide::Trailing, DockSide::Bottom),
90        }
91    }
92
93    /// Returns the *other* adjacent side (given one of the two).
94    fn other(self, side: DockSide) -> DockSide {
95        let (h, v) = self.adjacent_sides();
96        if side == h { v } else { h }
97    }
98}
99
100/// Which side owns each corner. Default = the classic IDE shell where the
101/// top and bottom bars span the full width and the leading / trailing columns
102/// occupy only the middle band.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104pub struct CornerOwners {
105    pub top_leading: DockSide,
106    pub top_trailing: DockSide,
107    pub bottom_leading: DockSide,
108    pub bottom_trailing: DockSide,
109}
110
111impl Default for CornerOwners {
112    fn default() -> Self {
113        Self {
114            top_leading: DockSide::Top,
115            top_trailing: DockSide::Top,
116            bottom_leading: DockSide::Bottom,
117            bottom_trailing: DockSide::Bottom,
118        }
119    }
120}
121
122impl CornerOwners {
123    pub fn owner(&self, corner: DockCorner) -> DockSide {
124        match corner {
125            DockCorner::TopLeading => self.top_leading,
126            DockCorner::TopTrailing => self.top_trailing,
127            DockCorner::BottomLeading => self.bottom_leading,
128            DockCorner::BottomTrailing => self.bottom_trailing,
129        }
130    }
131
132    pub fn set(&mut self, corner: DockCorner, owner: DockSide) {
133        match corner {
134            DockCorner::TopLeading => self.top_leading = owner,
135            DockCorner::TopTrailing => self.top_trailing = owner,
136            DockCorner::BottomLeading => self.bottom_leading = owner,
137            DockCorner::BottomTrailing => self.bottom_trailing = owner,
138        }
139    }
140}
141
142/// Per-side geometry inputs (all logical pixels), in LTR space.
143#[derive(Debug, Clone, Copy)]
144pub struct SideLayout {
145    /// Stored content size along the side's thickness axis (width for
146    /// leading/trailing, height for top/bottom).
147    pub size: f32,
148    /// Show/hide progress in `0..=1` (animated). `0` = hidden, `1` = shown.
149    pub visible_progress: f32,
150    /// Resize-handle (gutter) thickness when content is shown.
151    pub gutter: f32,
152    /// Minimum content thickness (used only by the caller's `layout_response`).
153    pub min_size: f32,
154    /// Always-visible rail (activity bar) thickness; `0` when the side has
155    /// no rail.
156    pub rail_thickness: f32,
157    /// Whether this side shows an activity rail.
158    pub has_rail: bool,
159}
160
161impl SideLayout {
162    /// A fully-collapsed, rail-less, zero-size placeholder.
163    pub fn empty() -> Self {
164        Self {
165            size: 0.0,
166            visible_progress: 0.0,
167            gutter: 0.0,
168            min_size: 0.0,
169            rail_thickness: 0.0,
170            has_rail: false,
171        }
172    }
173
174    fn rail_extent(&self) -> f32 {
175        if self.has_rail {
176            self.rail_thickness.max(0.0)
177        } else {
178            0.0
179        }
180    }
181
182    fn content_extent(&self) -> f32 {
183        (self.size * self.visible_progress.clamp(0.0, 1.0)).max(0.0)
184    }
185
186    fn gutter_extent(&self) -> f32 {
187        if self.content_extent() > EPS {
188            self.gutter.max(0.0)
189        } else {
190            0.0
191        }
192    }
193
194    /// Total extent the side occupies toward the centre (rail + content +
195    /// gutter). Used for the **leading / trailing** columns, where the rail sits
196    /// on the main (thickness) axis.
197    fn total_extent(&self) -> f32 {
198        self.rail_extent() + self.content_extent() + self.gutter_extent()
199    }
200
201    /// Depth a **top / bottom** band occupies toward the centre. Their (vertical)
202    /// rail sits on the *cross* (leading) edge as a column, so it does **not**
203    /// add to the band depth — that's content + gutter. A hidden top / bottom
204    /// band collapses **completely** (the vertical rail can't stand alone in a
205    /// zero-depth band the way a leading/trailing rail can in a full-height
206    /// column) — the app offers an external button to reveal it again.
207    ///
208    /// Folding the rail into the depth instead is not the missing fix:
209    /// [`SideLayout`] carries a single `rail_thickness` scalar, which on a
210    /// leading / trailing column is a width with a *free* height and here would
211    /// be a width with a *constrained* one. That is a model mismatch, not an
212    /// oversight — see the Top / Bottom arm of [`split_side`] for what paying
213    /// for it would cost.
214    fn band_depth(&self) -> f32 {
215        self.content_extent() + self.gutter_extent()
216    }
217
218    /// Whether a leading / trailing column occupies any space.
219    fn present(&self) -> bool {
220        self.total_extent() > EPS
221    }
222
223    /// Whether a top / bottom band occupies any space.
224    fn band_present(&self) -> bool {
225        self.band_depth() > EPS
226    }
227}
228
229/// The three sub-rectangles a side contributes: the always-visible rail, the
230/// resizable content, and the resize handle. Any of them is [`Rect::ZERO`]
231/// when absent.
232#[derive(Debug, Clone, Copy, PartialEq)]
233pub struct SideRects {
234    pub rail: Rect,
235    pub content: Rect,
236    pub handle: Rect,
237}
238
239impl SideRects {
240    const ZERO: SideRects = SideRects {
241        rail: Rect::ZERO,
242        content: Rect::ZERO,
243        handle: Rect::ZERO,
244    };
245}
246
247/// The computed geometry: four side breakdowns plus the centre rect.
248#[derive(Debug, Clone, Copy, PartialEq)]
249pub struct DockingRects {
250    pub leading: SideRects,
251    pub trailing: SideRects,
252    pub top: SideRects,
253    pub bottom: SideRects,
254    pub center: Rect,
255}
256
257/// Resolve a corner to its *effective* owner: the declared owner if that side
258/// is present, else the other adjacent side if *it* is present, else the
259/// declared owner (both absent — the choice is moot).
260fn effective_owner(
261    corner: DockCorner,
262    owners: &CornerOwners,
263    present: impl Fn(DockSide) -> bool,
264) -> DockSide {
265    let declared = owners.owner(corner);
266    if present(declared) {
267        declared
268    } else {
269        let other = corner.other(declared);
270        if present(other) { other } else { declared }
271    }
272}
273
274/// Compute the five region rectangles. The caller swaps `leading` and
275/// `trailing` on the way in and the resulting `leading`/`trailing` on the way
276/// out for RTL; `rtl` is passed through only to place a top / bottom band's
277/// (vertical) rail on the leading cross-edge (left in LTR, right in RTL).
278pub fn compute_rects(
279    container: Rect,
280    leading: SideLayout,
281    trailing: SideLayout,
282    top: SideLayout,
283    bottom: SideLayout,
284    owners: CornerOwners,
285    rtl: bool,
286) -> DockingRects {
287    let x = container.x;
288    let y = container.y;
289    let w = container.width.max(0.0);
290    let h = container.height.max(0.0);
291
292    // Total extents per side, pre-clamped so opposing sides can never claim
293    // more than the container in either axis (centre shrinks to zero first).
294    let mut l = leading.total_extent();
295    let mut r = trailing.total_extent();
296    let mut t = top.band_depth();
297    let mut b = bottom.band_depth();
298    if l + r > w {
299        let scale = if l + r > 0.0 { w / (l + r) } else { 0.0 };
300        l *= scale;
301        r *= scale;
302    }
303    if t + b > h {
304        let scale = if t + b > 0.0 { h / (t + b) } else { 0.0 };
305        t *= scale;
306        b *= scale;
307    }
308
309    let present = |side: DockSide| match side {
310        DockSide::Leading => leading.present(),
311        DockSide::Trailing => trailing.present(),
312        DockSide::Top => top.band_present(),
313        DockSide::Bottom => bottom.band_present(),
314    };
315    let eff = |corner: DockCorner| effective_owner(corner, &owners, present);
316
317    // Horizontal extents of the top / bottom bands.
318    let top_x_left = if eff(DockCorner::TopLeading) == DockSide::Top {
319        x
320    } else {
321        x + l
322    };
323    let top_x_right = if eff(DockCorner::TopTrailing) == DockSide::Top {
324        x + w
325    } else {
326        x + w - r
327    };
328    let bottom_x_left = if eff(DockCorner::BottomLeading) == DockSide::Bottom {
329        x
330    } else {
331        x + l
332    };
333    let bottom_x_right = if eff(DockCorner::BottomTrailing) == DockSide::Bottom {
334        x + w
335    } else {
336        x + w - r
337    };
338
339    // Vertical extents of the leading / trailing columns.
340    let leading_y_top = if eff(DockCorner::TopLeading) == DockSide::Leading {
341        y
342    } else {
343        y + t
344    };
345    let leading_y_bottom = if eff(DockCorner::BottomLeading) == DockSide::Leading {
346        y + h
347    } else {
348        y + h - b
349    };
350    let trailing_y_top = if eff(DockCorner::TopTrailing) == DockSide::Trailing {
351        y
352    } else {
353        y + t
354    };
355    let trailing_y_bottom = if eff(DockCorner::BottomTrailing) == DockSide::Trailing {
356        y + h
357    } else {
358        y + h - b
359    };
360
361    // Outer region rects (the whole side band).
362    let leading_region = Rect::new(
363        x,
364        leading_y_top,
365        l,
366        (leading_y_bottom - leading_y_top).max(0.0),
367    );
368    let trailing_region = Rect::new(
369        x + w - r,
370        trailing_y_top,
371        r,
372        (trailing_y_bottom - trailing_y_top).max(0.0),
373    );
374    let top_region = Rect::new(top_x_left, y, (top_x_right - top_x_left).max(0.0), t);
375    let bottom_region = Rect::new(
376        bottom_x_left,
377        y + h - b,
378        (bottom_x_right - bottom_x_left).max(0.0),
379        b,
380    );
381    let center = Rect::new(x + l, y + t, (w - l - r).max(0.0), (h - t - b).max(0.0));
382
383    DockingRects {
384        leading: split_side(DockSide::Leading, leading_region, &leading, l, rtl),
385        trailing: split_side(DockSide::Trailing, trailing_region, &trailing, r, rtl),
386        top: split_side(DockSide::Top, top_region, &top, t, rtl),
387        bottom: split_side(DockSide::Bottom, bottom_region, &bottom, b, rtl),
388        center,
389    }
390}
391
392/// Split a side's outer region into rail / content / handle sub-rects.
393///
394/// Leading / trailing lay them out along the thickness axis (`[rail │ content │
395/// handle]`), the rail on the outer edge. Top / bottom put the (vertical) rail
396/// as a **column on the leading cross-edge** (left in LTR, right in RTL) and
397/// split content / handle along the band depth to its inboard side. `total` is
398/// the (possibly clamped) band extent.
399fn split_side(
400    side: DockSide,
401    region: Rect,
402    layout: &SideLayout,
403    total: f32,
404    rtl: bool,
405) -> SideRects {
406    if total <= EPS || region.width <= 0.0 || region.height <= 0.0 {
407        // Rail-only sides still get a rail rect when there is room.
408        if layout.rail_extent() > EPS && region.width > 0.0 && region.height > 0.0 {
409            return rail_only(
410                side,
411                region,
412                layout.rail_extent().min(extent_along(side, region)),
413                rtl,
414            );
415        }
416        return SideRects::ZERO;
417    }
418
419    match side {
420        DockSide::Leading | DockSide::Trailing => {
421            // The rail is on the main (thickness) axis; rail + content + gutter
422            // share the band width, scaled to fit `total`.
423            let raw = layout.total_extent();
424            let scale = if raw > 0.0 { total / raw } else { 0.0 };
425            let rail = layout.rail_extent() * scale;
426            let content = layout.content_extent() * scale;
427            let gutter = layout.gutter_extent() * scale;
428            match side {
429                DockSide::Leading => SideRects {
430                    rail: Rect::new(region.x, region.y, rail, region.height),
431                    content: Rect::new(region.x + rail, region.y, content, region.height),
432                    handle: Rect::new(region.x + rail + content, region.y, gutter, region.height),
433                },
434                _ => SideRects {
435                    handle: Rect::new(region.x, region.y, gutter, region.height),
436                    content: Rect::new(region.x + gutter, region.y, content, region.height),
437                    rail: Rect::new(region.x + gutter + content, region.y, rail, region.height),
438                },
439            }
440        }
441        DockSide::Top | DockSide::Bottom => {
442            // Vertical rail = a column on the leading cross-edge; content + handle
443            // fill the rest, split along the band depth (`total`).
444            let rail_w = layout.rail_extent().min(region.width);
445            let body_w = (region.width - rail_w).max(0.0);
446            // Leading edge: left in LTR, right in RTL.
447            let (rail_x, body_x) = if rtl {
448                (region.x + body_w, region.x)
449            } else {
450                (region.x, region.x + rail_w)
451            };
452            let raw_depth = layout.content_extent() + layout.gutter_extent();
453            let scale = if raw_depth > 0.0 {
454                total / raw_depth
455            } else {
456                0.0
457            };
458            let content = layout.content_extent() * scale;
459            let gutter = layout.gutter_extent() * scale;
460            // The rail rect takes `region.height` — and for a band that *is*
461            // the depth (`t` / `b` = `band_depth()` = content + gutter), so a
462            // hidden band hands the rail a zero-high rect. Relaxing
463            // `rail_only`'s `region.height > 0.0` guard to keep the rail alive
464            // here was proposed, accepted, and then retracted: the guard is a
465            // symptom, not the cause — past it the rect is still zero-high.
466            // Keeping a *vertical* rail visible across a hidden band means
467            // permanently reserving `N × item_extent` of band depth (three
468            // `Large` items ≈ 130 dp of always-present bottom band) purely to
469            // host a column of icons. The reopen affordance for Top / Bottom
470            // therefore needs a *horizontal* rail — a different widget — not a
471            // relaxed guard. `hidden_top_with_rail_fully_collapses` pins it.
472            let rail = Rect::new(rail_x, region.y, rail_w, region.height);
473            match side {
474                // Top: content on top, handle below it (inboard, toward centre).
475                DockSide::Top => SideRects {
476                    rail,
477                    content: Rect::new(body_x, region.y, body_w, content),
478                    handle: Rect::new(body_x, region.y + content, body_w, gutter),
479                },
480                // Bottom: handle on top (inboard, toward centre), content below.
481                _ => SideRects {
482                    rail,
483                    handle: Rect::new(body_x, region.y, body_w, gutter),
484                    content: Rect::new(body_x, region.y + gutter, body_w, content),
485                },
486            }
487        }
488    }
489}
490
491fn extent_along(side: DockSide, region: Rect) -> f32 {
492    if side.is_horizontal_axis() {
493        region.width
494    } else {
495        region.height
496    }
497}
498
499/// A side with only its rail present (content hidden). Leading / trailing rails
500/// hug the outer thickness edge; top / bottom rails are a column on the leading
501/// cross-edge (left in LTR, right in RTL).
502fn rail_only(side: DockSide, region: Rect, rail: f32, rtl: bool) -> SideRects {
503    let mut rects = SideRects::ZERO;
504    rects.rail = match side {
505        DockSide::Leading => Rect::new(region.x, region.y, rail, region.height),
506        DockSide::Trailing => Rect::new(
507            region.x + region.width - rail,
508            region.y,
509            rail,
510            region.height,
511        ),
512        DockSide::Top | DockSide::Bottom => {
513            let rail_x = if rtl {
514                region.x + region.width - rail
515            } else {
516                region.x
517            };
518            Rect::new(rail_x, region.y, rail, region.height)
519        }
520    };
521    rects
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    fn container() -> Rect {
529        Rect::new(0.0, 0.0, 1000.0, 800.0)
530    }
531
532    /// A simple shown side: content `size`, no rail.
533    fn shown(size: f32) -> SideLayout {
534        SideLayout {
535            size,
536            visible_progress: 1.0,
537            gutter: 6.0,
538            min_size: 50.0,
539            rail_thickness: 0.0,
540            has_rail: false,
541        }
542    }
543
544    fn hidden(size: f32) -> SideLayout {
545        SideLayout {
546            visible_progress: 0.0,
547            ..shown(size)
548        }
549    }
550
551    #[test]
552    fn all_hidden_center_fills() {
553        let r = compute_rects(
554            container(),
555            SideLayout::empty(),
556            SideLayout::empty(),
557            SideLayout::empty(),
558            SideLayout::empty(),
559            CornerOwners::default(),
560            false,
561        );
562        assert_eq!(r.center, container());
563    }
564
565    #[test]
566    fn single_leading_side() {
567        let r = compute_rects(
568            container(),
569            shown(200.0),
570            SideLayout::empty(),
571            SideLayout::empty(),
572            SideLayout::empty(),
573            CornerOwners::default(),
574            false,
575        );
576        assert_eq!(r.leading.content.x, 0.0);
577        assert!((r.leading.content.width - 200.0).abs() < 0.01);
578        assert!(
579            (r.center.x - 206.0).abs() < 0.01,
580            "center after content+gutter"
581        );
582        assert!((r.center.width - (1000.0 - 206.0)).abs() < 0.01);
583        assert_eq!(r.trailing, SideRects::ZERO);
584    }
585
586    #[test]
587    fn four_sides_default_corners_inset_center() {
588        let r = compute_rects(
589            container(),
590            shown(200.0),
591            shown(150.0),
592            shown(100.0),
593            shown(120.0),
594            CornerOwners::default(),
595            false,
596        );
597        // Default: top/bottom own corners → they span full width.
598        assert_eq!(r.top.content.x, 0.0);
599        assert!((r.top.content.width - 1000.0).abs() < 0.01);
600        assert_eq!(r.bottom.content.x, 0.0);
601        // Leading column is inset vertically by top + gutter and bottom + gutter.
602        assert!((r.leading.content.y - 106.0).abs() < 0.01);
603        // Center inset on all four sides (size + gutter each).
604        assert!((r.center.x - 206.0).abs() < 0.01);
605        assert!((r.center.y - 106.0).abs() < 0.01);
606    }
607
608    #[test]
609    fn corner_owned_by_leading_extends_column_up() {
610        let mut owners = CornerOwners::default();
611        owners.set(DockCorner::TopLeading, DockSide::Leading);
612        let r = compute_rects(
613            container(),
614            shown(200.0),
615            SideLayout::empty(),
616            shown(100.0),
617            SideLayout::empty(),
618            owners,
619            false,
620        );
621        // Leading now extends to y=0; top starts after the leading column.
622        assert_eq!(r.leading.content.y, 0.0);
623        assert!(
624            (r.top.content.x - 206.0).abs() < 0.01,
625            "top pushed right of leading"
626        );
627    }
628
629    #[test]
630    fn corner_degrades_when_owner_hidden() {
631        // TopLeading declared to Leading, but leading is hidden ⇒ top fills.
632        let mut owners = CornerOwners::default();
633        owners.set(DockCorner::TopLeading, DockSide::Leading);
634        let r = compute_rects(
635            container(),
636            hidden(200.0),
637            SideLayout::empty(),
638            shown(100.0),
639            SideLayout::empty(),
640            owners,
641            false,
642        );
643        assert_eq!(
644            r.top.content.x, 0.0,
645            "top fills since its corner-owner is gone"
646        );
647    }
648
649    #[test]
650    fn visible_progress_half_scales_content() {
651        let mut s = shown(200.0);
652        s.visible_progress = 0.5;
653        let r = compute_rects(
654            container(),
655            s,
656            SideLayout::empty(),
657            SideLayout::empty(),
658            SideLayout::empty(),
659            CornerOwners::default(),
660            false,
661        );
662        assert!((r.leading.content.width - 100.0).abs() < 0.01);
663        assert!(
664            r.leading.handle.width > 0.0,
665            "gutter present while expanding"
666        );
667    }
668
669    #[test]
670    fn hidden_side_with_rail_keeps_rail_drops_handle() {
671        let s = SideLayout {
672            size: 240.0,
673            visible_progress: 0.0,
674            gutter: 6.0,
675            min_size: 60.0,
676            rail_thickness: 48.0,
677            has_rail: true,
678        };
679        let r = compute_rects(
680            container(),
681            s,
682            SideLayout::empty(),
683            SideLayout::empty(),
684            SideLayout::empty(),
685            CornerOwners::default(),
686            false,
687        );
688        assert!((r.leading.rail.width - 48.0).abs() < 0.01, "rail persists");
689        assert!(r.leading.content.width.abs() < 0.01, "content hidden");
690        assert!(r.leading.handle.width.abs() < 0.01, "no handle when hidden");
691        assert!(
692            (r.center.x - 48.0).abs() < 0.01,
693            "center inset only by rail"
694        );
695    }
696
697    #[test]
698    fn shown_side_with_rail_orders_rail_content_handle() {
699        let s = SideLayout {
700            size: 200.0,
701            visible_progress: 1.0,
702            gutter: 6.0,
703            min_size: 60.0,
704            rail_thickness: 48.0,
705            has_rail: true,
706        };
707        let r = compute_rects(
708            container(),
709            s,
710            SideLayout::empty(),
711            SideLayout::empty(),
712            SideLayout::empty(),
713            CornerOwners::default(),
714            false,
715        );
716        assert_eq!(r.leading.rail.x, 0.0);
717        assert!((r.leading.rail.width - 48.0).abs() < 0.01);
718        assert!((r.leading.content.x - 48.0).abs() < 0.01);
719        assert!((r.leading.content.width - 200.0).abs() < 0.01);
720        assert!((r.leading.handle.x - 248.0).abs() < 0.01);
721        assert!((r.center.x - 254.0).abs() < 0.01);
722    }
723
724    /// A top/bottom side with a rail.
725    fn band_with_rail(progress: f32) -> SideLayout {
726        SideLayout {
727            size: 100.0,
728            visible_progress: progress,
729            gutter: 6.0,
730            min_size: 80.0,
731            rail_thickness: 48.0,
732            has_rail: true,
733        }
734    }
735
736    #[test]
737    fn top_rail_is_a_leading_column_not_a_band() {
738        let r = compute_rects(
739            container(),
740            SideLayout::empty(),
741            SideLayout::empty(),
742            band_with_rail(1.0),
743            SideLayout::empty(),
744            CornerOwners::default(),
745            false,
746        );
747        // Rail is a vertical column on the leading (left) edge, spanning the
748        // band depth (content + gutter = 106), NOT a horizontal band.
749        assert_eq!(r.top.rail.x, 0.0);
750        assert!((r.top.rail.width - 48.0).abs() < 0.01);
751        assert!((r.top.rail.height - 106.0).abs() < 0.01);
752        // Content is inboard to the right of the rail.
753        assert!((r.top.content.x - 48.0).abs() < 0.01);
754        assert!((r.top.content.width - (1000.0 - 48.0)).abs() < 0.01);
755        assert!((r.top.content.height - 100.0).abs() < 0.01);
756        // The rail does not push the centre down — only content + gutter do.
757        assert!((r.center.y - 106.0).abs() < 0.01);
758    }
759
760    #[test]
761    fn top_rail_column_mirrors_to_the_right_in_rtl() {
762        let r = compute_rects(
763            container(),
764            SideLayout::empty(),
765            SideLayout::empty(),
766            band_with_rail(1.0),
767            SideLayout::empty(),
768            CornerOwners::default(),
769            true,
770        );
771        assert!(
772            (r.top.rail.x - (1000.0 - 48.0)).abs() < 0.01,
773            "rail on the right in RTL"
774        );
775        assert_eq!(r.top.content.x, 0.0, "content on the left in RTL");
776    }
777
778    #[test]
779    fn hidden_top_with_rail_fully_collapses() {
780        let r = compute_rects(
781            container(),
782            SideLayout::empty(),
783            SideLayout::empty(),
784            band_with_rail(0.0),
785            SideLayout::empty(),
786            CornerOwners::default(),
787            false,
788        );
789        // A hidden top/bottom band hides its vertical rail too (no persistent
790        // column) — the app reveals it again via an external button.
791        assert!(r.top.rail.height.abs() < 0.01, "no rail column when hidden");
792        assert!(r.top.content.height.abs() < 0.01, "no content when hidden");
793        assert_eq!(r.center.y, 0.0, "centre fills — no top inset");
794        assert!((r.center.height - 800.0).abs() < 0.01);
795    }
796
797    #[test]
798    fn bottom_rail_column_keeps_handle_inboard() {
799        let r = compute_rects(
800            container(),
801            SideLayout::empty(),
802            SideLayout::empty(),
803            SideLayout::empty(),
804            band_with_rail(1.0),
805            CornerOwners::default(),
806            false,
807        );
808        // Rail column on the leading edge; band pinned to the container bottom.
809        assert_eq!(r.bottom.rail.x, 0.0);
810        assert!((r.bottom.rail.width - 48.0).abs() < 0.01);
811        // The resize handle sits at the band's TOP (inboard, toward centre),
812        // right of the rail; content is below it.
813        assert!((r.bottom.handle.x - 48.0).abs() < 0.01);
814        assert!((r.bottom.handle.y - (800.0 - 106.0)).abs() < 0.01);
815        assert!(
816            r.bottom.content.y > r.bottom.handle.y,
817            "content below the inboard handle"
818        );
819    }
820
821    #[test]
822    fn trailing_rail_sits_on_the_outer_edge() {
823        let s = SideLayout {
824            size: 200.0,
825            visible_progress: 1.0,
826            gutter: 6.0,
827            min_size: 60.0,
828            rail_thickness: 48.0,
829            has_rail: true,
830        };
831        let r = compute_rects(
832            container(),
833            SideLayout::empty(),
834            s,
835            SideLayout::empty(),
836            SideLayout::empty(),
837            CornerOwners::default(),
838            false,
839        );
840        // Trailing band: handle | content | rail, rail flush to the right edge.
841        assert!((r.trailing.rail.right() - 1000.0).abs() < 0.01);
842        assert!((r.trailing.rail.width - 48.0).abs() < 0.01);
843        assert!(r.trailing.handle.x < r.trailing.content.x);
844        assert!(r.trailing.content.x < r.trailing.rail.x);
845    }
846
847    #[test]
848    fn handle_spans_same_cross_extent_as_content() {
849        let r = compute_rects(
850            container(),
851            shown(200.0),
852            SideLayout::empty(),
853            shown(100.0),
854            SideLayout::empty(),
855            CornerOwners::default(),
856            false,
857        );
858        assert!((r.leading.handle.height - r.leading.content.height).abs() < 0.01);
859        assert!((r.leading.handle.y - r.leading.content.y).abs() < 0.01);
860    }
861
862    #[test]
863    fn center_never_negative_under_over_constraint() {
864        // Sides demand far more than the container.
865        let big = shown(900.0);
866        let r = compute_rects(
867            container(),
868            big,
869            big,
870            big,
871            big,
872            CornerOwners::default(),
873            false,
874        );
875        assert!(r.center.width >= 0.0);
876        assert!(r.center.height >= 0.0);
877        // No band exceeds the container.
878        assert!(r.leading.content.width + r.trailing.content.width <= 1000.0 + 0.01);
879    }
880
881    #[test]
882    fn zero_by_zero_container_no_panic() {
883        let r = compute_rects(
884            Rect::new(0.0, 0.0, 0.0, 0.0),
885            shown(200.0),
886            shown(200.0),
887            shown(100.0),
888            shown(100.0),
889            CornerOwners::default(),
890            false,
891        );
892        assert_eq!(r.center, Rect::new(0.0, 0.0, 0.0, 0.0));
893    }
894
895    #[test]
896    fn degenerate_corner_clamps_to_zero() {
897        // Top + bottom exceed the height with leading owning both side
898        // corners → leading column height clamps to >= 0, no panic.
899        let mut owners = CornerOwners::default();
900        owners.set(DockCorner::TopLeading, DockSide::Leading);
901        owners.set(DockCorner::BottomLeading, DockSide::Leading);
902        let r = compute_rects(
903            Rect::new(0.0, 0.0, 1000.0, 100.0),
904            shown(200.0),
905            SideLayout::empty(),
906            shown(80.0),
907            shown(80.0),
908            owners,
909            false,
910        );
911        assert!(r.leading.content.height >= 0.0);
912    }
913
914    #[test]
915    fn idempotent() {
916        let a = compute_rects(
917            container(),
918            shown(200.0),
919            shown(150.0),
920            shown(100.0),
921            shown(120.0),
922            CornerOwners::default(),
923            false,
924        );
925        let b = compute_rects(
926            container(),
927            shown(200.0),
928            shown(150.0),
929            shown(100.0),
930            shown(120.0),
931            CornerOwners::default(),
932            false,
933        );
934        assert_eq!(a, b);
935    }
936
937    #[test]
938    fn corner_other_side_helper() {
939        assert_eq!(
940            DockCorner::TopLeading.other(DockSide::Leading),
941            DockSide::Top
942        );
943        assert_eq!(
944            DockCorner::TopLeading.other(DockSide::Top),
945            DockSide::Leading
946        );
947    }
948
949    #[test]
950    fn rtl_mirror_is_caller_swap() {
951        // The engine is LTR-only; the caller swaps leading/trailing. Verify a
952        // swapped call mirrors the bands.
953        let ltr = compute_rects(
954            container(),
955            shown(200.0),
956            shown(150.0),
957            SideLayout::empty(),
958            SideLayout::empty(),
959            CornerOwners::default(),
960            false,
961        );
962        let rtl = compute_rects(
963            container(),
964            shown(150.0),
965            shown(200.0),
966            SideLayout::empty(),
967            SideLayout::empty(),
968            CornerOwners::default(),
969            false,
970        );
971        // In RTL the (logical) leading band has trailing's geometry mirrored.
972        assert!((ltr.leading.content.width - rtl.trailing.content.width).abs() < 0.01);
973    }
974}