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

PaganDE/pagan-compositor/src/window.rs main

1242 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242
//! `PaganWindow` — element przestrzeni (Space) reprezentujący jedno okno.
//!
//! PO CO istnieje ten wrapper, skoro Smithay ma już `Window`?
//!
//! 1. **Dekoracje serwerowe (SSD) w Z-orderze okna.** Pasek tytułu, menu i treść
//!    muszą być narysowane razem i trafiać na wierzch stosu jako jedna grupa.
//!    Smithay pozwala dołączyć własne elementy renderowania tylko do typu, który
//!    sam implementuje `AsRenderElements`. Owinięcie `Window` pozwala nam
//!    "wstrzyknąć" elementy paska bezpośrednio przy elementach tego okna.
//! 2. **Miejsce na logikę dekoracji** (hit-testy, menu, stan aktywności).
//!
//! Pasek i menu rysujemy **własnym shaderem GLSL** (`shaders/rounded_corners.frag`)
//! przez `PixelShaderElement`. Dlatego typy renderowania są specjalizowane do
//! `GlesRenderer` (pixel shadery Smithaya działają tylko z konkretnym
//! rendererem) — to nie ogranicza nas w praktyce, bo używamy `GlesRenderer`
//! zarówno w backendzie winit, jak i udev.
//!
//! Uwaga: w specyfikacji pojawiało się `Space<Window>` — używamy
//! `Space<PaganWindow>` z powodów opisanych wyżej (patrz README).

use std::sync::atomic::{AtomicBool, Ordering};

use smithay::{
    backend::renderer::{
        element::{
            memory::MemoryRenderBufferRenderElement, surface::WaylandSurfaceRenderElement,
            AsRenderElements, Kind,
        },
        gles::{element::PixelShaderElement, GlesRenderer},
        Color32F,
    },
    desktop::{space::SpaceElement, Window, WindowSurface},
    output::Output,
    reexports::{
        wayland_protocols::xdg::shell::server::xdg_toplevel,
        wayland_server::protocol::wl_surface::WlSurface,
    },
    render_elements,
    utils::{user_data::UserDataMap, IsAlive, Logical, Physical, Point, Rectangle, Scale, Size},
    wayland::{
        compositor::{with_states, SurfaceData as WlSurfaceData},
        seat::WaylandFocus,
        shell::xdg::{ToplevelSurface, XdgToplevelSurfaceData},
    },
};

use crate::{icons, render, text, theme};

// --- Dekoracje serwerowe (SSD) ------------------------------------------------
//
// Styl: ciemny, płaski, monochromatyczne glify (bez kolorowych \"świateł\").
// Inspiracja nowoczesnymi pulpitami (GNOME), ale z własnym, chłodnym akcentem
// PaganDE — dlatego ikona zamykania jest delikatnie czerwona, a nie szara.
/// Wysokość paska tytułu rysowanego przez kompozytor.
pub const TITLEBAR_HEIGHT: i32 = 40;
/// Promień pola trafienia przycisku (używany w hit-testingu).
const BUTTON_RADIUS: i32 = 10;
/// Odległość środka skrajnego przycisku (Menu/Zamknij) od krawędzi paska.
const BUTTON_MARGIN: i32 = 24;
/// Odstęp między środkami przycisków okna po prawej.
const BUTTON_SPACING: i32 = 30;
/// Grubość cienkiej linii oddzielającej pasek od treści (px).
const BORDER_HEIGHT: i32 = 1;
/// Lewy margines paska (odstęp pierwszej etykiety/burgera od krawędzi).
const BAR_PADDING_X: i32 = 12;
/// Wewnętrzny odstęp poziomy klikalnej etykiety menu (z każdej strony).
const MENU_LABEL_PAD_X: i32 = 10;
/// Odstęp górny/dolny pola etykiety menu od krawędzi paska.
const MENU_LABEL_PAD_Y: i32 = 6;

/// Tło panelu menu wyprowadzamy z koloru paska (patrz `render_elements`).
/// Szerokość panelu menu okna.
const MENU_PANEL_WIDTH: i32 = 176;
/// Wysokość jednego wiersza menu okna.
const MENU_ROW_HEIGHT: i32 = 36;
/// Wewnętrzny odstęp panelu menu (góra/dół).
const MENU_PANEL_PADDING: i32 = 6;
/// Liczba wierszy menu okna (minimalizuj / maksymalizuj / zamknij).
const MENU_ROWS: i32 = 3;
/// Poziomy środek ikony w wierszu menu (liczony od lewej krawędzi panelu).
const MENU_ROW_ICON_X: i32 = 24;
/// Odstęp napisu wiersza menu od jego ikony (px).
const MENU_ROW_LABEL_OFFSET: i32 = 18;
/// Lewy margines napisu w wierszu MENU APLIKACJI (bez ikony, px).
const MENU_LABEL_ROW_PAD_X: i32 = 16;

/// Który przycisk paska tytułu trafiono.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TitlebarButton {
    /// „Burger" po lewej — rozwija menu okna.
    Menu,
    Minimize,
    Maximize,
    Close,
}

/// Co pokazuje rozwinięcie spod burgera (gdy w pasku nie ma miejsca na etykiety).
///
/// PO CO rozróżniamy: menu APLIKACJI (Plik/Edycja/…, publikowane przez klienta)
/// to coś innego niż menu OKNA (minimalizuj/maksymalizuj/zamknij). Akcje okna
/// mamy już jako przyciski po prawej, więc burger bez własnego menu klienta jest
/// tylko awaryjnym menu okna — a nie duplikatem przycisków.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SsdMenuKind {
    /// Menu aplikacji: etykiety górnego poziomu od klienta. Klik odsyłamy
    /// klientowi (`clicked`), bo tylko on zna zawartość rozwinięcia.
    AppMenu,
    /// Menu okna dla klientów bez własnego menu (np. terminale X11).
    Window,
}

/// Stan dekoracji okna trzymany w danych użytkownika okna.
///
/// PO CO w danych okna, a nie w `MyCompositor`: dekorację rysujemy razem z oknem
/// w `AsRenderElements`, a ta metoda nie widzi stanu kompozytora. `AtomicBool`
/// wystarcza — potrzebujemy tylko flag „menu otwarte" i „okno aktywne".
#[derive(Debug, Default)]
struct SsdState {
    open: AtomicBool,
    focused: AtomicBool,
}

// Styl paska i menu opisują stałe poniżej; kolory pochodzą z motywu
// (`theme::Theme`) — patrz `~/.config/pagan/theme.conf`.

/// Marker w danych powierzchni: klient **negocjował** dekoracje
/// (`zxdg_toplevel_decoration_v1`).
///
/// PO CO: tylko wtedy możemy wymusić tryb serwerowy i narysować własny pasek
/// tytułu. Klienci, którzy tego protokołu nie znają (np. GTK3 — `xfce4-terminal`,
/// `thunar`), rysują WŁASNE dekoracje (CSD). Dołożenie drugiego paska
/// nakładałoby je na siebie — to właśnie efekt „przesuniętej dekoracji”.
#[derive(Debug)]
pub struct NegotiatedSsd;

/// Zapisuje fakt negocjacji dekoracji przez klienta (wołane z `new_decoration`).
///
/// PO CO osobna funkcja: `XdgDecorationHandler` żyje w `state.rs`, a decyzję
/// „czy rysować SSD” podejmuje `PaganWindow` w tym module — marker w danych
/// powierzchni łączy oba miejsca bez przeciągania stanu przez cały kompozytor.
pub fn mark_ssd_negotiated(toplevel: &ToplevelSurface) {
    with_states(toplevel.wl_surface(), |states| {
        states.data_map.insert_if_missing(|| NegotiatedSsd);
    });
}

/// Czy klient negocjował dekoracje serwerowe (patrz `PaganWindow::wants_ssd`)?
fn ssd_negotiated(toplevel: &ToplevelSurface) -> bool {
    with_states(toplevel.wl_surface(), |states| {
        states.data_map.get::<NegotiatedSsd>().is_some()
    })
}

/// Reguła decyzji o dekoracjach serwerowych — CZYSTA, żeby dała się przetestować
/// bez żywych powierzchni Waylanda.
///
/// * `x11`: `Some(override_redirect)` dla okna X11, `None` dla okna Waylanda.
/// * `toplevel`: czy to okno najwyższego poziomu (nie popup/subsurface).
/// * `negotiated`: czy klient wynegocjował `zxdg_toplevel_decoration_v1`.
///
/// PO CO taka separacja: X11 nie zna pojęcia CSD, więc zawsze dostaje nasz pasek
/// (poza menu/podpowiedziami `override-redirect`). Okno Waylanda dostaje pasek
/// tylko wtedy, gdy oddało dekoracje kompozytorowi — inaczej (np. GTK3)
/// nakładalibyśmy drugi pasek na jego własny.
fn ssd_decision(x11: Option<bool>, toplevel: bool, negotiated: bool) -> bool {
    match x11 {
        Some(override_redirect) => !override_redirect,
        None => toplevel && negotiated,
    }
}

/// Publiczny dostęp do faktu negocjacji — używany w logach diagnostycznych.
pub fn decoration_negotiated(window: &PaganWindow) -> bool {
    window.toplevel().is_some_and(ssd_negotiated)
}

#[derive(Debug, Clone, PartialEq)]
pub struct PaganWindow(pub Window);

impl PaganWindow {
    /// Tworzy element przestrzeni z powierzchni toplevel (xdg-shell).
    pub fn new(toplevel: ToplevelSurface) -> Self {
        PaganWindow(Window::new_wayland_window(toplevel))
    }

    /// Tworzy element przestrzeni z okna X11 (XWayland).
    ///
    /// PO CO osobny konstruktor: aplikacje X11 są zarządzane przez nasz `X11Wm`,
    /// a nie przez xdg-shell — ale w przestrzeni i w renderowaniu zachowują się
    /// identycznie (XWayland daje każdemu oknu `wl_surface`).
    pub fn new_x11(surface: smithay::xwayland::X11Surface) -> Self {
        PaganWindow(Window::new_x11_window(surface))
    }

    /// Powierzchnia X11 tego okna (jeśli to okno X11 — z XWayland).
    pub fn x11_surface(&self) -> Option<&smithay::xwayland::X11Surface> {
        self.0.x11_surface()
    }

    /// Powierzchnia Waylanda tego okna.
    pub fn wl_surface(&self) -> Option<std::borrow::Cow<'_, WlSurface>> {
        self.0.wl_surface()
    }

    /// Geometria okna w przestrzeni logicznej (bez ramki SSD).
    pub fn geometry(&self) -> Rectangle<i32, Logical> {
        SpaceElement::geometry(&self.0)
    }

    /// Bazowy toplevel (configure/move/resize).
    pub fn toplevel(&self) -> Option<&ToplevelSurface> {
        self.0.toplevel()
    }

    /// Musi być wołane po commicie powierzchni okna, żeby zaktualizować bbox.
    pub fn on_commit(&self) {
        self.0.on_commit()
    }

    /// Uruchamia domknięcie na wszystkich powierzchniach okna.
    pub fn with_surfaces<F>(&self, processor: F)
    where
        F: FnMut(&WlSurface, &WlSurfaceData),
    {
        self.0.with_surfaces(processor)
    }

    /// Wysyła frame callback do klienta (VSync).
    pub fn send_frame<T, F>(
        &self,
        output: &Output,
        time: T,
        throttle: Option<std::time::Duration>,
        primary: F,
    ) where
        T: Into<std::time::Duration>,
        F: FnMut(&WlSurface, &WlSurfaceData) -> Option<Output> + Copy,
    {
        self.0.send_frame(output, time, throttle, primary)
    }

    /// Powierzchnia bazowa (Wayland) — w tym szkieletie zawsze Wayland (CSD).
    ///
    /// PO CO zwracamy referencję: `Window::underlying_surface()` zwraca `&WindowSurface`,
    /// a klonowanie powierzchni przy każdym odczycie byłoby marnotrawstwem.
    pub fn underlying_surface(&self) -> &WindowSurface {
        self.0.underlying_surface()
    }

    /// Trafienie w okno: zwraca powierzchnię pod punktem (lokalnie w oknie).
    pub fn surface_under(
        &self,
        location: impl Into<Point<f64, Logical>>,
        window_type: smithay::desktop::WindowSurfaceType,
    ) -> Option<(WlSurface, Point<i32, Logical>)> {
        self.0.surface_under(location, window_type)
    }

    pub fn user_data(&self) -> &UserDataMap {
        self.0.user_data()
    }

    /// Czy to okno ma dostać nasz pasek tytułu (dekoracja serwerowa)?
    ///
    /// PO CO wymuszamy to dla WSZYSTKICH okien najwyższego poziomu: chcemy
    /// spójnego wyglądu całego środowiska. Aplikacje obce (X11, GTK) nie znają
    /// naszego stylu, więc gdyby rysowały własny pasek, każda wyglądałaby inaczej.
    /// Negocjacja `xdg-decoration` i tak wymusza tryb serwerowy (patrz `state.rs`),
    /// więc klient nie dorysuje drugiego paska.
    ///
    /// WYJĄTEK: okna X11 typu `override-redirect` (menu, podpowiedzi, popupy,
    /// ikony przeciągania) to elementy aplikacji, a nie samodzielne okna —
    /// pasek nad nimi wyglądałby jak „przesunięta dekoracja” przyklejona do menu.
    pub fn wants_ssd(&self) -> bool {
        let x11 = self
            .x11_surface()
            .map(|surface| surface.is_override_redirect());
        ssd_decision(
            x11,
            self.toplevel().is_some(),
            self.toplevel().is_some_and(ssd_negotiated),
        )
    }

    /// Dane użytkownika okna z flagami dekoracji (tworzone leniwie).
    fn ssd_state(&self) -> &SsdState {
        self.0
            .user_data()
            .get_or_insert_threadsafe(SsdState::default)
    }

    /// Czy rozwinięte jest menu okna (dropdown z burgera).
    pub fn ssd_menu_open(&self) -> bool {
        self.ssd_state().open.load(Ordering::Relaxed)
    }

    /// Ustawia stan menu okna (otwarte/zamknięte).
    pub fn set_ssd_menu_open(&self, open: bool) {
        self.ssd_state().open.store(open, Ordering::Relaxed);
    }

    /// Czy okno jest aktywne (ma fokus klawiatury).
    ///
    /// PO CO: firmowy wygląd PaganDE wyróżnia okno aktywne (jaśniejszy, płaski
    /// pasek), a pozostałe przygasza. Bez tego stanu wszystkie okna wyglądałyby
    /// identycznie.
    pub fn is_focused(&self) -> bool {
        self.ssd_state().focused.load(Ordering::Relaxed)
    }

    /// Ustawia stan aktywności okna (wołane z `SeatHandler::focus_changed`).
    pub fn set_focused(&self, focused: bool) {
        self.ssd_state().focused.store(focused, Ordering::Relaxed);
    }

    /// Tytuł okna (Wayland: `xdg_toplevel`; X11: `_NET_WM_NAME`).
    pub fn window_title(&self) -> Option<String> {
        if let Some(toplevel) = self.toplevel() {
            let surface = toplevel.wl_surface().clone();
            return with_states(&surface, |states| {
                states
                    .data_map
                    .get::<XdgToplevelSurfaceData>()
                    .and_then(|data| data.lock().ok())
                    .and_then(|data| data.title.clone())
            })
            .filter(|value| !value.is_empty());
        }
        self.x11_surface()
            .map(|x11| x11.title())
            .filter(|value| !value.is_empty())
    }

    /// Czy pasek ma pokazać pełne etykiety menu (zamiast burgera)?
    ///
    /// Warunki: klient opublikował menu (własny protokół `pagan_app_menu_v1`)
    /// ORAZ okno jest wystarczająco szerokie. Wąskie okno zwija menu do burgera,
    /// żeby nie zabrakło miejsca na tytuł.
    pub fn ssd_bar_uses_labels(&self) -> bool {
        !crate::app_menu::labels(self).is_empty()
            && self.geometry().size.w >= theme::current().ssd_menu_min_width
    }

    /// Prostokąty pól etykiet menu aplikacji (układ w pasku SSD).
    pub fn ssd_menu_label_rects(
        &self,
        location: Point<i32, Logical>,
    ) -> Vec<Rectangle<i32, Logical>> {
        let labels = crate::app_menu::labels(self);
        if labels.is_empty() {
            return Vec::new();
        }
        let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
        let widths = menu_label_widths(&refs, theme::current().ssd_font_px as f64);
        menu_label_rects(self.titlebar_rect(location), &widths)
    }

    /// Prostokąt pola konkretnej etykiety menu (do wyliczenia pozycji rozwinięcia).
    pub fn ssd_menu_label_rect(
        &self,
        location: Point<i32, Logical>,
        index: usize,
    ) -> Option<Rectangle<i32, Logical>> {
        self.ssd_menu_label_rects(location).get(index).copied()
    }

    /// Indeks etykiety menu pod punktem (jeśli któraś trafiona).
    pub fn ssd_menu_label_at(
        &self,
        location: Point<i32, Logical>,
        point: Point<f64, Logical>,
    ) -> Option<usize> {
        if !self.wants_ssd() || !self.ssd_bar_uses_labels() {
            return None;
        }
        self.ssd_menu_label_rects(location)
            .iter()
            .position(|rect| rect.to_f64().contains(point))
    }

    /// Czy okno jest zmaksymalizowane (Wayland: stan xdg, X11: `_NET_WM_STATE`).
    pub fn is_maximized(&self) -> bool {
        if let Some(toplevel) = self.toplevel() {
            let mut maximized = false;
            toplevel.with_pending_state(|state| {
                maximized = state.states.contains(xdg_toplevel::State::Maximized);
            });
            return maximized;
        }
        if let Some(x11) = self.x11_surface() {
            return x11.is_maximized();
        }
        false
    }

    /// Prostokąt paska tytułu dla okna postawionego w `location` (układ logiczny).
    pub fn titlebar_rect(&self, location: Point<i32, Logical>) -> Rectangle<i32, Logical> {
        bar_rect(location, self.geometry().size.w)
    }

    /// Prostokąt panelu rozwiniętego menu okna (pod burgerem).
    ///
    /// Wysokość zależy od zawartości: menu aplikacji ma tyle wierszy, ile etykiet
    /// opublikował klient, a menu okna zawsze trzy (minimalizuj/maksymalizuj/zamknij).
    pub fn ssd_menu_rect(&self, location: Point<i32, Logical>) -> Rectangle<i32, Logical> {
        menu_panel_rect(self.titlebar_rect(location), self.menu_row_count())
    }

    /// Co pokazuje rozwinięcie spod burgera (patrz `SsdMenuKind`).
    ///
    /// Skoro burger pojawia się tylko wtedy, gdy w pasku brakuje miejsca na
    /// etykiety, rozwinięcie pokazuje menu APLIKACJI dokładnie wtedy, gdy klient
    /// je opublikował — a menu okna tylko jako awaryjne dla obcych aplikacji.
    pub fn ssd_menu_kind(&self) -> SsdMenuKind {
        if crate::app_menu::labels(self).is_empty() {
            SsdMenuKind::Window
        } else {
            SsdMenuKind::AppMenu
        }
    }

    /// Liczba wierszy rozwiniętego menu (używana też do wyliczenia wysokości panelu).
    fn menu_row_count(&self) -> i32 {
        match self.ssd_menu_kind() {
            SsdMenuKind::AppMenu => crate::app_menu::labels(self).len().max(1) as i32,
            SsdMenuKind::Window => MENU_ROWS,
        }
    }

    /// Czy punkt leży wewnątrz panelu menu (niezależnie od wiersza).
    pub fn ssd_menu_contains(
        &self,
        location: Point<i32, Logical>,
        point: Point<f64, Logical>,
    ) -> bool {
        self.ssd_menu_open() && self.ssd_menu_rect(location).to_f64().contains(point)
    }

    /// Który wiersz menu OKNA trafiono (minimalizuj / maksymalizuj / zamknij).
    pub fn ssd_menu_row_at(
        &self,
        location: Point<i32, Logical>,
        point: Point<f64, Logical>,
    ) -> Option<TitlebarButton> {
        if !self.ssd_menu_open() || self.ssd_menu_kind() != SsdMenuKind::Window {
            return None;
        }
        menu_row_at(self.ssd_menu_rect(location), point)
    }

    /// Indeks etykiety MENU APLIKACJI trafionej w rozwinięciu (nie w pasku).
    ///
    /// PO CO osobno od `ssd_menu_row_at`: wiersze menu aplikacji odpowiadają
    /// etykietom klienta, więc klik przekazujemy klientowi, a nie wykonujemy
    /// akcji okna.
    pub fn ssd_menu_label_row_at(
        &self,
        location: Point<i32, Logical>,
        point: Point<f64, Logical>,
    ) -> Option<usize> {
        if !self.ssd_menu_open() || self.ssd_menu_kind() != SsdMenuKind::AppMenu {
            return None;
        }
        let panel = self.ssd_menu_rect(location);
        if !panel.to_f64().contains(point) {
            return None;
        }
        let row =
            ((point.y as i32 - panel.loc.y - MENU_PANEL_PADDING) / MENU_ROW_HEIGHT).max(0) as usize;
        let count = crate::app_menu::labels(self).len();
        (row < count).then_some(row)
    }

    /// Czy punkt (w układzie przestrzeni) leży na pasku tytułu tego okna.
    pub fn titlebar_contains(
        &self,
        location: Point<i32, Logical>,
        point: Point<f64, Logical>,
    ) -> bool {
        self.wants_ssd() && self.titlebar_rect(location).to_f64().contains(point)
    }

    /// Który przycisk paska trafiono (jeśli którykolwiek).
    pub fn titlebar_button_at(
        &self,
        location: Point<i32, Logical>,
        point: Point<f64, Logical>,
    ) -> Option<TitlebarButton> {
        if !self.wants_ssd() {
            return None;
        }
        let bar = self.titlebar_rect(location);
        // W trybie etykiet menu NIE ma burgera — trafiają tylko przyciski okna.
        // (Etykiety obsługuje osobno `ssd_menu_label_at`.)
        if self.ssd_bar_uses_labels() {
            window_button_at(bar, point)
        } else {
            bar_button_at(bar, point)
        }
    }
}

// --- Czysta geometria paska SSD (bez zależności od Waylanda) -------------------
//
// PO CO osobne funkcje: logika trafień to najłatwiejsze do zepsucia miejsce
// w SSD, a metody wyżej wymagają żywego `ToplevelSurface`. Wydzielenie geometrii
// pozwala przetestować ją jednostkowo, bez kompozytora i bez XTestu.

/// Prostokąt paska tytułu dla treści o pozycji `content_loc` i zadanej szerokości.
fn bar_rect(content_loc: Point<i32, Logical>, content_width: i32) -> Rectangle<i32, Logical> {
    Rectangle::new(
        Point::from((content_loc.x, content_loc.y - TITLEBAR_HEIGHT)),
        Size::from((content_width, TITLEBAR_HEIGHT)),
    )
}

/// Środek przycisku „Menu" (burger). Ta sama oś Y co przyciski okna —
/// to wymóg wizualny: menu i przyciski muszą leżeć w jednej linii.
fn menu_center(bar: Rectangle<i32, Logical>) -> Point<i32, Logical> {
    Point::from((bar.loc.x + BUTTON_MARGIN, bar.loc.y + TITLEBAR_HEIGHT / 2))
}

/// Środki trzech przycisków paska (od prawej: zamknij, maks., min.).
fn window_buttons(bar: Rectangle<i32, Logical>) -> [(TitlebarButton, Point<i32, Logical>); 3] {
    let center_y = bar.loc.y + TITLEBAR_HEIGHT / 2;
    let right = bar.loc.x + bar.size.w;
    [
        (
            TitlebarButton::Close,
            Point::from((right - BUTTON_MARGIN, center_y)),
        ),
        (
            TitlebarButton::Maximize,
            Point::from((right - BUTTON_MARGIN - BUTTON_SPACING, center_y)),
        ),
        (
            TitlebarButton::Minimize,
            Point::from((right - BUTTON_MARGIN - BUTTON_SPACING * 2, center_y)),
        ),
    ]
}

/// Panel rozwijanego menu (tuż pod belką paska). `rows` = liczba wierszy.
fn menu_panel_rect(bar: Rectangle<i32, Logical>, rows: i32) -> Rectangle<i32, Logical> {
    Rectangle::new(
        Point::from((bar.loc.x + MENU_PANEL_PADDING, bar.loc.y + TITLEBAR_HEIGHT)),
        Size::from((
            MENU_PANEL_WIDTH,
            MENU_PANEL_PADDING * 2 + MENU_ROW_HEIGHT * rows.max(1),
        )),
    )
}

/// Przycisk paska pod punktem (menu po lewej albo przycisk okna po prawej).
///
/// Promień trafienia jest o 3 px większy niż promień rysowania — mały „luz"
/// znacząco poprawia ergonomię klikania niewielkich glifów.
fn bar_button_at(
    bar: Rectangle<i32, Logical>,
    point: Point<f64, Logical>,
) -> Option<TitlebarButton> {
    if within_radius(menu_center(bar), point) {
        return Some(TitlebarButton::Menu);
    }
    window_button_at(bar, point)
}

/// Przycisk OKNA pod punktem (bez burgera) — używane w trybie etykiet menu.
fn window_button_at(
    bar: Rectangle<i32, Logical>,
    point: Point<f64, Logical>,
) -> Option<TitlebarButton> {
    for (button, center) in window_buttons(bar) {
        if within_radius(center, point) {
            return Some(button);
        }
    }
    None
}

/// Czy punkt trafia w przycisk o zadanym środku (promień z „luzem").
fn within_radius(center: Point<i32, Logical>, point: Point<f64, Logical>) -> bool {
    let hit_radius_sq = ((BUTTON_RADIUS + 3) as f64).powi(2);
    let dx = point.x - center.x as f64;
    let dy = point.y - center.y as f64;
    dx * dx + dy * dy <= hit_radius_sq
}

/// Wiersz menu okna pod punktem (prostokąty wierszy są rozłączne i pokrywają
/// cały panel, więc wystarczy podzielić wysokość panelu przez wysokość wiersza).
fn menu_row_at(
    panel: Rectangle<i32, Logical>,
    point: Point<f64, Logical>,
) -> Option<TitlebarButton> {
    let panel = panel.to_f64();
    if !panel.contains(point) {
        return None;
    }
    let rel = point.y - panel.loc.y - MENU_PANEL_PADDING as f64;
    match (rel / MENU_ROW_HEIGHT as f64).floor() as i32 {
        0 => Some(TitlebarButton::Minimize),
        1 => Some(TitlebarButton::Maximize),
        2 => Some(TitlebarButton::Close),
        _ => None,
    }
}

impl IsAlive for PaganWindow {
    fn alive(&self) -> bool {
        self.0.alive()
    }
}

impl WaylandFocus for PaganWindow {
    fn wl_surface(&self) -> Option<std::borrow::Cow<'_, WlSurface>> {
        self.0.wl_surface()
    }
}

/// Delegujemy cały kontrakt SpaceElement do wewnętrznego okna.
impl SpaceElement for PaganWindow {
    fn geometry(&self) -> Rectangle<i32, Logical> {
        SpaceElement::geometry(&self.0)
    }
    fn bbox(&self) -> Rectangle<i32, Logical> {
        let bbox = SpaceElement::bbox(&self.0);
        if self.wants_ssd() {
            // Rama = pasek + treść; bbox musi go obejmować (rejon uszkodzeń, wyjścia).
            Rectangle::new(
                Point::from((bbox.loc.x, bbox.loc.y - TITLEBAR_HEIGHT)),
                Size::from((bbox.size.w, bbox.size.h + TITLEBAR_HEIGHT)),
            )
        } else {
            bbox
        }
    }
    fn is_in_input_region(&self, point: &Point<f64, Logical>) -> bool {
        SpaceElement::is_in_input_region(&self.0, point)
    }
    fn z_index(&self) -> u8 {
        SpaceElement::z_index(&self.0)
    }
    fn set_activate(&self, activated: bool) {
        SpaceElement::set_activate(&self.0, activated)
    }
    fn output_enter(&self, output: &Output, overlap: Rectangle<i32, Logical>) {
        SpaceElement::output_enter(&self.0, output, overlap)
    }
    fn output_leave(&self, output: &Output) {
        SpaceElement::output_leave(&self.0, output)
    }
    fn refresh(&self) {
        SpaceElement::refresh(&self.0)
    }
}

// Elementy renderowania okna: pasek SSD (własny pixel shader), tekst i treść.
//
// Wariant `<=GlesRenderer>` mówi makru, że enum jest specjalizowany do
// konkretnego renderera — dzięki temu możemy trzymać w nim `PixelShaderElement`,
// który implementuje `RenderElement` wyłącznie dla `GlesRenderer`.
render_elements! {
    pub WindowRenderElement<=GlesRenderer>;
    Window=WaylandSurfaceRenderElement<GlesRenderer>,
    // Tekst (tytuł, etykiety menu) rasteryzowany Cairo i wysłany jako tekstura.
    Text=MemoryRenderBufferRenderElement<GlesRenderer>,
    // Element rysowany pixel shaderem: beleczka paska i tło menu SSD.
    // (Jedna wariant, bo makro generuje `From` per typ — dwa warianty tego samego
    // typu kolidowałyby.)
    Pixel=PixelShaderElement,
}

impl std::fmt::Debug for WindowRenderElement {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Window(arg0) => f.debug_tuple("Window").field(arg0).finish(),
            Self::Text(arg0) => f.debug_tuple("Text").field(arg0).finish(),
            Self::Pixel(arg0) => f.debug_tuple("Pixel").field(arg0).finish(),
            Self::_GenericCatcher(arg0) => f.debug_tuple("_GenericCatcher").field(arg0).finish(),
        }
    }
}

impl AsRenderElements<GlesRenderer> for PaganWindow {
    type RenderElement = WindowRenderElement;

    fn render_elements<C: From<Self::RenderElement>>(
        &self,
        renderer: &mut GlesRenderer,
        location: Point<i32, Physical>,
        scale: Scale<f64>,
        alpha: f32,
    ) -> Vec<C> {
        let mut elements = Vec::<WindowRenderElement>::new();

        // Kolejność ma znaczenie: Smithay oczekuje elementów OD PRZODU DO TYŁU
        // (indeks 0 = rysowany na wierzchu; `render_output` rysuje listę od końca).
        // Dlatego menu okna i treść idą na początek listy, a belka paska na koniec
        // — czyli POD tekstem i glifami.

        let logical_loc = location.to_f64().to_logical(scale).to_i32_round::<i32>();
        let bar = Rectangle::new(
            Point::from((logical_loc.x, logical_loc.y - TITLEBAR_HEIGHT)),
            Size::from((self.geometry().size.w, TITLEBAR_HEIGHT)),
        );

        // Firmowy wygląd PaganDE pochodzi z motywu (`~/.config/pagan/theme.conf`) —
        // zmiana kolorów jest widoczna w następnej klatce bez restartu.
        let theme = theme::current();
        let focused = self.is_focused();
        let radius = theme.ssd_radius.max(0.0);
        // Tekst i ikony aktywnego okna są jaśniejsze; nieaktywnego — przygaszone.
        // Kolor RGB podajemy wprost do rasteryzera (Cairo), stąd trójki `f64`.
        let (text_color, close_color) = if focused {
            (theme.ssd_text, theme.ssd_icon_close)
        } else {
            (theme.ssd_text_unfocused, theme.ssd_text_unfocused)
        };
        let text_rgb = (
            text_color.r() as f64,
            text_color.g() as f64,
            text_color.b() as f64,
        );
        let close_rgb = (
            close_color.r() as f64,
            close_color.g() as f64,
            close_color.b() as f64,
        );
        let font_px = theme.ssd_font_px as f64;
        let icon_px = theme.ssd_icon_px;

        // 0) Menu okna (dropdown z burgera) — musi być NAD treścią okna, dlatego
        //    trafia na sam przód listy. Rysujemy je tutaj (a nie w `render.rs`),
        //    bo jest nierozerwalnie związane z konkretnym oknem i jego Z-orderem.
        if self.wants_ssd() && self.ssd_menu_open() {
            let panel = self.ssd_menu_rect(logical_loc);
            // Kolejność: NAJPIERW treść wierszy, POTEM tło panelu — lista jest
            // front-to-back, więc tło (wypchnięte później) wyląduje POD napisami.
            match self.ssd_menu_kind() {
                SsdMenuKind::AppMenu => {
                    // Menu APLIKACJI: same etykiety górnego poziomu. Rozwinięcie
                    // rysuje KLIENT — my tylko przekazujemy klik na wiersz.
                    for (row, label) in crate::app_menu::labels(self).into_iter().enumerate() {
                        let center_y = panel.loc.y
                            + MENU_PANEL_PADDING
                            + MENU_ROW_HEIGHT * row as i32
                            + MENU_ROW_HEIGHT / 2;
                        let width = text::measure(&label, font_px)
                            .map(|size| size.w)
                            .unwrap_or(0);
                        push_text(
                            &mut elements,
                            renderer,
                            &label,
                            font_px,
                            text_rgb,
                            Point::from((panel.loc.x + MENU_LABEL_ROW_PAD_X + width / 2, center_y)),
                            scale,
                        );
                    }
                }
                SsdMenuKind::Window => {
                    for (row, button) in [
                        TitlebarButton::Minimize,
                        TitlebarButton::Maximize,
                        TitlebarButton::Close,
                    ]
                    .into_iter()
                    .enumerate()
                    {
                        let center = Point::from((
                            panel.loc.x + MENU_ROW_ICON_X,
                            panel.loc.y
                                + MENU_PANEL_PADDING
                                + MENU_ROW_HEIGHT * row as i32
                                + MENU_ROW_HEIGHT / 2,
                        ));
                        push_icon(
                            &mut elements,
                            renderer,
                            button,
                            center,
                            icon_px,
                            if button == TitlebarButton::Close {
                                close_rgb
                            } else {
                                text_rgb
                            },
                            scale,
                        );
                        let label = menu_row_label(button);
                        let text_x = panel.loc.x + MENU_ROW_ICON_X + MENU_ROW_LABEL_OFFSET;
                        if let Some(texture) = text::texture(label, font_px, text_rgb) {
                            let loc = Point::<f64, Logical>::from((
                                text_x as f64,
                                (center.y - texture.size.h / 2) as f64,
                            ))
                            .to_physical(scale);
                            if let Ok(element) = MemoryRenderBufferRenderElement::from_buffer(
                                renderer,
                                loc,
                                &texture.buffer,
                                None,
                                None,
                                None,
                                Kind::Unspecified,
                            ) {
                                elements.push(WindowRenderElement::Text(element));
                            }
                        }
                    }
                }
            }
            let panel_bg = Color32F::new(
                theme.ssd_bar.r(),
                theme.ssd_bar.g(),
                theme.ssd_bar.b(),
                0.99,
            );
            if let Some(element) = render::rounded_rect_element(renderer, panel, panel_bg, 10.0) {
                elements.push(WindowRenderElement::Pixel(element));
            }
        }

        // 1) Treść okna (klient narysował już swoje wnętrze).
        elements.extend(AsRenderElements::<GlesRenderer>::render_elements::<
            WindowRenderElement,
        >(&self.0, renderer, location, scale, alpha));

        // 1b) SSD: PŁASKI pasek tytułu z menu aplikacji w JEDNYM rzędzie
        //     z przyciskami okna (menu po lewej, przyciski po prawej).
        if self.wants_ssd() {
            // Lista elementów jest OD PRZODU DO TYŁU, więc tekst i glify muszą być
            // wypchnięte PRZED belką — inaczej belka by je zasłoniła.
            let menu_labels = crate::app_menu::labels(self);
            let labels_mode =
                !menu_labels.is_empty() && self.geometry().size.w >= theme.ssd_menu_min_width;

            if labels_mode {
                let refs: Vec<&str> = menu_labels.iter().map(String::as_str).collect();
                let widths = menu_label_widths(&refs, font_px);
                for (rect, label) in menu_label_rects(bar, &widths).into_iter().zip(refs) {
                    push_text(
                        &mut elements,
                        renderer,
                        label,
                        font_px,
                        text_rgb,
                        Point::from((rect.loc.x + rect.size.w / 2, rect.loc.y + rect.size.h / 2)),
                        scale,
                    );
                }
            } else {
                push_icon(
                    &mut elements,
                    renderer,
                    TitlebarButton::Menu,
                    menu_center(bar),
                    icon_px,
                    text_rgb,
                    scale,
                );
            }

            // Przyciski okna po prawej — zawsze, niezależnie od trybu menu.
            for (button, center) in window_buttons(bar) {
                push_icon(
                    &mut elements,
                    renderer,
                    button,
                    center,
                    icon_px,
                    if button == TitlebarButton::Close {
                        close_rgb
                    } else {
                        text_rgb
                    },
                    scale,
                );
            }

            // Tytuł okna — tylko gdy zmieści się między menu a przyciskami.
            if let Some(title) = self.window_title() {
                let menu_right = if labels_mode {
                    let refs: Vec<&str> = menu_labels.iter().map(String::as_str).collect();
                    let widths = menu_label_widths(&refs, font_px);
                    menu_label_rects(bar, &widths)
                        .last()
                        .map(|rect| rect.loc.x + rect.size.w)
                        .unwrap_or(bar.loc.x + BAR_PADDING_X)
                } else {
                    menu_center(bar).x + 18
                };
                // Najbardziej na lewo wysunięty przycisk okna to minimalizacja
                // (ostatni element listy), stąd indeks 2.
                let buttons_left = window_buttons(bar)[2].1.x - 18;
                let title_width = text::measure(&title, font_px).map(|s| s.w).unwrap_or(0);
                let available = buttons_left - menu_right;
                if title_width > 0 && title_width + 24 <= available {
                    push_text(
                        &mut elements,
                        renderer,
                        &title,
                        font_px,
                        text_rgb,
                        Point::from((menu_right + available / 2, bar.loc.y + bar.size.h / 2)),
                        scale,
                    );
                }
            }

            // Cienka, płaska linia oddzielająca pasek od treści okna.
            let border = Rectangle::new(
                Point::from((bar.loc.x, bar.loc.y + bar.size.h - BORDER_HEIGHT)),
                Size::from((bar.size.w, BORDER_HEIGHT)),
            );
            if let Some(element) =
                render::rounded_rect_element(renderer, border, theme.ssd_border, 0.0)
            {
                elements.push(WindowRenderElement::Pixel(element));
            }

            // Belka: płaski kolor (bez gradientu — zgodnie z firmowym stylem).
            let bar_color = if focused {
                theme.ssd_bar
            } else {
                theme.ssd_unfocused
            };
            if let Some(element) =
                render::top_rounded_rect_element(renderer, bar, bar_color, radius)
            {
                elements.push(WindowRenderElement::Pixel(element));
            }
        }

        elements.into_iter().map(C::from).collect()
    }
}

/// Szerokości klikalnych pól etykiet menu (sam tekst + odstęp po bokach).
fn menu_label_widths(labels: &[&str], font_px: f64) -> Vec<i32> {
    labels
        .iter()
        .map(|label| {
            text::measure(label, font_px)
                .map(|size| size.w)
                .unwrap_or(0)
                + MENU_LABEL_PAD_X * 2
        })
        .collect()
}

/// Prostokąty pól etykiet menu, układane od lewej krawędzi paska.
///
/// Czysta funkcja (bez Cairo i bez Waylanda) — dzięki temu układ menu można
/// przetestować jednostkowo, a ten sam wynik służy do rysowania i do trafień myszy.
fn menu_label_rects(bar: Rectangle<i32, Logical>, widths: &[i32]) -> Vec<Rectangle<i32, Logical>> {
    let height = TITLEBAR_HEIGHT - MENU_LABEL_PAD_Y * 2;
    let y = bar.loc.y + MENU_LABEL_PAD_Y;
    let mut x = bar.loc.x + BAR_PADDING_X;
    widths
        .iter()
        .map(|width| {
            let rect = Rectangle::new(Point::from((x, y)), Size::from((*width, height)));
            x += width;
            rect
        })
        .collect()
}

/// Podpis wiersza menu okna (rozwijanego z burgera).
fn menu_row_label(button: TitlebarButton) -> &'static str {
    match button {
        TitlebarButton::Minimize => "Minimalizuj",
        TitlebarButton::Maximize => "Maksymalizuj",
        TitlebarButton::Close => "Zamknij",
        TitlebarButton::Menu => "Menu",
    }
}

/// Rysuje napis wyśrodkowany w zadanym punkcie (tekstura Cayro → element GL).
///
/// PO CO osobno: ta sama operacja dotyczy tytułu okna i etykiet menu.
fn push_text(
    elements: &mut Vec<WindowRenderElement>,
    renderer: &mut GlesRenderer,
    text_value: &str,
    font_px: f64,
    color: (f64, f64, f64),
    center: Point<i32, Logical>,
    scale: Scale<f64>,
) {
    let Some(texture) = text::texture(text_value, font_px, color) else {
        return;
    };
    let location = Point::<f64, Logical>::from((
        (center.x - texture.size.w / 2) as f64,
        (center.y - texture.size.h / 2) as f64,
    ))
    .to_physical(scale);
    match MemoryRenderBufferRenderElement::from_buffer(
        renderer,
        location,
        &texture.buffer,
        None,
        None,
        None,
        Kind::Unspecified,
    ) {
        Ok(element) => elements.push(WindowRenderElement::Text(element)),
        Err(err) => tracing::warn!(?err, "nie udało się narysować tekstu paska"),
    }
}

/// Dodaje do listy element ikony danego przycisku (spójne w pasku i w menu).
///
/// PO CO osobno: pasek tytułu i wiersze menu rysują dokładnie te same ikony.
/// Jedna funkcja = brak rozjazdu wizualnego między nimi.
#[allow(clippy::too_many_arguments)]
fn push_icon(
    elements: &mut Vec<WindowRenderElement>,
    renderer: &mut GlesRenderer,
    button: TitlebarButton,
    center: Point<i32, Logical>,
    size_px: i32,
    color: (f64, f64, f64),
    scale: Scale<f64>,
) {
    let Some(texture) = icons::texture(icon_for(button), size_px, color) else {
        return;
    };
    let location = Point::<f64, Logical>::from((
        (center.x - texture.size.w / 2) as f64,
        (center.y - texture.size.h / 2) as f64,
    ))
    .to_physical(scale);
    match MemoryRenderBufferRenderElement::from_buffer(
        renderer,
        location,
        &texture.buffer,
        None,
        None,
        None,
        Kind::Unspecified,
    ) {
        Ok(element) => elements.push(WindowRenderElement::Text(element)),
        Err(err) => tracing::warn!(?err, "nie udało się narysować ikony paska"),
    }
}

/// Ikona odpowiadająca przyciskowi paska.
fn icon_for(button: TitlebarButton) -> icons::Icon {
    match button {
        TitlebarButton::Menu => icons::Icon::Menu,
        TitlebarButton::Minimize => icons::Icon::Minimize,
        TitlebarButton::Maximize => icons::Icon::Maximize,
        TitlebarButton::Close => icons::Icon::Close,
    }
}

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

    /// Pasek leży DOKŁADNIE nad treścią (treść zaczyna się pod paskiem).
    #[test]
    fn pasek_jest_nad_trescia() {
        let bar = bar_rect(Point::from((20, 40)), 800);
        assert_eq!(bar.loc, Point::from((20, 0)));
        assert_eq!(bar.size, Size::from((800, TITLEBAR_HEIGHT)));
    }

    /// Wymóg wizualny: menu (burger) i przyciski okna leżą na TEJ SAMEJ osi Y.
    #[test]
    fn menu_i_przyciski_na_tej_samej_osi_y() {
        let bar = bar_rect(Point::from((20, 40)), 800);
        let menu_y = menu_center(bar).y;
        for (_, center) in window_buttons(bar) {
            assert_eq!(center.y, menu_y, "przycisk rozjechał się z menu");
        }
    }

    /// Klik w środek burgera trafia w „Menu", a nie w pusty pasek.
    #[test]
    fn trafienie_w_burger() {
        let bar = bar_rect(Point::from((20, 40)), 800);
        let center = menu_center(bar);
        let point = Point::from((center.x as f64, center.y as f64));
        assert_eq!(bar_button_at(bar, point), Some(TitlebarButton::Menu));
    }

    /// Każdy z trzech przycisków okna trafia we właściwą akcję.
    #[test]
    fn trafienie_w_przyciski_okna() {
        let bar = bar_rect(Point::from((0, 40)), 600);
        for (button, center) in window_buttons(bar) {
            let point = Point::from((center.x as f64, center.y as f64));
            assert_eq!(bar_button_at(bar, point), Some(button));
        }
    }

    /// Środek pustej części paska nie należy do żadnego przycisku.
    #[test]
    fn pusty_pasek_nie_trafia() {
        let bar = bar_rect(Point::from((0, 40)), 600);
        let point = Point::from((300.0, 20.0));
        assert_eq!(bar_button_at(bar, point), None);
    }

    /// Wiersze menu są w ustalonej kolejności i pokrywają cały panel.
    #[test]
    fn wiersze_menu_maja_stala_kolejnosc() {
        let bar = bar_rect(Point::from((0, 40)), 600);
        let panel = menu_panel_rect(bar, MENU_ROWS);
        assert_eq!(
            panel.loc,
            Point::from((MENU_PANEL_PADDING, TITLEBAR_HEIGHT))
        );

        let row_center = |index: i32| {
            Point::from((
                (panel.loc.x + MENU_ROW_ICON_X) as f64,
                (panel.loc.y + MENU_PANEL_PADDING + MENU_ROW_HEIGHT * index + MENU_ROW_HEIGHT / 2)
                    as f64,
            ))
        };
        assert_eq!(
            menu_row_at(panel, row_center(0)),
            Some(TitlebarButton::Minimize)
        );
        assert_eq!(
            menu_row_at(panel, row_center(1)),
            Some(TitlebarButton::Maximize)
        );
        assert_eq!(
            menu_row_at(panel, row_center(2)),
            Some(TitlebarButton::Close)
        );
    }

    /// Punkt poza panelem menu nie trafia w żaden wiersz.
    #[test]
    fn punkt_poza_panelem_nie_trafia() {
        let bar = bar_rect(Point::from((0, 40)), 600);
        let panel = menu_panel_rect(bar, MENU_ROWS);
        // Po prawej stronie panelu (poza jego szerokością).
        let point = Point::from(((panel.loc.x + panel.size.w + 5) as f64, 100.0));
        assert_eq!(menu_row_at(panel, point), None);
    }

    /// Panel menu zaczyna się pod belką paska i jest szerszy niż wysoki.
    #[test]
    fn panel_menu_jest_pod_belka() {
        let bar = bar_rect(Point::from((0, 40)), 800);
        let panel = menu_panel_rect(bar, MENU_ROWS);
        assert_eq!(panel.loc.y, bar.loc.y + TITLEBAR_HEIGHT);
        assert_eq!(panel.size.w, MENU_PANEL_WIDTH);
        assert_eq!(
            panel.size.h,
            MENU_PANEL_PADDING * 2 + MENU_ROW_HEIGHT * MENU_ROWS
        );
    }

    /// Wysokość panelu menu ROŚNIE z liczbą etykiet menu aplikacji.
    #[test]
    fn panel_menu_aplikacji_ma_tyle_wierszy_ile_etykiet() {
        let bar = bar_rect(Point::from((0, 40)), 800);
        let cztery = menu_panel_rect(bar, 4);
        let dwie = menu_panel_rect(bar, 2);
        assert_eq!(cztery.size.h, MENU_PANEL_PADDING * 2 + MENU_ROW_HEIGHT * 4);
        assert!(cztery.size.h > dwie.size.h);
    }

    /// Etykiety menu układają się od lewej, jedna za drugą, na jednej osi Y.
    #[test]
    fn etykiety_menu_na_tej_samej_osi_y() {
        let bar = bar_rect(Point::from((20, 40)), 900);
        let widths = [40, 50, 45, 48];
        let rects = menu_label_rects(bar, &widths);
        assert_eq!(rects.len(), widths.len());

        let first_center_y = rects[0].loc.y + rects[0].size.h / 2;
        for (index, rect) in rects.iter().enumerate() {
            assert_eq!(rect.size.w, widths[index]);
            assert_eq!(
                rect.loc.y + rect.size.h / 2,
                first_center_y,
                "etykieta rozjechała się w pionie"
            );
            if index > 0 {
                // Etykiety sąsiadują bez przerw i bez nakładania się.
                let previous = rects[index - 1];
                assert_eq!(rect.loc.x, previous.loc.x + previous.size.w);
            }
        }
    }

    /// Etykiety menu siedzą na tej samej osi Y co przyciski okna.
    #[test]
    fn etykiety_menu_w_linii_z_przyciskami() {
        let bar = bar_rect(Point::from((0, 40)), 1000);
        let labels = menu_label_rects(bar, &[40, 40, 40, 40]);
        let buttons = window_buttons(bar);
        let label_y = labels[0].loc.y + labels[0].size.h / 2;
        for (_, center) in buttons {
            assert_eq!(center.y, bar.loc.y + TITLEBAR_HEIGHT / 2);
        }
        // Środki etykiet i przycisków leżą w pionowym środku paska.
        assert_eq!(label_y, bar.loc.y + TITLEBAR_HEIGHT / 2);
    }

    /// Okno X11 zawsze dostaje nasz pasek (X11 nie ma CSD).
    #[test]
    fn x11_okno_dostaje_ssd() {
        assert!(ssd_decision(Some(false), false, false));
    }

    /// Menu/podpowiedź X11 (`override-redirect`) NIE dostaje paska.
    #[test]
    fn x11_override_redirect_bez_ssd() {
        assert!(!ssd_decision(Some(true), false, false));
    }

    /// Okno Waylanda bez negocjacji dekoracji (np. GTK3) NIE dostaje paska —
    /// inaczej nasz pasek nakładałby się na jego CSD.
    #[test]
    fn wayland_bez_negocjacji_bez_ssd() {
        assert!(!ssd_decision(None, true, false));
    }

    /// Okno Waylanda, które oddało dekoracje kompozytorowi, dostaje nasz pasek.
    #[test]
    fn wayland_z_negocjacja_dostaje_ssd() {
        assert!(ssd_decision(None, true, true));
    }

    /// Popup/subsurface nie jest samodzielnym oknem — bez paska.
    #[test]
    fn wayland_popup_bez_ssd() {
        assert!(!ssd_decision(None, false, false));
    }
}