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    fn band_depth(&self) -> f32 {
208        self.content_extent() + self.gutter_extent()
209    }
210
211    /// Whether a leading / trailing column occupies any space.
212    fn present(&self) -> bool {
213        self.total_extent() > EPS
214    }
215
216    /// Whether a top / bottom band occupies any space.
217    fn band_present(&self) -> bool {
218        self.band_depth() > EPS
219    }
220}
221
222/// The three sub-rectangles a side contributes: the always-visible rail, the
223/// resizable content, and the resize handle. Any of them is [`Rect::ZERO`]
224/// when absent.
225#[derive(Debug, Clone, Copy, PartialEq)]
226pub struct SideRects {
227    pub rail: Rect,
228    pub content: Rect,
229    pub handle: Rect,
230}
231
232impl SideRects {
233    const ZERO: SideRects = SideRects {
234        rail: Rect::ZERO,
235        content: Rect::ZERO,
236        handle: Rect::ZERO,
237    };
238}
239
240/// The computed geometry: four side breakdowns plus the centre rect.
241#[derive(Debug, Clone, Copy, PartialEq)]
242pub struct DockingRects {
243    pub leading: SideRects,
244    pub trailing: SideRects,
245    pub top: SideRects,
246    pub bottom: SideRects,
247    pub center: Rect,
248}
249
250/// Resolve a corner to its *effective* owner: the declared owner if that side
251/// is present, else the other adjacent side if *it* is present, else the
252/// declared owner (both absent — the choice is moot).
253fn effective_owner(
254    corner: DockCorner,
255    owners: &CornerOwners,
256    present: impl Fn(DockSide) -> bool,
257) -> DockSide {
258    let declared = owners.owner(corner);
259    if present(declared) {
260        declared
261    } else {
262        let other = corner.other(declared);
263        if present(other) { other } else { declared }
264    }
265}
266
267/// Compute the five region rectangles. The caller swaps `leading` and
268/// `trailing` on the way in and the resulting `leading`/`trailing` on the way
269/// out for RTL; `rtl` is passed through only to place a top / bottom band's
270/// (vertical) rail on the leading cross-edge (left in LTR, right in RTL).
271pub fn compute_rects(
272    container: Rect,
273    leading: SideLayout,
274    trailing: SideLayout,
275    top: SideLayout,
276    bottom: SideLayout,
277    owners: CornerOwners,
278    rtl: bool,
279) -> DockingRects {
280    let x = container.x;
281    let y = container.y;
282    let w = container.width.max(0.0);
283    let h = container.height.max(0.0);
284
285    // Total extents per side, pre-clamped so opposing sides can never claim
286    // more than the container in either axis (centre shrinks to zero first).
287    let mut l = leading.total_extent();
288    let mut r = trailing.total_extent();
289    let mut t = top.band_depth();
290    let mut b = bottom.band_depth();
291    if l + r > w {
292        let scale = if l + r > 0.0 { w / (l + r) } else { 0.0 };
293        l *= scale;
294        r *= scale;
295    }
296    if t + b > h {
297        let scale = if t + b > 0.0 { h / (t + b) } else { 0.0 };
298        t *= scale;
299        b *= scale;
300    }
301
302    let present = |side: DockSide| match side {
303        DockSide::Leading => leading.present(),
304        DockSide::Trailing => trailing.present(),
305        DockSide::Top => top.band_present(),
306        DockSide::Bottom => bottom.band_present(),
307    };
308    let eff = |corner: DockCorner| effective_owner(corner, &owners, present);
309
310    // Horizontal extents of the top / bottom bands.
311    let top_x_left = if eff(DockCorner::TopLeading) == DockSide::Top {
312        x
313    } else {
314        x + l
315    };
316    let top_x_right = if eff(DockCorner::TopTrailing) == DockSide::Top {
317        x + w
318    } else {
319        x + w - r
320    };
321    let bottom_x_left = if eff(DockCorner::BottomLeading) == DockSide::Bottom {
322        x
323    } else {
324        x + l
325    };
326    let bottom_x_right = if eff(DockCorner::BottomTrailing) == DockSide::Bottom {
327        x + w
328    } else {
329        x + w - r
330    };
331
332    // Vertical extents of the leading / trailing columns.
333    let leading_y_top = if eff(DockCorner::TopLeading) == DockSide::Leading {
334        y
335    } else {
336        y + t
337    };
338    let leading_y_bottom = if eff(DockCorner::BottomLeading) == DockSide::Leading {
339        y + h
340    } else {
341        y + h - b
342    };
343    let trailing_y_top = if eff(DockCorner::TopTrailing) == DockSide::Trailing {
344        y
345    } else {
346        y + t
347    };
348    let trailing_y_bottom = if eff(DockCorner::BottomTrailing) == DockSide::Trailing {
349        y + h
350    } else {
351        y + h - b
352    };
353
354    // Outer region rects (the whole side band).
355    let leading_region = Rect::new(
356        x,
357        leading_y_top,
358        l,
359        (leading_y_bottom - leading_y_top).max(0.0),
360    );
361    let trailing_region = Rect::new(
362        x + w - r,
363        trailing_y_top,
364        r,
365        (trailing_y_bottom - trailing_y_top).max(0.0),
366    );
367    let top_region = Rect::new(top_x_left, y, (top_x_right - top_x_left).max(0.0), t);
368    let bottom_region = Rect::new(
369        bottom_x_left,
370        y + h - b,
371        (bottom_x_right - bottom_x_left).max(0.0),
372        b,
373    );
374    let center = Rect::new(x + l, y + t, (w - l - r).max(0.0), (h - t - b).max(0.0));
375
376    DockingRects {
377        leading: split_side(DockSide::Leading, leading_region, &leading, l, rtl),
378        trailing: split_side(DockSide::Trailing, trailing_region, &trailing, r, rtl),
379        top: split_side(DockSide::Top, top_region, &top, t, rtl),
380        bottom: split_side(DockSide::Bottom, bottom_region, &bottom, b, rtl),
381        center,
382    }
383}
384
385/// Split a side's outer region into rail / content / handle sub-rects.
386///
387/// Leading / trailing lay them out along the thickness axis (`[rail │ content │
388/// handle]`), the rail on the outer edge. Top / bottom put the (vertical) rail
389/// as a **column on the leading cross-edge** (left in LTR, right in RTL) and
390/// split content / handle along the band depth to its inboard side. `total` is
391/// the (possibly clamped) band extent.
392fn split_side(
393    side: DockSide,
394    region: Rect,
395    layout: &SideLayout,
396    total: f32,
397    rtl: bool,
398) -> SideRects {
399    if total <= EPS || region.width <= 0.0 || region.height <= 0.0 {
400        // Rail-only sides still get a rail rect when there is room.
401        if layout.rail_extent() > EPS && region.width > 0.0 && region.height > 0.0 {
402            return rail_only(
403                side,
404                region,
405                layout.rail_extent().min(extent_along(side, region)),
406                rtl,
407            );
408        }
409        return SideRects::ZERO;
410    }
411
412    match side {
413        DockSide::Leading | DockSide::Trailing => {
414            // The rail is on the main (thickness) axis; rail + content + gutter
415            // share the band width, scaled to fit `total`.
416            let raw = layout.total_extent();
417            let scale = if raw > 0.0 { total / raw } else { 0.0 };
418            let rail = layout.rail_extent() * scale;
419            let content = layout.content_extent() * scale;
420            let gutter = layout.gutter_extent() * scale;
421            match side {
422                DockSide::Leading => SideRects {
423                    rail: Rect::new(region.x, region.y, rail, region.height),
424                    content: Rect::new(region.x + rail, region.y, content, region.height),
425                    handle: Rect::new(region.x + rail + content, region.y, gutter, region.height),
426                },
427                _ => SideRects {
428                    handle: Rect::new(region.x, region.y, gutter, region.height),
429                    content: Rect::new(region.x + gutter, region.y, content, region.height),
430                    rail: Rect::new(region.x + gutter + content, region.y, rail, region.height),
431                },
432            }
433        }
434        DockSide::Top | DockSide::Bottom => {
435            // Vertical rail = a column on the leading cross-edge; content + handle
436            // fill the rest, split along the band depth (`total`).
437            let rail_w = layout.rail_extent().min(region.width);
438            let body_w = (region.width - rail_w).max(0.0);
439            // Leading edge: left in LTR, right in RTL.
440            let (rail_x, body_x) = if rtl {
441                (region.x + body_w, region.x)
442            } else {
443                (region.x, region.x + rail_w)
444            };
445            let raw_depth = layout.content_extent() + layout.gutter_extent();
446            let scale = if raw_depth > 0.0 {
447                total / raw_depth
448            } else {
449                0.0
450            };
451            let content = layout.content_extent() * scale;
452            let gutter = layout.gutter_extent() * scale;
453            let rail = Rect::new(rail_x, region.y, rail_w, region.height);
454            match side {
455                // Top: content on top, handle below it (inboard, toward centre).
456                DockSide::Top => SideRects {
457                    rail,
458                    content: Rect::new(body_x, region.y, body_w, content),
459                    handle: Rect::new(body_x, region.y + content, body_w, gutter),
460                },
461                // Bottom: handle on top (inboard, toward centre), content below.
462                _ => SideRects {
463                    rail,
464                    handle: Rect::new(body_x, region.y, body_w, gutter),
465                    content: Rect::new(body_x, region.y + gutter, body_w, content),
466                },
467            }
468        }
469    }
470}
471
472fn extent_along(side: DockSide, region: Rect) -> f32 {
473    if side.is_horizontal_axis() {
474        region.width
475    } else {
476        region.height
477    }
478}
479
480/// A side with only its rail present (content hidden). Leading / trailing rails
481/// hug the outer thickness edge; top / bottom rails are a column on the leading
482/// cross-edge (left in LTR, right in RTL).
483fn rail_only(side: DockSide, region: Rect, rail: f32, rtl: bool) -> SideRects {
484    let mut rects = SideRects::ZERO;
485    rects.rail = match side {
486        DockSide::Leading => Rect::new(region.x, region.y, rail, region.height),
487        DockSide::Trailing => Rect::new(
488            region.x + region.width - rail,
489            region.y,
490            rail,
491            region.height,
492        ),
493        DockSide::Top | DockSide::Bottom => {
494            let rail_x = if rtl {
495                region.x + region.width - rail
496            } else {
497                region.x
498            };
499            Rect::new(rail_x, region.y, rail, region.height)
500        }
501    };
502    rects
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    fn container() -> Rect {
510        Rect::new(0.0, 0.0, 1000.0, 800.0)
511    }
512
513    /// A simple shown side: content `size`, no rail.
514    fn shown(size: f32) -> SideLayout {
515        SideLayout {
516            size,
517            visible_progress: 1.0,
518            gutter: 6.0,
519            min_size: 50.0,
520            rail_thickness: 0.0,
521            has_rail: false,
522        }
523    }
524
525    fn hidden(size: f32) -> SideLayout {
526        SideLayout {
527            visible_progress: 0.0,
528            ..shown(size)
529        }
530    }
531
532    #[test]
533    fn all_hidden_center_fills() {
534        let r = compute_rects(
535            container(),
536            SideLayout::empty(),
537            SideLayout::empty(),
538            SideLayout::empty(),
539            SideLayout::empty(),
540            CornerOwners::default(),
541            false,
542        );
543        assert_eq!(r.center, container());
544    }
545
546    #[test]
547    fn single_leading_side() {
548        let r = compute_rects(
549            container(),
550            shown(200.0),
551            SideLayout::empty(),
552            SideLayout::empty(),
553            SideLayout::empty(),
554            CornerOwners::default(),
555            false,
556        );
557        assert_eq!(r.leading.content.x, 0.0);
558        assert!((r.leading.content.width - 200.0).abs() < 0.01);
559        assert!(
560            (r.center.x - 206.0).abs() < 0.01,
561            "center after content+gutter"
562        );
563        assert!((r.center.width - (1000.0 - 206.0)).abs() < 0.01);
564        assert_eq!(r.trailing, SideRects::ZERO);
565    }
566
567    #[test]
568    fn four_sides_default_corners_inset_center() {
569        let r = compute_rects(
570            container(),
571            shown(200.0),
572            shown(150.0),
573            shown(100.0),
574            shown(120.0),
575            CornerOwners::default(),
576            false,
577        );
578        // Default: top/bottom own corners → they span full width.
579        assert_eq!(r.top.content.x, 0.0);
580        assert!((r.top.content.width - 1000.0).abs() < 0.01);
581        assert_eq!(r.bottom.content.x, 0.0);
582        // Leading column is inset vertically by top + gutter and bottom + gutter.
583        assert!((r.leading.content.y - 106.0).abs() < 0.01);
584        // Center inset on all four sides (size + gutter each).
585        assert!((r.center.x - 206.0).abs() < 0.01);
586        assert!((r.center.y - 106.0).abs() < 0.01);
587    }
588
589    #[test]
590    fn corner_owned_by_leading_extends_column_up() {
591        let mut owners = CornerOwners::default();
592        owners.set(DockCorner::TopLeading, DockSide::Leading);
593        let r = compute_rects(
594            container(),
595            shown(200.0),
596            SideLayout::empty(),
597            shown(100.0),
598            SideLayout::empty(),
599            owners,
600            false,
601        );
602        // Leading now extends to y=0; top starts after the leading column.
603        assert_eq!(r.leading.content.y, 0.0);
604        assert!(
605            (r.top.content.x - 206.0).abs() < 0.01,
606            "top pushed right of leading"
607        );
608    }
609
610    #[test]
611    fn corner_degrades_when_owner_hidden() {
612        // TopLeading declared to Leading, but leading is hidden ⇒ top fills.
613        let mut owners = CornerOwners::default();
614        owners.set(DockCorner::TopLeading, DockSide::Leading);
615        let r = compute_rects(
616            container(),
617            hidden(200.0),
618            SideLayout::empty(),
619            shown(100.0),
620            SideLayout::empty(),
621            owners,
622            false,
623        );
624        assert_eq!(
625            r.top.content.x, 0.0,
626            "top fills since its corner-owner is gone"
627        );
628    }
629
630    #[test]
631    fn visible_progress_half_scales_content() {
632        let mut s = shown(200.0);
633        s.visible_progress = 0.5;
634        let r = compute_rects(
635            container(),
636            s,
637            SideLayout::empty(),
638            SideLayout::empty(),
639            SideLayout::empty(),
640            CornerOwners::default(),
641            false,
642        );
643        assert!((r.leading.content.width - 100.0).abs() < 0.01);
644        assert!(
645            r.leading.handle.width > 0.0,
646            "gutter present while expanding"
647        );
648    }
649
650    #[test]
651    fn hidden_side_with_rail_keeps_rail_drops_handle() {
652        let s = SideLayout {
653            size: 240.0,
654            visible_progress: 0.0,
655            gutter: 6.0,
656            min_size: 60.0,
657            rail_thickness: 48.0,
658            has_rail: true,
659        };
660        let r = compute_rects(
661            container(),
662            s,
663            SideLayout::empty(),
664            SideLayout::empty(),
665            SideLayout::empty(),
666            CornerOwners::default(),
667            false,
668        );
669        assert!((r.leading.rail.width - 48.0).abs() < 0.01, "rail persists");
670        assert!(r.leading.content.width.abs() < 0.01, "content hidden");
671        assert!(r.leading.handle.width.abs() < 0.01, "no handle when hidden");
672        assert!(
673            (r.center.x - 48.0).abs() < 0.01,
674            "center inset only by rail"
675        );
676    }
677
678    #[test]
679    fn shown_side_with_rail_orders_rail_content_handle() {
680        let s = SideLayout {
681            size: 200.0,
682            visible_progress: 1.0,
683            gutter: 6.0,
684            min_size: 60.0,
685            rail_thickness: 48.0,
686            has_rail: true,
687        };
688        let r = compute_rects(
689            container(),
690            s,
691            SideLayout::empty(),
692            SideLayout::empty(),
693            SideLayout::empty(),
694            CornerOwners::default(),
695            false,
696        );
697        assert_eq!(r.leading.rail.x, 0.0);
698        assert!((r.leading.rail.width - 48.0).abs() < 0.01);
699        assert!((r.leading.content.x - 48.0).abs() < 0.01);
700        assert!((r.leading.content.width - 200.0).abs() < 0.01);
701        assert!((r.leading.handle.x - 248.0).abs() < 0.01);
702        assert!((r.center.x - 254.0).abs() < 0.01);
703    }
704
705    /// A top/bottom side with a rail.
706    fn band_with_rail(progress: f32) -> SideLayout {
707        SideLayout {
708            size: 100.0,
709            visible_progress: progress,
710            gutter: 6.0,
711            min_size: 80.0,
712            rail_thickness: 48.0,
713            has_rail: true,
714        }
715    }
716
717    #[test]
718    fn top_rail_is_a_leading_column_not_a_band() {
719        let r = compute_rects(
720            container(),
721            SideLayout::empty(),
722            SideLayout::empty(),
723            band_with_rail(1.0),
724            SideLayout::empty(),
725            CornerOwners::default(),
726            false,
727        );
728        // Rail is a vertical column on the leading (left) edge, spanning the
729        // band depth (content + gutter = 106), NOT a horizontal band.
730        assert_eq!(r.top.rail.x, 0.0);
731        assert!((r.top.rail.width - 48.0).abs() < 0.01);
732        assert!((r.top.rail.height - 106.0).abs() < 0.01);
733        // Content is inboard to the right of the rail.
734        assert!((r.top.content.x - 48.0).abs() < 0.01);
735        assert!((r.top.content.width - (1000.0 - 48.0)).abs() < 0.01);
736        assert!((r.top.content.height - 100.0).abs() < 0.01);
737        // The rail does not push the centre down — only content + gutter do.
738        assert!((r.center.y - 106.0).abs() < 0.01);
739    }
740
741    #[test]
742    fn top_rail_column_mirrors_to_the_right_in_rtl() {
743        let r = compute_rects(
744            container(),
745            SideLayout::empty(),
746            SideLayout::empty(),
747            band_with_rail(1.0),
748            SideLayout::empty(),
749            CornerOwners::default(),
750            true,
751        );
752        assert!(
753            (r.top.rail.x - (1000.0 - 48.0)).abs() < 0.01,
754            "rail on the right in RTL"
755        );
756        assert_eq!(r.top.content.x, 0.0, "content on the left in RTL");
757    }
758
759    #[test]
760    fn hidden_top_with_rail_fully_collapses() {
761        let r = compute_rects(
762            container(),
763            SideLayout::empty(),
764            SideLayout::empty(),
765            band_with_rail(0.0),
766            SideLayout::empty(),
767            CornerOwners::default(),
768            false,
769        );
770        // A hidden top/bottom band hides its vertical rail too (no persistent
771        // column) — the app reveals it again via an external button.
772        assert!(r.top.rail.height.abs() < 0.01, "no rail column when hidden");
773        assert!(r.top.content.height.abs() < 0.01, "no content when hidden");
774        assert_eq!(r.center.y, 0.0, "centre fills — no top inset");
775        assert!((r.center.height - 800.0).abs() < 0.01);
776    }
777
778    #[test]
779    fn bottom_rail_column_keeps_handle_inboard() {
780        let r = compute_rects(
781            container(),
782            SideLayout::empty(),
783            SideLayout::empty(),
784            SideLayout::empty(),
785            band_with_rail(1.0),
786            CornerOwners::default(),
787            false,
788        );
789        // Rail column on the leading edge; band pinned to the container bottom.
790        assert_eq!(r.bottom.rail.x, 0.0);
791        assert!((r.bottom.rail.width - 48.0).abs() < 0.01);
792        // The resize handle sits at the band's TOP (inboard, toward centre),
793        // right of the rail; content is below it.
794        assert!((r.bottom.handle.x - 48.0).abs() < 0.01);
795        assert!((r.bottom.handle.y - (800.0 - 106.0)).abs() < 0.01);
796        assert!(
797            r.bottom.content.y > r.bottom.handle.y,
798            "content below the inboard handle"
799        );
800    }
801
802    #[test]
803    fn trailing_rail_sits_on_the_outer_edge() {
804        let s = SideLayout {
805            size: 200.0,
806            visible_progress: 1.0,
807            gutter: 6.0,
808            min_size: 60.0,
809            rail_thickness: 48.0,
810            has_rail: true,
811        };
812        let r = compute_rects(
813            container(),
814            SideLayout::empty(),
815            s,
816            SideLayout::empty(),
817            SideLayout::empty(),
818            CornerOwners::default(),
819            false,
820        );
821        // Trailing band: handle | content | rail, rail flush to the right edge.
822        assert!((r.trailing.rail.right() - 1000.0).abs() < 0.01);
823        assert!((r.trailing.rail.width - 48.0).abs() < 0.01);
824        assert!(r.trailing.handle.x < r.trailing.content.x);
825        assert!(r.trailing.content.x < r.trailing.rail.x);
826    }
827
828    #[test]
829    fn handle_spans_same_cross_extent_as_content() {
830        let r = compute_rects(
831            container(),
832            shown(200.0),
833            SideLayout::empty(),
834            shown(100.0),
835            SideLayout::empty(),
836            CornerOwners::default(),
837            false,
838        );
839        assert!((r.leading.handle.height - r.leading.content.height).abs() < 0.01);
840        assert!((r.leading.handle.y - r.leading.content.y).abs() < 0.01);
841    }
842
843    #[test]
844    fn center_never_negative_under_over_constraint() {
845        // Sides demand far more than the container.
846        let big = shown(900.0);
847        let r = compute_rects(
848            container(),
849            big,
850            big,
851            big,
852            big,
853            CornerOwners::default(),
854            false,
855        );
856        assert!(r.center.width >= 0.0);
857        assert!(r.center.height >= 0.0);
858        // No band exceeds the container.
859        assert!(r.leading.content.width + r.trailing.content.width <= 1000.0 + 0.01);
860    }
861
862    #[test]
863    fn zero_by_zero_container_no_panic() {
864        let r = compute_rects(
865            Rect::new(0.0, 0.0, 0.0, 0.0),
866            shown(200.0),
867            shown(200.0),
868            shown(100.0),
869            shown(100.0),
870            CornerOwners::default(),
871            false,
872        );
873        assert_eq!(r.center, Rect::new(0.0, 0.0, 0.0, 0.0));
874    }
875
876    #[test]
877    fn degenerate_corner_clamps_to_zero() {
878        // Top + bottom exceed the height with leading owning both side
879        // corners → leading column height clamps to >= 0, no panic.
880        let mut owners = CornerOwners::default();
881        owners.set(DockCorner::TopLeading, DockSide::Leading);
882        owners.set(DockCorner::BottomLeading, DockSide::Leading);
883        let r = compute_rects(
884            Rect::new(0.0, 0.0, 1000.0, 100.0),
885            shown(200.0),
886            SideLayout::empty(),
887            shown(80.0),
888            shown(80.0),
889            owners,
890            false,
891        );
892        assert!(r.leading.content.height >= 0.0);
893    }
894
895    #[test]
896    fn idempotent() {
897        let a = compute_rects(
898            container(),
899            shown(200.0),
900            shown(150.0),
901            shown(100.0),
902            shown(120.0),
903            CornerOwners::default(),
904            false,
905        );
906        let b = compute_rects(
907            container(),
908            shown(200.0),
909            shown(150.0),
910            shown(100.0),
911            shown(120.0),
912            CornerOwners::default(),
913            false,
914        );
915        assert_eq!(a, b);
916    }
917
918    #[test]
919    fn corner_other_side_helper() {
920        assert_eq!(
921            DockCorner::TopLeading.other(DockSide::Leading),
922            DockSide::Top
923        );
924        assert_eq!(
925            DockCorner::TopLeading.other(DockSide::Top),
926            DockSide::Leading
927        );
928    }
929
930    #[test]
931    fn rtl_mirror_is_caller_swap() {
932        // The engine is LTR-only; the caller swaps leading/trailing. Verify a
933        // swapped call mirrors the bands.
934        let ltr = compute_rects(
935            container(),
936            shown(200.0),
937            shown(150.0),
938            SideLayout::empty(),
939            SideLayout::empty(),
940            CornerOwners::default(),
941            false,
942        );
943        let rtl = compute_rects(
944            container(),
945            shown(150.0),
946            shown(200.0),
947            SideLayout::empty(),
948            SideLayout::empty(),
949            CornerOwners::default(),
950            false,
951        );
952        // In RTL the (logical) leading band has trailing's geometry mirrored.
953        assert!((ltr.leading.content.width - rtl.trailing.content.width).abs() < 0.01);
954    }
955}