//! Własny protokół `pagan_app_menu_v1` — menu aplikacji w pasku SSD. //! //! PO CO osobny protokół: każde menu aplikacji może być INNE (Plik/Edycja/Widok, //! ale też np. Obraz/Filtr dla edytora grafiki). Kompozytor nie może więc znać //! zawartości menu z góry. Rozwiązanie dzieli odpowiedzialność: //! //! * KLIENT publikuje tylko ETYKIETY górnego poziomu (`set_labels`), //! * KOMPOZYTOR rysuje je w firmowym pasku tytułu (w jednym rzędzie z przyciskami //! okna) i odsyła zdarzenie `clicked`, gdy użytkownik w coś kliknie, //! * KLIENT zna zawartość rozwinięcia i pokazuje je sam (np. jako popup). //! //! Dzięki temu wygląd jest spójny (rysuje go kompozytor), a funkcje menu należą //! do aplikacji. //! //! Gdy w pasku nie ma miejsca na etykiety (wąskie okno), zwijają się one do //! jednego przycisku „burger”. Rozwinięcie burgera pokazuje wtedy WŁAŚNIE menu //! aplikacji (klik wiersza odsyłamy klientowi). Dopiero gdy klient nie ma menu //! (obce aplikacje, np. terminale X11), burger rozwija awaryjne menu okna //! (minimalizuj/maksymalizuj/zamknij). use std::sync::Mutex; use wayland_server::{ backend::ClientId, protocol::wl_surface::WlSurface, Dispatch, DisplayHandle, GlobalDispatch, }; use smithay::utils::{Logical, Point}; use crate::{state::MyCompositor, window::PaganWindow}; /// Kod wygenerowany z `protocols/pagan-app-menu-v1.xml`. /// /// Ścieżka jest względna do katalogu crate'a (`CARGO_MANIFEST_DIR`), więc XML /// trzymamy w jednym miejscu w repozytorium i używają go oba projekty. pub mod protocol { #[allow(clippy::all)] pub mod server { // Wygenerowany kod odwołuje się do crate'ów przez `super::wayland_server` // oraz do `wayland_backend` bezpośrednio — musimy mieć oba w zasięgu. #[allow(unused_imports)] use wayland_server; // Wygenerowany kod odwołuje się do interfejsów rdzenia (np. `wl_surface`) // przez `super::wl_surface` — dlatego importujemy je do tego modułu. #[allow(unused_imports)] use wayland_server::protocol::*; pub mod __interfaces { use wayland_server::protocol::__interfaces::*; wayland_scanner::generate_interfaces!("../protocols/pagan-app-menu-v1.xml"); } use self::__interfaces::*; wayland_scanner::generate_server_code!("../protocols/pagan-app-menu-v1.xml"); } } use protocol::server::pagan_app_menu_manager_v1::{ PaganAppMenuManagerV1, Request as ManagerRequest, }; use protocol::server::pagan_app_menu_v1::{PaganAppMenuV1, Request as MenuRequest}; /// Wersja protokołu, którą reklamujemy klientom. const VERSION: u32 = 1; /// Dane protokołu dołączone do obiektu `pagan_app_menu_v1`. /// /// Trzymamy powierzchnię, żeby przy `set_labels`/`destroyed` znaleźć okno. #[derive(Debug)] pub struct AppMenuData { surface: WlSurface, } /// Stan menu trzymany w danych użytkownika OKNA. /// /// PO CO w danych okna, a nie w `MyCompositor`: etykiety czyta renderowanie /// (`AsRenderElements`), które nie widzi stanu kompozytora — tak samo jak przy /// flagach dekoracji. #[derive(Debug, Default)] struct WindowMenuState { labels: Mutex>, resource: Mutex>, } fn window_state(window: &PaganWindow) -> &WindowMenuState { window .user_data() .get_or_insert_threadsafe(WindowMenuState::default) } /// Etykiety menu opublikowane przez klienta (puste = brak menu → burger). pub fn labels(window: &PaganWindow) -> Vec { window_state(window) .labels .lock() .map(|labels| labels.clone()) .unwrap_or_default() } /// Zapisuje etykiety menu i uchwyt obiektu protokołu dla danego okna. fn set_labels(window: &PaganWindow, labels: Vec, resource: PaganAppMenuV1) { let state = window_state(window); if let Ok(mut stored) = state.labels.lock() { *stored = labels; } if let Ok(mut stored) = state.resource.lock() { *stored = Some(resource); } } /// Usuwa etykiety okna, gdy klient zniszczył obiekt menu. fn clear_labels(window: &PaganWindow) { let state = window_state(window); if let Ok(mut stored) = state.labels.lock() { stored.clear(); } if let Ok(mut stored) = state.resource.lock() { *stored = None; } } /// Informuje klienta, że użytkownik kliknął etykietę o danym indeksie. /// /// Wysyłamy także POZIOMĄ pozycję etykiety we współrzędnych powierzchni klienta — /// dzięki temu klient ustawia rozwinięcie dokładnie pod właściwą etykietą, nie /// znając układu paska kompozytora. /// /// PO CO tutaj: obsługa myszy (`input.rs`) nie powinna znać szczegółów protokołu — /// woła po prostu „okno, kliknięto pozycję N”. pub fn send_click(window: &PaganWindow, location: Point, index: usize) { let x = window .ssd_menu_label_rect(location, index) .map(|rect| rect.loc.x - location.x) .unwrap_or(0); let state = window_state(window); let Ok(guard) = state.resource.lock() else { return; }; if let Some(resource) = guard.as_ref() { resource.clicked(index as u32, x); } } /// Rejestruje global `pagan_app_menu_manager_v1`. pub fn init(display: &DisplayHandle) { display.create_global::(VERSION, ()); tracing::debug!("global pagan_app_menu_manager_v1 zarejestrowany"); } /// Rozbija napis z protokołu na listę etykiet (rozdzielone znakami nowej linii). /// /// PO CO osobna funkcja: to granica protokołu — warto ją przetestować bez /// uruchamiania kompozytora. Puste pozycje odrzucamy, żeby klik nie trafiał /// w niewidoczne etykiety. fn parse_labels(raw: &str) -> Vec { raw.split('\n') .map(|label| label.trim().to_string()) .filter(|label| !label.is_empty()) .collect() } impl GlobalDispatch for MyCompositor { fn bind( state: &mut MyCompositor, _handle: &DisplayHandle, _client: &wayland_server::Client, resource: wayland_server::New, _data: &(), data_init: &mut wayland_server::DataInit<'_, MyCompositor>, ) { let manager = data_init.init(resource, ()); state.app_menu_managers.push(manager); } } impl Dispatch for MyCompositor { fn request( _state: &mut MyCompositor, _client: &wayland_server::Client, _manager: &PaganAppMenuManagerV1, request: ManagerRequest, _data: &(), _handle: &DisplayHandle, data_init: &mut wayland_server::DataInit<'_, MyCompositor>, ) { match request { ManagerRequest::GetAppMenu { id, surface } => { // Obiekt menu istnieje po to, żeby klient mógł wysłać etykiety // i odebrać kliknięcia — nic więcej nie musimy tu robić. let _menu = data_init.init(id, AppMenuData { surface }); } ManagerRequest::Destroy => {} } } fn destroyed( state: &mut MyCompositor, _client: ClientId, manager: &PaganAppMenuManagerV1, _data: &(), ) { state .app_menu_managers .retain(|candidate| candidate != manager); } } impl Dispatch for MyCompositor { fn request( state: &mut MyCompositor, _client: &wayland_server::Client, menu: &PaganAppMenuV1, request: MenuRequest, data: &AppMenuData, _handle: &DisplayHandle, _data_init: &mut wayland_server::DataInit<'_, MyCompositor>, ) { match request { MenuRequest::SetLabels { labels: raw } => { let labels = parse_labels(&raw); if let Some(window) = state.window_for_surface(&data.surface) { set_labels(&window, labels, menu.clone()); } } MenuRequest::Destroy => {} } } fn destroyed( state: &mut MyCompositor, _client: ClientId, menu: &PaganAppMenuV1, data: &AppMenuData, ) { if let Some(window) = state.window_for_surface(&data.surface) { // Upewniamy się, że usuwamy wpis TEGO obiektu (okno mogło już mieć nowszy). let is_current = window_state(&window) .resource .lock() .map(|guard| guard.as_ref() == Some(menu)) .unwrap_or(false); if is_current { clear_labels(&window); } } } } #[cfg(test)] mod tests { use super::parse_labels; #[test] fn etykiety_sa_rozdzielane_nowa_linia() { assert_eq!( parse_labels("Plik\nEdycja\nWidok\nPomoc"), vec!["Plik", "Edycja", "Widok", "Pomoc"] ); } #[test] fn puste_etykiety_sa_pomijane() { assert_eq!(parse_labels("\n \nPlik\n\n"), vec!["Plik"]); assert!(parse_labels("").is_empty()); assert!(parse_labels(" ").is_empty()); } #[test] fn etykiety_sa_przycinane() { assert_eq!(parse_labels(" Plik \n Edycja "), vec!["Plik", "Edycja"]); } }