🔒 Repository is read-only – file editing is disabled.

luna/crates/libluna-widgets/src/containers.rs main

865 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865
//! Kontenery GTK4: `CenterBox`, `Grid`, `Paned`, `Stack`, `ActionBar`, `PopoverMenuBar`.
//!
//! `Box` w modelu libLuny to stos drzewa układu ([`crate::layout::Node::hbox`] /
//! [`crate::layout::Node::vbox`]); tutaj żyją pozostałe kontenery. Te, które są
//! wyłącznie geometrią (CenterBox, Grid), mają statyczne funkcje liczące prostokąty;
//! te ze stanem (Paned, Stack, PopoverMenuBar) rysują się i obsługują trafienia.

use libluna_core::style::weight;
use libluna_core::{Animation, Easing, Point, Rect, Size, Theme};
use libluna_render::TextEngine;

use crate::ctx::PaintCtx;
use crate::display::Orientation;

/// Trzyczęściowy układ: początek (lewo), środek (na środku), koniec (prawo).
#[derive(Debug, Clone, Copy, Default)]
pub struct CenterBox;

impl CenterBox {
    /// Dzieli `rect` na trzy części; środek jest wyśrodkowany względem całego prostokąta.
    pub fn parts(rect: Rect, start_w: f64, center_w: f64, end_w: f64) -> [Rect; 3] {
        let max = rect.width().max(0.0);
        let start_w = start_w.clamp(0.0, max);
        let end_w = end_w.clamp(0.0, max);
        let center_w = center_w.clamp(0.0, max);
        let start = Rect::from_xywh(rect.x(), rect.y(), start_w, rect.height());
        let end = Rect::from_xywh(rect.right() - end_w, rect.y(), end_w, rect.height());
        let center = Rect::from_xywh(
            rect.center().x - center_w / 2.0,
            rect.y(),
            center_w,
            rect.height(),
        );
        [start, center, end]
    }
}

/// Kontener o stałym położeniu dzieci (`GtkFixed`): dziecko umieszczane jest
/// we współrzędnych względem kontenera, niezależnie od reszty układu.
#[derive(Debug, Clone, Copy, Default)]
pub struct Fixed;

impl Fixed {
    /// Prostokąt dziecka o rozmiarze `w × h` w punkcie `(x, y)` względem `rect`.
    pub fn child(rect: Rect, x: f64, y: f64, w: f64, h: f64) -> Rect {
        Rect::from_xywh(rect.x() + x, rect.y() + y, w, h)
    }

    /// Przesuwa gotowy prostokąt dziecka o przesunięcie względne w `rect`.
    pub fn place(rect: Rect, child: Rect) -> Rect {
        Rect::from_xywh(
            rect.x() + child.x(),
            rect.y() + child.y(),
            child.width(),
            child.height(),
        )
    }
}

/// Okno na większą treść (`GtkViewport`): mapuje przesuniętą treść na obszar widoku.
#[derive(Debug, Clone, Copy, Default)]
pub struct Viewport;

impl Viewport {
    /// Prostokąt treści o rozmiarze `content`, przesunięty o `offset`, we współrzędnych `view`.
    pub fn content_rect(view: Rect, content: Size, offset: Point) -> Rect {
        Rect::from_xywh(
            view.x() - offset.x,
            view.y() - offset.y,
            content.width,
            content.height,
        )
    }

    /// Czy treść nie mieści się w widoku (po którejkolwiek osi).
    pub fn overflows(view: Rect, content: Size) -> bool {
        content.width > view.width() + 1e-9 || content.height > view.height() + 1e-9
    }
}

/// Siatka `rows × cols` z równymi komórkami i odstępem z motywu.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Grid {
    rows: usize,
    cols: usize,
}

impl Grid {
    pub fn new(rows: usize, cols: usize) -> Self {
        Self {
            rows: rows.max(1),
            cols: cols.max(1),
        }
    }

    pub fn rows(&self) -> usize {
        self.rows
    }

    pub fn cols(&self) -> usize {
        self.cols
    }

    /// Wszystkie komórki w kolejności wierszami (row-major).
    pub fn cells(&self, rect: Rect, theme: &Theme) -> Vec<Rect> {
        let mut out = Vec::with_capacity(self.rows * self.cols);
        for r in 0..self.rows {
            for c in 0..self.cols {
                out.push(self.cell(rect, theme, r, c));
            }
        }
        out
    }

    /// Prostokąt komórki `(row, col)`.
    pub fn cell(&self, rect: Rect, theme: &Theme, row: usize, col: usize) -> Rect {
        let gap = theme.metrics.container.gap;
        let cw = ((rect.width() - gap * (self.cols as f64 - 1.0)) / self.cols as f64).max(0.0);
        let ch = ((rect.height() - gap * (self.rows as f64 - 1.0)) / self.rows as f64).max(0.0);
        Rect::from_xywh(
            rect.x() + col as f64 * (cw + gap),
            rect.y() + row as f64 * (ch + gap),
            cw,
            ch,
        )
    }

    /// Komórka pod punktem `p` (lub `None`).
    pub fn cell_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<(usize, usize)> {
        if !rect.contains(p) {
            return None;
        }
        let gap = theme.metrics.container.gap;
        let cw = ((rect.width() - gap * (self.cols as f64 - 1.0)) / self.cols as f64).max(0.0);
        let ch = ((rect.height() - gap * (self.rows as f64 - 1.0)) / self.rows as f64).max(0.0);
        let col = ((p.x - rect.x()) / (cw + gap)) as usize;
        let row = ((p.y - rect.y()) / (ch + gap)) as usize;
        Some((row.min(self.rows - 1), col.min(self.cols - 1)))
    }
}

/// Panel dzielony (dwa dzieci + przesuwany separator).
#[derive(Debug, Clone, Copy)]
pub struct Paned {
    orientation: Orientation,
    position: f64,
    min_first: f64,
    min_second: f64,
    hovered: bool,
    dragging: bool,
}

impl Default for Paned {
    fn default() -> Self {
        Self {
            orientation: Orientation::Horizontal,
            position: 0.5,
            min_first: 60.0,
            min_second: 60.0,
            hovered: false,
            dragging: false,
        }
    }
}

impl Paned {
    pub fn new(orientation: Orientation) -> Self {
        Self {
            orientation,
            ..Self::default()
        }
    }

    pub fn orientation(&self) -> Orientation {
        self.orientation
    }

    /// Położenie separatora jako ułamek długości głównej osi (0..1).
    pub fn position(&self) -> f64 {
        self.position
    }

    pub fn set_position(&mut self, fraction: f64) {
        self.position = fraction.clamp(0.0, 1.0);
    }

    pub fn min_sizes(mut self, first: f64, second: f64) -> Self {
        self.min_first = first.max(0.0);
        self.min_second = second.max(0.0);
        self
    }

    pub fn set_hovered(&mut self, hovered: bool) {
        self.hovered = hovered;
    }

    pub fn is_dragging(&self) -> bool {
        self.dragging
    }

    fn clamped_fraction(&self, avail: f64) -> f64 {
        if avail <= 0.0 {
            return 0.0;
        }
        let min_f = (self.min_first / avail).clamp(0.0, 1.0);
        let max_f = (1.0 - self.min_second / avail).clamp(0.0, 1.0);
        self.position.clamp(min_f.min(max_f), max_f.max(min_f))
    }

    /// Trzy prostokąty: pierwszy panel, separator, drugi panel.
    pub fn split(&self, rect: Rect, theme: &Theme) -> (Rect, Rect, Rect) {
        let thick = theme.metrics.container.separator;
        match self.orientation {
            Orientation::Horizontal => {
                let avail = (rect.width() - thick).max(0.0);
                let fw = avail * self.clamped_fraction(avail);
                let first = Rect::from_xywh(rect.x(), rect.y(), fw, rect.height());
                let sep = Rect::from_xywh(rect.x() + fw, rect.y(), thick, rect.height());
                let second = Rect::from_xywh(
                    rect.x() + fw + thick,
                    rect.y(),
                    (avail - fw).max(0.0),
                    rect.height(),
                );
                (first, sep, second)
            }
            Orientation::Vertical => {
                let avail = (rect.height() - thick).max(0.0);
                let fh = avail * self.clamped_fraction(avail);
                let first = Rect::from_xywh(rect.x(), rect.y(), rect.width(), fh);
                let sep = Rect::from_xywh(rect.x(), rect.y() + fh, rect.width(), thick);
                let second = Rect::from_xywh(
                    rect.x(),
                    rect.y() + fh + thick,
                    rect.width(),
                    (avail - fh).max(0.0),
                );
                (first, sep, second)
            }
        }
    }

    pub fn panes(&self, rect: Rect, theme: &Theme) -> (Rect, Rect) {
        let (a, _, b) = self.split(rect, theme);
        (a, b)
    }

    pub fn separator_rect(&self, rect: Rect, theme: &Theme) -> Rect {
        self.split(rect, theme).1
    }

    /// Czy punkt trafia w separator (z tolerancją `hit_pad`).
    pub fn separator_hit(&self, rect: Rect, theme: &Theme, p: Point) -> bool {
        let sep = self.separator_rect(rect, theme);
        let pad = theme.metrics.container.hit_pad;
        match self.orientation {
            Orientation::Horizontal => Rect::from_xywh(
                sep.x() - pad,
                sep.y(),
                sep.width() + 2.0 * pad,
                sep.height(),
            )
            .contains(p),
            Orientation::Vertical => Rect::from_xywh(
                sep.x(),
                sep.y() - pad,
                sep.width(),
                sep.height() + 2.0 * pad,
            )
            .contains(p),
        }
    }

    /// Rozpoczyna przeciąganie separatora (jeśli punkt w niego trafia).
    pub fn begin_drag(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
        if self.separator_hit(rect, theme, p) {
            self.dragging = true;
            true
        } else {
            false
        }
    }

    /// Aktualizuje położenie separatora w trakcie przeciągania.
    pub fn drag_to(&mut self, rect: Rect, theme: &Theme, p: Point) {
        if !self.dragging {
            return;
        }
        let thick = theme.metrics.container.separator;
        match self.orientation {
            Orientation::Horizontal => {
                let avail = (rect.width() - thick).max(0.0);
                if avail > 0.0 {
                    self.set_position((p.x - rect.x()) / avail);
                }
            }
            Orientation::Vertical => {
                let avail = (rect.height() - thick).max(0.0);
                if avail > 0.0 {
                    self.set_position((p.y - rect.y()) / avail);
                }
            }
        }
    }

    pub fn end_drag(&mut self) {
        self.dragging = false;
    }

    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let sep = self.separator_rect(rect, &theme);
        let active = self.dragging || self.hovered;
        let color = if active {
            theme.colors.accent
        } else {
            theme.colors.border
        };
        ctx.scene.fill_rect(sep, color);
        if active {
            // Uchwyt: trzy kropki na środku separatora.
            let c = sep.center();
            for i in -1..=1 {
                let dot = Rect::from_xywh(c.x - 1.5, c.y + i as f64 * 6.0 - 1.5, 3.0, 3.0);
                ctx.scene
                    .fill_rounded_rect(dot, 1.5, theme.colors.accent_fg);
            }
        }
    }
}

/// Sposób przejścia między stronami [`Stack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StackTransition {
    /// Bez animacji (natychmiastowa zmiana).
    #[default]
    None,
    /// Wsuwanie w poziomie.
    SlideHorizontal,
    /// Wsuwanie w pionie.
    SlideVertical,
    /// Przenikanie (crossfade).
    Fade,
}

/// Pojedyncza klatka strony w trakcie przejścia: prostokąt i krycie (0..1).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StackFrame {
    pub rect: Rect,
    pub alpha: f64,
}

/// Stos stron: wiele dzieci, jedno widoczne (bez zakładek).
///
/// W odróżnieniu od [`TabView`](crate::TabView) nie ma paska zakładek — przełączaniem
/// steruje aplikacja (np. przez [`ViewSwitcher`](crate::ViewSwitcher)). Opcjonalnie zmiana
/// strony może być animowana ([`StackTransition`]); postęp animacji sterowany jest
/// z aplikacji przez [`Stack::advance`], a klatki wychodzącej/przychodzącej daje
/// [`Stack::frames`].
#[derive(Debug, Clone, Default)]
pub struct Stack {
    count: usize,
    visible: usize,
    previous: Option<usize>,
    transition: StackTransition,
    progress: Animation,
}

impl Stack {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_count(count: usize) -> Self {
        Self {
            count,
            visible: 0,
            ..Self::default()
        }
    }

    pub fn count(&self) -> usize {
        self.count
    }

    pub fn visible_index(&self) -> usize {
        self.visible
    }

    /// Indeks strony wychodzącej w trakcie animacji przejścia.
    pub fn previous_index(&self) -> Option<usize> {
        self.previous
    }

    /// Ustawia sposób przejścia między stronami.
    pub fn set_transition(&mut self, transition: StackTransition) {
        self.transition = transition;
    }

    pub fn transition(&self) -> StackTransition {
        self.transition
    }

    /// Ustawia krzywą wygładzania przejścia.
    pub fn set_transition_easing(&mut self, easing: Easing) {
        self.progress.set_easing(easing);
    }

    pub fn is_animating(&self) -> bool {
        self.previous.is_some() && self.progress.is_animating()
    }

    /// Ustawia widoczną stronę; przy włączonym przejściu startuje animację.
    pub fn set_visible_index(&mut self, index: usize) {
        if self.count == 0 {
            return;
        }
        let index = index.min(self.count - 1);
        if index == self.visible {
            return;
        }
        if self.transition != StackTransition::None {
            self.previous = Some(self.visible);
            self.progress.jump_to(0.0);
            self.progress.animate_to(1.0);
        }
        self.visible = index;
    }

    pub fn is_visible(&self, index: usize) -> bool {
        index == self.visible
    }

    pub fn push(&mut self) -> usize {
        self.count += 1;
        self.count - 1
    }

    /// Usuwa stronę (przycina wybór, jeśli trzeba).
    pub fn remove(&mut self, index: usize) {
        if index < self.count {
            self.count -= 1;
            if self.visible >= self.count && self.count > 0 {
                self.visible = self.count - 1;
            }
            self.previous = None;
        }
    }

    pub fn next(&mut self) {
        if self.count > 0 {
            self.set_visible_index((self.visible + 1) % self.count);
        }
    }

    pub fn prev(&mut self) {
        if self.count > 0 {
            self.set_visible_index((self.visible + self.count - 1) % self.count);
        }
    }

    /// Krok animacji przejścia; po jej zakończeniu czyści stronę wychodzącą.
    /// Zwraca `true`, gdy animacja trwa dalej.
    pub fn advance(&mut self, dt: f64, duration: f64) -> bool {
        if self.previous.is_none() {
            return false;
        }
        let animating = self.progress.advance(dt, duration);
        if !animating {
            self.previous = None;
        }
        animating
    }

    /// Klatki `(wychodząca, przychodząca)` dla bieżącego postępu przejścia.
    pub fn frames(&self, rect: Rect) -> (StackFrame, StackFrame) {
        let full = |alpha: f64| StackFrame { rect, alpha };
        if self.previous.is_none() {
            return (full(0.0), full(1.0));
        }
        let t = self.progress.value().clamp(0.0, 1.0);
        match self.transition {
            StackTransition::None => (full(0.0), full(1.0)),
            StackTransition::Fade => (full(1.0 - t), full(t)),
            StackTransition::SlideHorizontal => {
                let out = Rect::from_xywh(
                    rect.x() - rect.width() * t,
                    rect.y(),
                    rect.width(),
                    rect.height(),
                );
                let inc = Rect::from_xywh(
                    rect.x() + rect.width() * (1.0 - t),
                    rect.y(),
                    rect.width(),
                    rect.height(),
                );
                (
                    StackFrame {
                        rect: out,
                        alpha: 1.0,
                    },
                    StackFrame {
                        rect: inc,
                        alpha: 1.0,
                    },
                )
            }
            StackTransition::SlideVertical => {
                let out = Rect::from_xywh(
                    rect.x(),
                    rect.y() - rect.height() * t,
                    rect.width(),
                    rect.height(),
                );
                let inc = Rect::from_xywh(
                    rect.x(),
                    rect.y() + rect.height() * (1.0 - t),
                    rect.width(),
                    rect.height(),
                );
                (
                    StackFrame {
                        rect: out,
                        alpha: 1.0,
                    },
                    StackFrame {
                        rect: inc,
                        alpha: 1.0,
                    },
                )
            }
        }
    }
}

/// Pasek akcji (`ActionBar`): pasek u dołu/góry z akcjami po lewej i prawej.
#[derive(Debug, Clone, Copy, Default)]
pub struct ActionBar;

impl ActionBar {
    pub fn height(theme: &Theme) -> f64 {
        theme.metrics.button.height + theme.spacing.xs * 2.0
    }

    /// Prostokąty: `[akcje-początku, akcje-końca]` wewnątrz `rect`.
    pub fn parts(rect: Rect, theme: &Theme, start_w: f64, end_w: f64) -> [Rect; 2] {
        let pad = theme.spacing.md;
        let h = rect.height();
        let inner_w = (rect.width() - 2.0 * pad).max(0.0);
        let start_w = start_w.clamp(0.0, inner_w);
        let end_w = end_w.clamp(0.0, inner_w);
        [
            Rect::from_xywh(rect.x() + pad, rect.y(), start_w, h),
            Rect::from_xywh(rect.right() - pad - end_w, rect.y(), end_w, h),
        ]
    }

    pub fn hit(rect: Rect, p: Point) -> bool {
        rect.contains(p)
    }

    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        ctx.scene.fill_rect(rect, theme.colors.window_bg);
        let line = Rect::from_xywh(rect.x(), rect.top(), rect.width(), 1.0);
        ctx.scene.fill_rect(line, theme.colors.border);
    }
}

/// Pasek menu (`PopoverMenuBar`): rząd przycisków otwierających popovery.
#[derive(Debug, Clone, Default)]
pub struct PopoverMenuBar {
    labels: Vec<String>,
    open: Option<usize>,
    hovered: Option<usize>,
}

impl PopoverMenuBar {
    pub fn new<I, S>(labels: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        Self {
            labels: labels.into_iter().map(Into::into).collect(),
            open: None,
            hovered: None,
        }
    }

    pub fn labels(&self) -> &[String] {
        &self.labels
    }

    pub fn open_index(&self) -> Option<usize> {
        self.open
    }

    pub fn is_open(&self) -> bool {
        self.open.is_some()
    }

    pub fn set_hovered(&mut self, hovered: Option<usize>) {
        self.hovered = hovered;
    }

    pub fn close(&mut self) {
        self.open = None;
    }

    /// Prostokąty przycisków, jeden za drugim, o szerokości dopasowanej do etykiety.
    pub fn buttons(&self, rect: Rect, text: &TextEngine, theme: &Theme) -> Vec<Rect> {
        let m = theme.metrics.button;
        let size = theme.typography.body.size;
        let mut x = rect.x();
        let mut out = Vec::with_capacity(self.labels.len());
        for label in &self.labels {
            let w = text.measure(label, size, weight::NORMAL).width + m.pad_x * 2.0;
            out.push(Rect::from_xywh(x, rect.y(), w, rect.height()));
            x += w;
        }
        out
    }

    pub fn index_at(
        &self,
        rect: Rect,
        text: &TextEngine,
        theme: &Theme,
        p: Point,
    ) -> Option<usize> {
        self.buttons(rect, text, theme)
            .iter()
            .position(|b| b.contains(p))
    }

    /// Obsługuje klik: otwiera popover danego przycisku (lub zamyka, gdy ten sam).
    pub fn click_at(
        &mut self,
        rect: Rect,
        text: &TextEngine,
        theme: &Theme,
        p: Point,
    ) -> Option<usize> {
        match self.index_at(rect, text, theme, p) {
            Some(i) => {
                self.open = if self.open == Some(i) { None } else { Some(i) };
                self.open
            }
            None => None,
        }
    }

    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let size = theme.typography.body.size;
        ctx.scene.fill_rect(rect, theme.colors.headerbar_bg);
        let text = ctx.text;
        for (i, b) in self.buttons(rect, text, &theme).iter().enumerate() {
            if self.open == Some(i) || self.hovered == Some(i) {
                let bg = if self.open == Some(i) {
                    theme.colors.shade.scale_alpha(1.8)
                } else {
                    theme.colors.shade
                };
                ctx.scene.fill_rounded_rect(*b, theme.radius.small, bg);
            }
            ctx.label_center(
                b.center().x,
                b.center().y,
                &self.labels[i],
                size,
                weight::NORMAL,
                theme.colors.text,
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use libluna_render::TextEngine;

    fn theme() -> Theme {
        Theme::light()
    }

    #[test]
    fn fixed_offsets_children() {
        let rect = Rect::from_xywh(10.0, 20.0, 100.0, 100.0);
        let c = Fixed::child(rect, 5.0, 7.0, 30.0, 40.0);
        assert_eq!(c, Rect::from_xywh(15.0, 27.0, 30.0, 40.0));
        let p = Fixed::place(rect, Rect::from_xywh(1.0, 2.0, 3.0, 4.0));
        assert_eq!(p, Rect::from_xywh(11.0, 22.0, 3.0, 4.0));
    }

    #[test]
    fn viewport_maps_scrolled_content() {
        let view = Rect::from_xywh(0.0, 0.0, 100.0, 80.0);
        let content = Size::new(100.0, 300.0);
        assert!(Viewport::overflows(view, content));
        assert!(!Viewport::overflows(view, Size::new(100.0, 80.0)));
        let r = Viewport::content_rect(view, content, Point::new(0.0, 50.0));
        assert_eq!(r.y(), -50.0);
        assert_eq!(r.height(), 300.0);
    }

    #[test]
    fn center_box_places_three_parts() {
        let rect = Rect::from_xywh(0.0, 0.0, 300.0, 40.0);
        let [s, c, e] = CenterBox::parts(rect, 60.0, 100.0, 60.0);
        assert_eq!(s, Rect::from_xywh(0.0, 0.0, 60.0, 40.0));
        assert_eq!(e, Rect::from_xywh(240.0, 0.0, 60.0, 40.0));
        assert_eq!(c.center().x, 150.0);
        assert_eq!(c.width(), 100.0);
    }

    #[test]
    fn grid_cells_partition_with_gap() {
        let theme = theme();
        let rect = Rect::from_xywh(0.0, 0.0, 208.0, 108.0);
        let g = Grid::new(2, 3);
        let cells = g.cells(rect, &theme);
        assert_eq!(cells.len(), 6);
        let gap = theme.metrics.container.gap; // 8
        assert!((cells[0].width() - (208.0 - 2.0 * gap) / 3.0).abs() < 1e-9);
        assert_eq!(cells[0].x(), 0.0);
        assert_eq!(cells[1].x(), cells[0].right() + gap);
        // Ostatnia komórka dotyka prawego brzegu.
        assert!((cells[2].right() - 208.0).abs() < 1e-9);
    }

    #[test]
    fn grid_cell_at_maps_points() {
        let theme = theme();
        let rect = Rect::from_xywh(0.0, 0.0, 208.0, 108.0);
        let g = Grid::new(2, 3);
        assert_eq!(g.cell_at(rect, &theme, Point::new(5.0, 5.0)), Some((0, 0)));
        let c = g.cell(rect, &theme, 1, 2);
        assert_eq!(g.cell_at(rect, &theme, c.center()), Some((1, 2)));
        assert_eq!(g.cell_at(rect, &theme, Point::new(999.0, 5.0)), None);
    }

    #[test]
    fn paned_splits_and_drags() {
        let theme = theme();
        let rect = Rect::from_xywh(0.0, 0.0, 300.0, 100.0);
        let mut p = Paned::new(Orientation::Horizontal);
        let (a, b) = p.panes(rect, &theme);
        assert!((a.width() - b.width()).abs() <= theme.metrics.container.separator);
        let sep = p.separator_rect(rect, &theme);
        assert!(p.begin_drag(rect, &theme, sep.center()));
        assert!(p.is_dragging());
        p.drag_to(rect, &theme, Point::new(60.0, 50.0));
        p.end_drag();
        assert!(!p.is_dragging());
        assert!(p.position() > 0.0 && p.position() < 1.0);
        // Minimalne rozmiary ograniczają przeciągnięcie do krawędzi.
        p.set_position(0.0);
        let (a2, _) = p.panes(rect, &theme);
        assert!(a2.width() >= theme.metrics.container.separator);
    }

    #[test]
    fn paned_vertical_hit_zone() {
        let theme = theme();
        let rect = Rect::from_xywh(0.0, 0.0, 100.0, 300.0);
        let mut p = Paned::new(Orientation::Vertical);
        p.set_position(0.5);
        let sep = p.separator_rect(rect, &theme);
        assert!(p.separator_hit(rect, &theme, sep.center()));
        assert!(!p.separator_hit(rect, &theme, Point::new(10.0, 10.0)));
    }

    #[test]
    fn stack_cycles_and_clamps() {
        let mut s = Stack::with_count(3);
        assert_eq!(s.visible_index(), 0);
        s.next();
        assert_eq!(s.visible_index(), 1);
        s.prev();
        s.prev();
        assert_eq!(s.visible_index(), 2);
        s.set_visible_index(99);
        assert_eq!(s.visible_index(), 2);
        s.remove(2);
        assert_eq!(s.count(), 2);
        assert_eq!(s.visible_index(), 1);
    }

    #[test]
    fn stack_without_transition_hides_outgoing() {
        let s = Stack::with_count(2);
        let rect = Rect::from_xywh(0.0, 0.0, 100.0, 50.0);
        let (out, inc) = s.frames(rect);
        assert_eq!(out.alpha, 0.0);
        assert_eq!(inc.alpha, 1.0);
        assert!(!s.is_animating());
    }

    #[test]
    fn stack_slide_moves_frames() {
        let rect = Rect::from_xywh(0.0, 0.0, 100.0, 50.0);
        let mut s = Stack::with_count(2);
        s.set_transition(StackTransition::SlideHorizontal);
        s.set_visible_index(1);
        assert!(s.is_animating());
        assert_eq!(s.previous_index(), Some(0));
        let (out, inc) = s.frames(rect);
        assert_eq!(out.rect.x(), 0.0);
        assert_eq!(inc.rect.x(), 100.0);
        assert!(!s.advance(1.0, 1.0));
        assert!(!s.is_animating());
        assert_eq!(s.frames(rect).1.rect.x(), 0.0);
    }

    #[test]
    fn stack_fade_crossfades() {
        let rect = Rect::from_xywh(0.0, 0.0, 100.0, 50.0);
        let mut s = Stack::with_count(2);
        s.set_transition(StackTransition::Fade);
        s.set_visible_index(1);
        let (out, inc) = s.frames(rect);
        assert_eq!(out.alpha, 1.0);
        assert_eq!(inc.alpha, 0.0);
        assert!(s.advance(0.5, 1.0)); // EaseInOut w połowie = 0.5
        let (out2, inc2) = s.frames(rect);
        assert!((out2.alpha - 0.5).abs() < 1e-9);
        assert!((inc2.alpha - 0.5).abs() < 1e-9);
    }

    #[test]
    fn action_bar_parts_and_height() {
        let theme = theme();
        let rect = Rect::from_xywh(0.0, 0.0, 400.0, ActionBar::height(&theme));
        let [s, e] = ActionBar::parts(rect, &theme, 80.0, 120.0);
        assert_eq!(s.x(), theme.spacing.md);
        assert!((e.right() - (400.0 - theme.spacing.md)).abs() < 1e-9);
        assert!(ActionBar::hit(rect, rect.center()));
    }

    #[test]
    fn menu_bar_opens_and_toggles() {
        let theme = theme();
        let text = TextEngine::system();
        let rect = Rect::from_xywh(0.0, 0.0, 600.0, 34.0);
        let mut bar = PopoverMenuBar::new(["Plik", "Edycja", "Widok"]);
        let buttons = bar.buttons(rect, &text, &theme);
        assert_eq!(buttons.len(), 3);
        assert_eq!(
            bar.index_at(rect, &text, &theme, buttons[1].center()),
            Some(1)
        );
        assert_eq!(
            bar.click_at(rect, &text, &theme, buttons[1].center()),
            Some(1)
        );
        assert_eq!(bar.open_index(), Some(1));
        // Klik w ten sam przycisk zamyka.
        assert_eq!(bar.click_at(rect, &text, &theme, buttons[1].center()), None);
        assert!(!bar.is_open());
    }
}