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

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

272 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
//! 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<Vec<String>>,
    resource: Mutex<Option<PaganAppMenuV1>>,
}

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<String> {
    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<String>, 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<i32, Logical>, 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::<MyCompositor, PaganAppMenuManagerV1, ()>(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<String> {
    raw.split('\n')
        .map(|label| label.trim().to_string())
        .filter(|label| !label.is_empty())
        .collect()
}

impl GlobalDispatch<PaganAppMenuManagerV1, (), MyCompositor> for MyCompositor {
    fn bind(
        state: &mut MyCompositor,
        _handle: &DisplayHandle,
        _client: &wayland_server::Client,
        resource: wayland_server::New<PaganAppMenuManagerV1>,
        _data: &(),
        data_init: &mut wayland_server::DataInit<'_, MyCompositor>,
    ) {
        let manager = data_init.init(resource, ());
        state.app_menu_managers.push(manager);
    }
}

impl Dispatch<PaganAppMenuManagerV1, (), MyCompositor> 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<PaganAppMenuV1, AppMenuData, MyCompositor> 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"]);
    }
}