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

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

654 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
//! Logika okna: model, hit-testing, hover oraz renderowanie Cairo do bufora SHM.

use cairo::{Context, Format, ImageSurface};
use memmap2::MmapMut;
use wayland_protocols::xdg::shell::client::xdg_toplevel::ResizeEdge;

use crate::draw;
use crate::i18n::Locale;

/// Wspólna oś Y środka WSZYSTKICH kontrolek paska tytułu (Menu i przycisków okna).
///
/// To jest jedyne miejsce, w którym definiujemy tę wartość. Dzięki temu „Menu" po
/// lewej i Zamknij/Minimalizuj/Maksymalizuj po prawej leżą idealnie na tej samej
/// wysokości — wystarczy zmienić jedną stałą.
pub const TITLEBAR_CONTROL_CENTER_Y: f64 = 20.0;

/// Domyślna wysokość paska tytułu w pikselach.
pub const TITLEBAR_HEIGHT: i32 = 40;

/// Domyślny promień zaokrąglenia rogów okna w pikselach.
pub const CORNER_RADIUS: f64 = 12.0;

/// Promień okręgu przycisku.
pub const CONTROL_RADIUS: f64 = 8.0;

/// Odstęp między środkami sąsiednich przycisków po prawej stronie.
pub const CONTROL_SPACING: f64 = 32.0;

/// Poziomy środek przycisku „Menu" (liczony od lewej krawędzi).
pub const MENU_CENTER_X: f64 = 20.0;

/// Odstęp prawej krawędzi okna od środka ostatniego przycisku (Zamknij).
pub const WINDOW_CONTROLS_RIGHT_MARGIN: f64 = 20.0;

// --- Rozwinięte menu aplikacji (rysowane przez klienta) -----------------------

/// Rozmiar czcionki pozycji menu (px).
pub const MENU_FONT_SIZE: f64 = 13.0;
/// Wysokość jednej pozycji menu (px).
pub const MENU_ITEM_HEIGHT: i32 = 26;
/// Wewnętrzny odstęp poziomy pozycji menu (z każdej strony).
pub const MENU_ITEM_PAD_X: i32 = 14;
/// Wewnętrzny odstęp górny/dolny panelu menu (px).
pub const MENU_PANEL_PAD_Y: i32 = 6;
/// Minimalna szerokość panelu menu (px).
pub const MENU_PANEL_MIN_WIDTH: i32 = 170;

/// Rozwinięte menu aplikacji — panel wewnątrz powierzchni klienta.
///
/// PO CO wewnątrz powierzchni, a nie jako `xdg_popup`: wynik widać od razu,
/// bez zależności od tego, czy kompozytor rysuje popupy. Panel pojawia się
/// dokładnie pod etykietą klikniętą w pasku SSD (jej pozycję przysyła kompozytor).
#[derive(Debug, Clone, PartialEq)]
pub struct OpenMenu {
    /// Indeks tytułu górnego poziomu (np. 0 = „Plik”).
    pub title_index: usize,
    /// Lewa krawędź panelu w układzie powierzchni (px).
    pub x: i32,
    /// Szerokość panelu (wyliczona z najdłuższej pozycji).
    pub width: i32,
    /// Pozycje menu.
    pub items: Vec<String>,
    /// Pozycja podświetlona kursorem.
    pub hover: Option<usize>,
}

impl OpenMenu {
    /// Prostokąt panelu `(x, y, szerokość, wysokość)` w układzie powierzchni.
    pub fn panel_rect(&self) -> (i32, i32, i32, i32) {
        let height = self.items.len() as i32 * MENU_ITEM_HEIGHT + MENU_PANEL_PAD_Y * 2;
        (self.x, 0, self.width, height)
    }

    /// Prostokąt pozycji o danym indeksie.
    pub fn item_rect(&self, index: usize) -> (i32, i32, i32, i32) {
        let (x, y, width, _) = self.panel_rect();
        let top = y + MENU_PANEL_PAD_Y + index as i32 * MENU_ITEM_HEIGHT;
        (x, top, width, MENU_ITEM_HEIGHT)
    }

    /// Indeks pozycji pod punktem (jeśli trafiona).
    pub fn item_at(&self, px: i32, py: i32) -> Option<usize> {
        for index in 0..self.items.len() {
            let (x, y, w, h) = self.item_rect(index);
            if px >= x && px < x + w && py >= y && py < y + h {
                return Some(index);
            }
        }
        None
    }

    /// Czy punkt leży w obrębie panelu (także w odstępie między pozycjami).
    pub fn contains(&self, px: i32, py: i32) -> bool {
        let (x, y, w, h) = self.panel_rect();
        px >= x && px < x + w && py >= y && py < y + h
    }
}

/// Szerokość strefy przy krawędziach, w której klik rozpoczyna resize.
pub const RESIZE_BORDER: i32 = 8;

/// Identyfikator klikalnej kontrolki okna.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Control {
    /// Przycisk menu (lewa strona paska tytułu).
    Menu,
    /// Minimalizacja.
    Minimize,
    /// Maksymalizacja / przywrócenie.
    Maximize,
    /// Zamknięcie.
    Close,
}

/// Akcja wynikająca z kliknięcia w powierzchnię klienta.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindowAction {
    /// Nic się nie stało.
    None,
    /// Użytkownik kliknął „Zamknij".
    Close,
    /// Użytkownik kliknął „Minimalizuj".
    Minimize,
    /// Użytkownik kliknął „Maksymalizuj".
    Maximize,
    /// Użytkownik chwycił pasek tytułu → okno należy przeciągnąć.
    StartMove,
    /// Użytkownik kliknął „Menu".
    ShowMenu,
    /// Użytkownik wybrał pozycję rozwiniętego menu aplikacji (indeks w rozwinięciu).
    MenuItem(usize),
}

/// Zbiór krawędzi okna, przy których trwa interaktywny resize.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Edges {
    /// Górna krawędź.
    pub top: bool,
    /// Dolna krawędź.
    pub bottom: bool,
    /// Lewa krawędź.
    pub left: bool,
    /// Prawa krawędź.
    pub right: bool,
}

impl Edges {
    /// Czy zaznaczona jest chociaż jedna krawędź.
    pub fn any(&self) -> bool {
        self.top || self.bottom || self.left || self.right
    }

    /// Tłumaczy kombinację krawędzi na wartość protokołu xdg-shell.
    ///
    /// `ResizeEdge` jest zwykłym `enum` (nie bitflagami), więc kombinacje
    /// budujemy jawnie. Kolejność `match` pokrywa wszystkie 8 sensownych przypadków;
    /// każdy inny (np. sprzeczny top+bottom) degraduje się do [`ResizeEdge::None`].
    pub fn to_xdg_resize_edge(&self) -> ResizeEdge {
        use ResizeEdge::*;
        match (self.top, self.bottom, self.left, self.right) {
            (true, false, true, false) => TopLeft,
            (true, false, false, false) => Top,
            (true, false, false, true) => TopRight,
            (false, false, true, false) => Left,
            (false, false, false, true) => Right,
            (false, true, true, false) => BottomLeft,
            (false, true, false, false) => Bottom,
            (false, true, false, true) => BottomRight,
            _ => None,
        }
    }
}

/// Nasze okno: rozmiar, wygląd oraz bieżący stan hover.
///
/// Struktura jest czysto „modelowa" — nie ma w niej żadnych obiektów Waylanda ani
/// Cairo. Dzięki temu można ją testować jednostkowo i rysować do dowolnego bufora.
pub struct MyToolkitWindow {
    /// Szerokość powierzchni w pikselach.
    pub width: i32,
    /// Wysokość powierzchni w pikselach.
    pub height: i32,
    /// Wysokość paska tytułu w pikselach.
    pub titlebar_height: i32,
    /// Promień zaokrąglenia rogów okna.
    pub corner_radius: f64,
    /// Tytuł okna (używany też w `xdg_toplevel.set_title`).
    pub title: String,
    /// Język interfejsu.
    pub locale: Locale,
    /// Aktualnie podświetlona kontrolka (stan hover) — prywatne, bo wyliczane z pozycji.
    hover: Option<Control>,
    /// Czy kompozytor rysuje dekoracje serwerowe (SSD).
    ///
    /// Gdy `true`, klient NIE rysuje własnego paska tytułu ani przycisków okna —
    /// robi to kompozytor. Zapobiega to podwójnemu paskowi.
    pub ssd: bool,
    /// Rozwinięte menu aplikacji (jeśli otwarte).
    menu: Option<OpenMenu>,
}

impl MyToolkitWindow {
    /// Tworzy okno o zadanym rozmiarze i tytule; język bierze z `PAGAN_LANG`.
    pub fn new(width: i32, height: i32, title: impl Into<String>) -> Self {
        Self::with_locale(width, height, title, Locale::from_env())
    }

    /// Tworzy okno z jawnie podanym językiem (przydatne w testach i osadzaniu).
    pub fn with_locale(width: i32, height: i32, title: impl Into<String>, locale: Locale) -> Self {
        Self {
            width,
            height,
            titlebar_height: TITLEBAR_HEIGHT,
            corner_radius: CORNER_RADIUS,
            title: title.into(),
            locale,
            hover: None,
            ssd: false,
            menu: None,
        }
    }

    /// Otwiera rozwinięte menu aplikacji pod etykietą o pozycji `x`.
    ///
    /// PO CO liczymy szerokość tutaj: panel ma objąć najdłuższą pozycję, a ten
    /// sam prostokąt służy potem do rysowania I do trafień myszy.
    pub fn open_menu(&mut self, title_index: usize, x: i32, items: Vec<String>) {
        if items.is_empty() {
            self.menu = None;
            return;
        }
        let text_width = items
            .iter()
            .map(|item| draw::measure_text(item, MENU_FONT_SIZE).unwrap_or(0.0))
            .fold(0.0_f64, f64::max);
        let width = (text_width.ceil() as i32 + MENU_ITEM_PAD_X * 2).max(MENU_PANEL_MIN_WIDTH);
        // Panel nie może wyjść za prawą krawędź okna.
        let x = x.min((self.width - width - 4).max(0));
        self.menu = Some(OpenMenu {
            title_index,
            x,
            width,
            items,
            hover: None,
        });
    }

    /// Zamyka rozwinięte menu. Zwraca `true`, jeśli coś było otwarte.
    pub fn close_menu(&mut self) -> bool {
        self.menu.take().is_some()
    }

    /// Czy rozwinięte menu jest otwarte.
    pub fn menu_is_open(&self) -> bool {
        self.menu.is_some()
    }

    /// Włącza/wyłącza tryb dekoracji serwerowych (SSD).
    ///
    /// Wołane po zdarzeniu `zxdg_toplevel_decoration_v1.configure`: gdy kompozytor
    /// wymusza SSD, klient musi zrezygnować z własnego paska tytułu.
    pub fn set_ssd(&mut self, ssd: bool) {
        if self.ssd != ssd {
            self.ssd = ssd;
            self.hover = None;
            self.menu = None;
        }
    }

    /// Ustawia nowy rozmiar powierzchni (np. po `xdg_toplevel.configure`).
    ///
    /// Zerowanie hover jest istotne: po zmianie rozmiaru przyciski przesuwają się
    /// poziomo, więc zapamiętana kontrolka mogłaby wskazywać na złe miejsce.
    pub fn resize(&mut self, width: i32, height: i32) {
        self.width = width;
        self.height = height;
        self.hover = None;
        // Rozwinięte menu zależało od szerokości okna — po zmianie rozmiaru
        // jego pozycja byłaby nieaktualna.
        self.menu = None;
    }

    /// Zwraca środek podanej kontrolki w układzie lokalnym powierzchni.
    fn control_center(&self, control: Control) -> (f64, f64) {
        // Środek prawego skraju zestawu przycisków = przycisk „Zamknij".
        let close_x = self.width as f64 - WINDOW_CONTROLS_RIGHT_MARGIN;
        let maximize_x = close_x - CONTROL_SPACING;
        let minimize_x = close_x - CONTROL_SPACING * 2.0;
        let y = TITLEBAR_CONTROL_CENTER_Y;
        match control {
            Control::Menu => (MENU_CENTER_X, y),
            Control::Minimize => (minimize_x, y),
            Control::Maximize => (maximize_x, y),
            Control::Close => (close_x, y),
        }
    }

    /// Zwraca przycisk, na którym znajduje się punkt (jeśli którykolwiek).
    ///
    /// Promień trafienia jest o 2 px większy niż promień rysowania — drobny
    /// „luz" znacząco poprawia ergonomię klikania małych okrągłych przycisków.
    fn hit_control(&self, x: i32, y: i32) -> Option<Control> {
        let hit_radius = CONTROL_RADIUS + 2.0;
        let hit_radius_sq = hit_radius * hit_radius;
        let (xf, yf) = (x as f64, y as f64);
        for control in [
            Control::Menu,
            Control::Minimize,
            Control::Maximize,
            Control::Close,
        ] {
            let (cx, cy) = self.control_center(control);
            let dx = xf - cx;
            let dy = yf - cy;
            if dx * dx + dy * dy <= hit_radius_sq {
                return Some(control);
            }
        }
        None
    }

    /// Czy punkt leży wewnątrz powierzchni.
    fn is_inside(&self, x: i32, y: i32) -> bool {
        x >= 0 && y >= 0 && x < self.width && y < self.height
    }

    /// Rysuje CAŁE okno do bufora pamięci współdzielonej.
    ///
    /// `mmap` musi mieć co najmniej `width * height * 4` bajtów (ARGB32).
    ///
    /// API-NOTE: powierzchnia Cairo tworzona przez `create_for_data_unsafe` NIE może
    /// przeżyć danych, na których została utworzona. Dlatego trzymamy ją lokalnie,
    /// kończymy rysowanie i wołamy `surface.flush()` zanim `mmap` wróci do wołającego.
    pub fn render_to_buffer(&self, mmap: &mut MmapMut) {
        if self.width <= 0 || self.height <= 0 {
            tracing::error!(
                width = self.width,
                height = self.height,
                "pomijam render: niepoprawny rozmiar okna"
            );
            return;
        }

        // ARGB32 = dokładnie 4 bajty na piksel (premultiplied).
        let stride = match self.width.checked_mul(4) {
            Some(s) if s > 0 => s,
            _ => {
                tracing::error!(width = self.width, "pomijam render: przepełnienie stride");
                return;
            }
        };
        let required = match (stride as i64).checked_mul(self.height as i64) {
            Some(n) if n > 0 => n as usize,
            _ => {
                tracing::error!("pomijam render: przepełnienie rozmiaru bufora");
                return;
            }
        };
        if mmap.len() < required {
            tracing::error!(
                available = mmap.len(),
                required,
                "pomijam render: bufor SHM jest za mały"
            );
            return;
        }

        // SAFETY: `mmap` żyje co najmniej do końca tej funkcji, ma co najmniej
        // `stride * height` bajtów i jest wyrównany do strony (mapowanie mmap).
        let surface = unsafe {
            ImageSurface::create_for_data_unsafe(
                mmap.as_mut_ptr(),
                Format::ARgb32,
                self.width,
                self.height,
                stride,
            )
        };
        let surface = match surface {
            Ok(surface) => surface,
            Err(err) => {
                tracing::error!(error = %err, "nie udało się utworzyć powierzchni Cairo na buforze SHM");
                return;
            }
        };

        let ctx = match Context::new(&surface) {
            Ok(ctx) => ctx,
            Err(err) => {
                tracing::error!(error = %err, "nie udało się utworzyć kontekstu Cairo");
                return;
            }
        };

        if let Err(err) = self.draw(&ctx).and_then(|()| self.draw_menu(&ctx)) {
            tracing::error!(error = %err, "błąd rysowania okna");
        }

        // Wypycha wszystkie operacje rysowania do pamięci bufora przed powrotem.
        // `ImageSurface::flush()` nic nie zwraca (to `cairo_surface_flush`).
        surface.flush();
    }

    /// Właściwa kolejność warstw rysowania okna.
    fn draw(&self, ctx: &Context) -> Result<(), cairo::Error> {
        // W trybie SSD pasek tytułu i przyciski rysuje kompozytor — klient
        // rysuje tylko korpus oraz własny pasek menu.
        if self.ssd {
            return self.draw_ssd(ctx);
        }

        let width = self.width as f64;
        let height = self.height as f64;

        // 1. Wyzeruj bufor → przezroczystość poza zaokrągleniami.
        draw::clear_transparent(ctx)?;

        // 2. Pełne zaokrąglone tło (wszystkie cztery narożniki, promień 12 px).
        draw::rounded_rectangle(ctx, 0.0, 0.0, width, height, self.corner_radius);
        draw::set_color(ctx, draw::BODY_COLOR);
        ctx.fill()?;

        // 3. Pasek tytułu (ciemny, zaokrąglone górne rogi).
        draw::draw_titlebar(ctx, width, self.titlebar_height as f64, self.corner_radius)?;

        // 4. Kontrolki. Używamy DOKŁADNIE tej samej współrzędnej Y dla menu i przycisków —
        //    to wymóg wizualny, dlatego obie wartości pochodzą z jednej stałej.
        let menu_y = TITLEBAR_CONTROL_CENTER_Y;
        let buttons_y = TITLEBAR_CONTROL_CENTER_Y; // Ta sama wartość!

        let (menu_x, _) = self.control_center(Control::Menu);
        let menu_color = if self.hover == Some(Control::Menu) {
            draw::highlight_color(draw::COLOR_MENU)
        } else {
            draw::COLOR_MENU
        };
        draw::draw_menu_button(ctx, menu_x, menu_y, CONTROL_RADIUS, menu_color)?;

        // Trzy osobne okręgi po prawej: minimalizuj, maksymalizuj, zamknij.
        let (right_start, _) = self.control_center(Control::Minimize);
        draw::draw_window_controls(
            ctx,
            right_start,
            buttons_y,
            CONTROL_RADIUS,
            CONTROL_SPACING,
            self.hover == Some(Control::Close),
            self.hover == Some(Control::Minimize),
            self.hover == Some(Control::Maximize),
        )?;

        // 5. Tytuł okna, wyrównany do tej samej osi co kontrolki.
        let text_x = menu_x + CONTROL_RADIUS + 12.0;
        draw::draw_title_text(
            ctx,
            &self.title,
            text_x,
            TITLEBAR_CONTROL_CENTER_Y,
            draw::TITLE_TEXT_COLOR,
        )?;

        Ok(())
    }

    /// Rysuje okno w trybie dekoracji serwerowych (SSD).
    ///
    /// PO CO osobna ścieżka: w trybie SSD CAŁĄ dekorację (pasek tytułu, menu
    /// aplikacji, przyciski okna) rysuje kompozytor. Klient nie może dołożyć
    /// własnego paska — powstałyby dwa. Zostaje mu tylko korpus okna
    /// (z zaokrąglonymi dolnymi rogami, bo górę zasłania ramka kompozytora).
    fn draw_ssd(&self, ctx: &Context) -> Result<(), cairo::Error> {
        let width = self.width as f64;
        let height = self.height as f64;

        draw::clear_transparent(ctx)?;

        // Korpus: zaokrąglone tylko DOLNE rogi (górę zasłania pasek SSD
        // o ostrych dolnych krawędziach — zaokrąglenie góry dałoby prześwit).
        draw::rounded_bottom_rectangle(ctx, 0.0, 0.0, width, height, self.corner_radius);
        draw::set_color(ctx, draw::BODY_COLOR);
        ctx.fill()?;

        Ok(())
    }

    /// Rysuje rozwinięte menu aplikacji (jeśli otwarte).
    ///
    /// PO CO osobno od `draw`/`draw_ssd`: menu jest nakładką nad treścią okna
    /// i wygląda tak samo niezależnie od tego, kto rysuje pasek tytułu (klient
    /// czy kompozytor).
    fn draw_menu(&self, ctx: &Context) -> Result<(), cairo::Error> {
        let Some(menu) = &self.menu else {
            return Ok(());
        };
        let (px, py, pw, ph) = menu.panel_rect();
        let (x, y, w, h) = (px as f64, py as f64, pw as f64, ph as f64);

        // Cień pod panelem — bez niego menu zlewa się z treścią okna.
        draw::rounded_rectangle(ctx, x + 1.0, y + 3.0, w, h, 10.0);
        ctx.set_source_rgba(0.0, 0.0, 0.0, 0.35);
        ctx.fill()?;

        // Tło panelu.
        draw::rounded_rectangle(ctx, x, y, w, h, 10.0);
        draw::set_color(ctx, draw::MENU_PANEL_COLOR);
        ctx.fill()?;

        // Delikatna obwódka w kolorze akcentu PaganDE.
        draw::rounded_rectangle(ctx, x + 0.5, y + 0.5, w - 1.0, h - 1.0, 10.0);
        ctx.set_source_rgba(0.31, 0.63, 1.0, 0.35);
        ctx.set_line_width(1.0);
        ctx.stroke()?;

        // Pozycje menu.
        for (index, item) in menu.items.iter().enumerate() {
            let (ix, iy, iw, ih) = menu.item_rect(index);
            if menu.hover == Some(index) {
                draw::rounded_rectangle(
                    ctx,
                    ix as f64 + 4.0,
                    iy as f64 + 1.0,
                    (iw - 8) as f64,
                    (ih - 2) as f64,
                    6.0,
                );
                draw::set_color(ctx, draw::MENU_ITEM_HOVER_COLOR);
                ctx.fill()?;
            }
            draw::draw_title_text(
                ctx,
                item,
                ix as f64 + MENU_ITEM_PAD_X as f64,
                iy as f64 + f64::from(ih) / 2.0,
                draw::TITLE_TEXT_COLOR,
            )?;
        }

        Ok(())
    }

    /// Hit-testing: zwraca akcję dla kliknięcia w punkcie `(x, y)`.
    ///
    /// Kolejność ma znaczenie: najpierw przyciski (małe, precyzyjne cele), dopiero
    /// potem „pusty" pasek tytułu, który rozpoczyna przeciąganie.
    pub fn handle_mouse_click(&mut self, x: i32, y: i32) -> WindowAction {
        // Rozwinięte menu jest nad wszystkim — obsługujemy je pierwsze.
        // Kliknięcie poza pozycją (także poza panelem) po prostu zamyka menu.
        if self.menu.is_some() {
            let hit = self.menu.as_ref().and_then(|menu| menu.item_at(x, y));
            self.menu = None;
            return hit
                .map(WindowAction::MenuItem)
                .unwrap_or(WindowAction::None);
        }

        if !self.is_inside(x, y) {
            return WindowAction::None;
        }

        // W trybie SSD całą dekorację rysuje kompozytor — klient nie ma tu
        // żadnych własnych kontrolek (menu aplikacji jest w pasku kompozytora).
        if self.ssd {
            return WindowAction::None;
        }

        if let Some(control) = self.hit_control(x, y) {
            return match control {
                Control::Menu => WindowAction::ShowMenu,
                Control::Close => WindowAction::Close,
                Control::Minimize => WindowAction::Minimize,
                Control::Maximize => WindowAction::Maximize,
            };
        }

        // Klik w pasek tytułu poza przyciskami → przeciąganie okna.
        if y < self.titlebar_height {
            return WindowAction::StartMove;
        }

        WindowAction::None
    }

    /// Aktualizuje stan hover na podstawie ruchu kursora.
    ///
    /// Zwraca `true`, gdy stan się zmienił — tylko wtedy trzeba przerysować okno,
    /// co oszczędza pełne renderowanie Cairo przy każdym drobnym ruchu myszy.
    pub fn handle_mouse_motion(&mut self, x: i32, y: i32) -> bool {
        // Nad otwartym menu podświetlamy pozycję pod kursorem.
        if let Some(menu) = self.menu.as_mut() {
            let hover = menu.item_at(x, y);
            if hover != menu.hover {
                menu.hover = hover;
                return true;
            }
            return false;
        }

        if self.ssd {
            // W trybie SSD klient nie rysuje własnych kontrolek — nic nie podświetlamy.
            if self.hover.take().is_some() {
                return true;
            }
            return false;
        }

        let new_hover = if self.is_inside(x, y) {
            self.hit_control(x, y)
        } else {
            None
        };

        if new_hover != self.hover {
            self.hover = new_hover;
            true
        } else {
            false
        }
    }

    /// Zwraca krawędzie do resize, jeśli punkt leży na krawędzi okna; inaczej `None`.
    ///
    /// Strefa łapania ma [`RESIZE_BORDER`] pikseli. Punkty poza powierzchnią są
    /// ignorowane — resize wywołuje kompozytor dopiero po kliknięciu w naszym oknie.
    pub fn resize_edge_at(&self, x: i32, y: i32) -> Option<Edges> {
        if !self.is_inside(x, y) {
            return None;
        }

        let mut edges = Edges::default();
        if y < RESIZE_BORDER {
            edges.top = true;
        }
        if y >= self.height - RESIZE_BORDER {
            edges.bottom = true;
        }
        if x < RESIZE_BORDER {
            edges.left = true;
        }
        if x >= self.width - RESIZE_BORDER {
            edges.right = true;
        }

        if edges.any() {
            Some(edges)
        } else {
            None
        }
    }

    /// Czy kontrolka jest aktualnie podświetlona (do tooltipów/logów w aplikacji).
    pub fn hovered_control(&self) -> Option<Control> {
        self.hover
    }
}