//! 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, /// Pozycja podświetlona kursorem. pub hover: Option, } 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 { 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, /// 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, } 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) -> 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, 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) { 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 { 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 { 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 { self.hover } }