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

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

177 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
//! Klient własnego protokołu `pagan_app_menu_v1` — menu aplikacji w pasku SSD.
//!
//! PO CO: kompozytor PaganDE rysuje pasek tytułu po swojej stronie (wymuszony
//! SSD). Żeby w tym pasku pokazać menu KONKRETNEJ aplikacji, klient publikuje
//! same ETYKIETY górnego poziomu, a kompozytor odsyła zdarzenie `clicked`,
//! gdy użytkownik w którąś kliknie. Zawartość rozwinięcia zna i pokazuje klient.
//!
//! Dzięki temu każde menu może być inne (bo pochodzi od aplikacji), a mimo to
//! wygląda spójnie — rysuje je ten sam pasek PaganDE.

/// Kod wygenerowany z `protocols/pagan-app-menu-v1.xml` (strona klienta).
pub mod protocol {
    #[allow(clippy::all)]
    pub mod client {
        // Wygenerowany kod odwołuje się do crate'ów przez `super::wayland_client`
        // oraz do interfejsów rdzenia (np. `wl_surface`) — oba musimy mieć w zasięgu.
        #[allow(unused_imports)]
        use wayland_client;
        #[allow(unused_imports)]
        use wayland_client::protocol::*;

        pub mod __interfaces {
            use wayland_client::protocol::__interfaces::*;
            wayland_scanner::generate_interfaces!("../protocols/pagan-app-menu-v1.xml");
        }
        use self::__interfaces::*;

        wayland_scanner::generate_client_code!("../protocols/pagan-app-menu-v1.xml");
    }
}

pub use protocol::client::pagan_app_menu_manager_v1::PaganAppMenuManagerV1;
pub use protocol::client::pagan_app_menu_v1::{Event as AppMenuEvent, PaganAppMenuV1};

use wayland_client::{Connection, Dispatch, QueueHandle};

use crate::connection::CompositorState;

/// Definicja menu aplikacji: tytuły górnego poziomu i pozycje każdego z nich.
///
/// PO CO po stronie klienta trzymamy CAŁE menu: kompozytor rysuje tylko tytuły
/// w pasku SSD, a zawartość rozwinięcia pokazuje klient (bo tylko on ją zna).
/// Jedno miejsce definicji = tytuły i pozycje nie mogą się rozjechać.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AppMenu {
    entries: Vec<(String, Vec<String>)>,
}

impl AppMenu {
    /// Puste menu.
    pub fn new() -> Self {
        Self::default()
    }

    /// Dodaje tytuł górnego poziomu wraz z jego pozycjami (kolejność = kolejność w pasku).
    pub fn add(&mut self, title: &str, items: &[&str]) -> &mut Self {
        self.entries.push((
            title.to_string(),
            items.iter().map(|item| item.to_string()).collect(),
        ));
        self
    }

    /// Tytuły górnego poziomu (to publikujemy kompozytorowi).
    pub fn titles(&self) -> Vec<String> {
        self.entries
            .iter()
            .map(|(title, _)| title.clone())
            .collect()
    }

    /// Pozycje menu o danym indeksie (zawartość rozwinięcia).
    pub fn items(&self, index: usize) -> Vec<String> {
        self.entries
            .get(index)
            .map(|(_, items)| items.clone())
            .unwrap_or_default()
    }

    /// Czy menu jest puste.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

/// Zamienia listę etykiet na format protokołu (rozdzielony znakami nowej linii).
///
/// PO CO tak, a nie tablica: napis jest prostszy w oby dwóch bindingach i w pełni
/// wystarcza — etykiety menu nie zawierają znaków nowej linii.
pub fn encode_labels(labels: &[&str]) -> String {
    labels.join("\n")
}

#[cfg(test)]
mod tests {
    use super::{encode_labels, AppMenu, AppMenuEvent};

    #[test]
    fn kodowanie_etykiet_jest_odwracalne() {
        let labels = ["Plik", "Edycja", "Widok", "Pomoc"];
        let encoded = encode_labels(&labels);
        assert_eq!(encoded, "Plik\nEdycja\nWidok\nPomoc");
        // Po stronie kompozytora ten sam napis jest dzielony z powrotem.
        let decoded: Vec<&str> = encoded.split('\n').collect();
        assert_eq!(decoded, labels);
    }

    #[test]
    fn pusta_lista_daje_pusty_napis() {
        assert!(encode_labels(&[]).is_empty());
    }

    /// Zdarzenie `clicked` niesie indeks tytułu ORAZ pozycję etykiety.
    #[test]
    fn zdarzenie_klikniecia_niesie_indeks_i_pozycje() {
        let event = AppMenuEvent::Clicked { index: 2, x: 40 };
        match event {
            AppMenuEvent::Clicked { index, x } => {
                assert_eq!(index, 2);
                assert_eq!(x, 40);
            }
        }
    }

    #[test]
    fn menu_trzyma_tytuly_i_pozycje() {
        let mut menu = AppMenu::new();
        menu.add("Plik", &["Nowy", "Zamknij"])
            .add("Pomoc", &["O programie"]);
        assert_eq!(menu.titles(), vec!["Plik".to_string(), "Pomoc".to_string()]);
        assert_eq!(
            menu.items(0),
            vec!["Nowy".to_string(), "Zamknij".to_string()]
        );
        assert_eq!(menu.items(1), vec!["O programie".to_string()]);
        assert!(menu.items(9).is_empty());
        assert!(!menu.is_empty());
        assert!(AppMenu::new().is_empty());
    }
}

// Menedżer nie ma zdarzeń — wystarczy puste `Dispatch`, żeby wayland-client
// wiedział, dokąd kierować ewentualne komunikaty.
impl Dispatch<PaganAppMenuManagerV1, ()> for CompositorState {
    fn event(
        _state: &mut Self,
        _proxy: &PaganAppMenuManagerV1,
        _event: <PaganAppMenuManagerV1 as wayland_client::Proxy>::Event,
        _data: &(),
        _conn: &Connection,
        _qh: &QueueHandle<Self>,
    ) {
    }
}

impl Dispatch<PaganAppMenuV1, ()> for CompositorState {
    fn event(
        state: &mut Self,
        _proxy: &PaganAppMenuV1,
        event: AppMenuEvent,
        _data: &(),
        _conn: &Connection,
        _qh: &QueueHandle<Self>,
    ) {
        let AppMenuEvent::Clicked { index, x } = event;
        let index = index as usize;
        // Klik w tytuł menu: od razu rozwijamy panel pod tą etykietą. Pozycje
        // znamy z lokalnej definicji (`AppMenu`), więc nie ma zbędnego round-tripu.
        let items = state.app_menu_def.items(index);
        state.pending_menu_click = Some((index, x));
        if !items.is_empty() {
            state.window.open_menu(index, x, items);
            state.needs_redraw = true;
        }
        tracing::debug!(index, x, "kompozytor zgłosił kliknięcie etykiety menu");
    }
}