//! `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` — używamy //! `Space` 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::().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, 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> { self.0.wl_surface() } /// Geometria okna w przestrzeni logicznej (bez ramki SSD). pub fn geometry(&self) -> Rectangle { 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(&self, processor: F) where F: FnMut(&WlSurface, &WlSurfaceData), { self.0.with_surfaces(processor) } /// Wysyła frame callback do klienta (VSync). pub fn send_frame( &self, output: &Output, time: T, throttle: Option, primary: F, ) where T: Into, F: FnMut(&WlSurface, &WlSurfaceData) -> Option + 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>, window_type: smithay::desktop::WindowSurfaceType, ) -> Option<(WlSurface, Point)> { 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 { if let Some(toplevel) = self.toplevel() { let surface = toplevel.wl_surface().clone(); return with_states(&surface, |states| { states .data_map .get::() .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, ) -> Vec> { 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, index: usize, ) -> Option> { 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, point: Point, ) -> Option { 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) -> Rectangle { 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) -> Rectangle { 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, point: Point, ) -> 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, point: Point, ) -> Option { 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, point: Point, ) -> Option { 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, point: Point, ) -> 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, point: Point, ) -> Option { 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, content_width: i32) -> Rectangle { 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) -> Point { 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) -> [(TitlebarButton, Point); 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, rows: i32) -> Rectangle { 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, point: Point, ) -> Option { 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, point: Point, ) -> Option { 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, point: Point) -> 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, point: Point, ) -> Option { 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> { self.0.wl_surface() } } /// Delegujemy cały kontrakt SpaceElement do wewnętrznego okna. impl SpaceElement for PaganWindow { fn geometry(&self) -> Rectangle { SpaceElement::geometry(&self.0) } fn bbox(&self) -> Rectangle { 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) -> 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) { 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, // Tekst (tytuł, etykiety menu) rasteryzowany Cairo i wysłany jako tekstura. Text=MemoryRenderBufferRenderElement, // 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 for PaganWindow { type RenderElement = WindowRenderElement; fn render_elements>( &self, renderer: &mut GlesRenderer, location: Point, scale: Scale, alpha: f32, ) -> Vec { let mut elements = Vec::::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::(); 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::::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::::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 { 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, widths: &[i32]) -> Vec> { 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, renderer: &mut GlesRenderer, text_value: &str, font_px: f64, color: (f64, f64, f64), center: Point, scale: Scale, ) { let Some(texture) = text::texture(text_value, font_px, color) else { return; }; let location = Point::::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, renderer: &mut GlesRenderer, button: TitlebarButton, center: Point, size_px: i32, color: (f64, f64, f64), scale: Scale, ) { let Some(texture) = icons::texture(icon_for(button), size_px, color) else { return; }; let location = Point::::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)); } }