← PaganDE

Commit 794300f

12
plików
+469
dodanych
-79
usuniętych
@@ -155,10 +155,19 @@ dzięki temu od razu widać, gdzie trafi klawiatura.
155 155 Każda aplikacja ma **inne** menu, więc kompozytor nie może znać jego zawartości.
156 156 Rozdzielamy odpowiedzialność:
157 157
158 -* **klient** publikuje same ETYKIETY górnego poziomu (`set_labels`),
159 -* **kompozytor** rysuje je w firmowym pasku i odsyła zdarzenie `clicked(index)`,
160 - gdy użytkownik w coś kliknie,
161 -* **klient** zna zawartość rozwinięcia i pokazuje je sam (docelowo jako `xdg_popup`).
158 +* **klient** publikuje same TYTUŁY górnego poziomu (`set_labels`),
159 +* **kompozytor** rysuje je w firmowym pasku i odsyła zdarzenie `clicked(index, x)`
160 + (z pozycją etykiety, żeby klient wiedział, gdzie rozwinąć),
161 +* **klient** rysuje ROZWINIĘCIE we własnej powierzchni (`OpenMenu`) i wykonuje
162 + akcję wybranej pozycji (`WindowAction::MenuItem`).
163 +
164 +Pełny łańcuch działania (z logu demo):
165 +
166 +```
167 +otwarto menu z paska tytułu index=0
168 +wybrano pozycję menu aplikacji menu_index=0 item_index=3 label="Zamknij"
169 +Zamykanie okna na życzenie użytkownika.
170 +```
162 171
163 172 Opis protokołu: [`protocols/pagan-app-menu-v1.xml`](protocols/pagan-app-menu-v1.xml).
164 173 Kod generuje `wayland-scanner` po obu stronach (te same etykiety na serwerze
@@ -166,7 +175,10 @@ w `pagan-compositor/src/app_menu.rs` i u klienta w `pagan-toolkit/src/app_menu.r
166 175 Użycie w aplikacji:
167 176
168 177 ```rust
169 -pagan_toolkit::publish_app_menu(&mut state, &["Plik", "Edycja", "Widok", "Pomoc"])?;
178 +let mut menu = pagan_toolkit::AppMenu::new();
179 +menu.add("Plik", &["Nowe okno", "Otwórz…", "Zapisz", "Zamknij"])
180 + .add("Edycja", &["Wytnij", "Kopiuj", "Wklej"]);
181 +pagan_toolkit::publish_app_menu(&mut state, &menu)?;
170 182 ```
171 183
172 184 Aplikacje, które tego protokołu nie używają (np. terminale X11), dostają w pasku
@@ -21,6 +21,8 @@ use wayland_server::{
21 21 backend::ClientId, protocol::wl_surface::WlSurface, Dispatch, DisplayHandle, GlobalDispatch,
22 22 };
23 23
24 +use smithay::utils::{Logical, Point};
25 +
24 26 use crate::{state::MyCompositor, window::PaganWindow};
25 27
26 28 /// Kod wygenerowany z `protocols/pagan-app-menu-v1.xml`.
@@ -115,15 +117,24 @@ fn clear_labels(window: &PaganWindow) {
115 117
116 118 /// Informuje klienta, że użytkownik kliknął etykietę o danym indeksie.
117 119 ///
120 +/// Wysyłamy także POZIOMĄ pozycję etykiety we współrzędnych powierzchni klienta —
121 +/// dzięki temu klient ustawia rozwinięcie dokładnie pod właściwą etykietą, nie
122 +/// znając układu paska kompozytora.
123 +///
118 124 /// PO CO tutaj: obsługa myszy (`input.rs`) nie powinna znać szczegółów protokołu —
119 125 /// woła po prostu „okno, kliknięto pozycję N”.
120 -pub fn send_click(window: &PaganWindow, index: usize) {
126 +pub fn send_click(window: &PaganWindow, location: Point<i32, Logical>, index: usize) {
127 + let x = window
128 + .ssd_menu_label_rect(location, index)
129 + .map(|rect| rect.loc.x - location.x)
130 + .unwrap_or(0);
131 +
121 132 let state = window_state(window);
122 133 let Ok(guard) = state.resource.lock() else {
123 134 return;
124 135 };
125 136 if let Some(resource) = guard.as_ref() {
126 - resource.clicked(index as u32);
137 + resource.clicked(index as u32, x);
127 138 }
128 139 }
129 140
@@ -413,7 +413,7 @@ impl MyCompositor {
413 413 // ale nie jest przyciskiem okna. Informujemy o kliknięciu KLIENTA —
414 414 // on zna zawartość swojego menu i pokaże rozwinięcie.
415 415 if let Some(index) = window.ssd_menu_label_at(location, point) {
416 - crate::app_menu::send_click(&window, index);
416 + crate::app_menu::send_click(&window, location, index);
417 417 return true;
418 418 }
419 419 // Przeciąganie za pasek — ten sam grab co przy `xdg_toplevel.move`.
@@ -272,6 +272,29 @@ impl PaganWindow {
272 272 && self.geometry().size.w >= theme::current().ssd_menu_min_width
273 273 }
274 274
275 + /// Prostokąty pól etykiet menu aplikacji (układ w pasku SSD).
276 + pub fn ssd_menu_label_rects(
277 + &self,
278 + location: Point<i32, Logical>,
279 + ) -> Vec<Rectangle<i32, Logical>> {
280 + let labels = crate::app_menu::labels(self);
281 + if labels.is_empty() {
282 + return Vec::new();
283 + }
284 + let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
285 + let widths = menu_label_widths(&refs, theme::current().ssd_font_px as f64);
286 + menu_label_rects(self.titlebar_rect(location), &widths)
287 + }
288 +
289 + /// Prostokąt pola konkretnej etykiety menu (do wyliczenia pozycji rozwinięcia).
290 + pub fn ssd_menu_label_rect(
291 + &self,
292 + location: Point<i32, Logical>,
293 + index: usize,
294 + ) -> Option<Rectangle<i32, Logical>> {
295 + self.ssd_menu_label_rects(location).get(index).copied()
296 + }
297 +
275 298 /// Indeks etykiety menu pod punktem (jeśli któraś trafiona).
276 299 pub fn ssd_menu_label_at(
277 300 &self,
@@ -281,11 +304,7 @@ impl PaganWindow {
281 304 if !self.wants_ssd() || !self.ssd_bar_uses_labels() {
282 305 return None;
283 306 }
284 - let bar = self.titlebar_rect(location);
285 - let labels = crate::app_menu::labels(self);
286 - let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
287 - let widths = menu_label_widths(&refs, theme::current().ssd_font_px as f64);
288 - menu_label_rects(bar, &widths)
307 + self.ssd_menu_label_rects(location)
289 308 .iter()
290 309 .position(|rect| rect.to_f64().contains(point))
291 310 }
@@ -1,4 +1,4 @@
1 -//! Demo `pagan-toolkit`: okno 800×500 z własnym paskiem tytułu rysowanym Cairo.
1 +//! Demo `pagan-toolkit`: okno z dekoracjami serwerowymi (SSD) i działającym menu.
2 2 //!
3 3 //! Uruchomienie (na kompozytorze PaganDE lub dowolnym innym kompozytorze Wayland):
4 4 //!
@@ -9,10 +9,12 @@
9 9 //!
10 10 //! Co pokazuje to demo:
11 11 //! * połączenie z kompozytorem i role xdg-shell (`create_window`),
12 -//! * renderowanie do bufora SHM i wysłanie pierwszej ramki (`render_and_commit`),
13 -//! * pętlę zdarzeń bez panik — każdy błąd jest logowany i kończy pętlę,
14 -//! * reakcję na `WindowAction`: Close → wyjście, StartMove → `xdg_toplevel.move`,
15 -//! ShowMenu → log, Maximize → `set_maximized` + przerysowanie,
12 +//! * **menu aplikacji w pasku SSD**: publikujemy tytuły własnym protokołem
13 +//! `pagan_app_menu_v1`, kompozytor rysuje je obok przycisków okna i odsyła
14 +//! kliknięcia (`pending_menu_click`),
15 +//! * **rozwijane menu** rysowane przez klienta (`OpenMenu`): klik w pozycję
16 +//! kończy się akcją (`WindowAction::MenuItem`),
17 +//! * renderowanie do bufora SHM i wysłanie ramki (`render_and_commit`),
16 18 //! * lokalizację napisów przez `PAGAN_LANG` (PL/EN).
17 19
18 20 use tracing_subscriber::EnvFilter;
@@ -20,22 +22,45 @@ use tracing_subscriber::EnvFilter;
20 22 use pagan_toolkit::connection::{create_window, publish_app_menu, render_and_commit};
21 23 use pagan_toolkit::i18n::{tr, Locale, MsgKey};
22 24 use pagan_toolkit::window::WindowAction;
25 +use pagan_toolkit::{AppMenu, CompositorState};
23 26
24 27 /// Rozmiar okna demo. Bufor SHM ma dokładnie ten rozmiar (4 bajty/piksel).
25 28 const WINDOW_WIDTH: i32 = 800;
26 29 const WINDOW_HEIGHT: i32 = 500;
27 30
28 -/// Pozycje menu aplikacji w kolejności wyświetlania w pasku tytułu.
31 +/// Definicja menu aplikacji (tytuły + pozycje), lokalizowana przez `PAGAN_LANG`.
29 32 ///
30 -/// PO CO osobna funkcja: te same etykiety publikujemy kompozytorowi (rysuje je
31 -/// w pasku SSD) i używamy do rozpoznania kliknięcia w `pending_menu_click`.
32 -fn menu_labels(locale: Locale) -> [&'static str; 4] {
33 - [
34 - tr(locale, MsgKey::MenuFile),
35 - tr(locale, MsgKey::MenuEdit),
36 - tr(locale, MsgKey::MenuView),
37 - tr(locale, MsgKey::MenuHelp),
38 - ]
33 +/// PO CO jedno miejsce: te same tytuły publikujemy kompozytorowi (rysuje je
34 +/// w pasku SSD), a pozycje tworzą rozwinięcie pokazywane po kliknięciu.
35 +fn app_menu(locale: Locale) -> AppMenu {
36 + let mut menu = AppMenu::new();
37 + match locale {
38 + Locale::Pl => {
39 + menu.add(
40 + tr(locale, MsgKey::MenuFile),
41 + &["Nowe okno", "Otwórz…", "Zapisz", "Zamknij"],
42 + )
43 + .add(tr(locale, MsgKey::MenuEdit), &["Wytnij", "Kopiuj", "Wklej"])
44 + .add(
45 + tr(locale, MsgKey::MenuView),
46 + &["Powiększ", "Pomniejsz", "Pełny ekran"],
47 + )
48 + .add(tr(locale, MsgKey::MenuHelp), &["O programie"]);
49 + }
50 + Locale::En => {
51 + menu.add(
52 + tr(locale, MsgKey::MenuFile),
53 + &["New window", "Open…", "Save", "Quit"],
54 + )
55 + .add(tr(locale, MsgKey::MenuEdit), &["Cut", "Copy", "Paste"])
56 + .add(
57 + tr(locale, MsgKey::MenuView),
58 + &["Zoom in", "Zoom out", "Fullscreen"],
59 + )
60 + .add(tr(locale, MsgKey::MenuHelp), &["About"]);
61 + }
62 + }
63 + menu
39 64 }
40 65
41 66 fn main() {
@@ -56,9 +81,10 @@ fn main() {
56 81 };
57 82 let qh = event_queue.handle();
58 83
59 - // 1b. Publikujemy menu aplikacji kompozytorowi — narysuje je w pasku tytułu
60 - // (we własnym protokole PaganDE), a kliknięcia odeśle nam zdarzeniem.
61 - if let Err(err) = publish_app_menu(&mut state, &menu_labels(locale)) {
84 + // 1b. Publikujemy menu aplikacji kompozytorowi — narysuje tytuły w pasku
85 + // tytułu, a kliknięcia odeśle nam zdarzeniem `clicked`.
86 + let menu = app_menu(locale);
87 + if let Err(err) = publish_app_menu(&mut state, &menu) {
62 88 tracing::warn!(error = %err, "nie udało się opublikować menu aplikacji");
63 89 }
64 90
@@ -70,6 +96,9 @@ fn main() {
70 96 tracing::error!(error = %err, "nie udało się wypchnąć żądań do kompozytora");
71 97 }
72 98
99 + // Numer tytułu menu, którego rozwinięcie jest aktualnie otwarte (jeśli któreś).
100 + let mut open_menu: Option<usize> = None;
101 +
73 102 // 3. Pętla zdarzeń. `blocking_dispatch` blokuje do najbliższego zdarzenia,
74 103 // więc gdy nic się nie dzieje, proces nie zajmuje CPU.
75 104 while state.running {
@@ -83,17 +112,16 @@ fn main() {
83 112
84 113 // 4. Reakcja na akcję wyprodukowaną przez wejście (patrz `input.rs`).
85 114 if let Some(action) = state.pending_action.take() {
86 - handle_action(&mut state, action);
115 + handle_action(&mut state, action, &mut open_menu);
87 116 }
88 117
89 - // 4b. Kliknięcie etykiety menu aplikacji (zdarzenie z kompozytora).
90 - if let Some(index) = state.pending_menu_click.take() {
91 - let labels = menu_labels(state.locale);
92 - let label = labels.get(index).copied().unwrap_or("?");
93 - tracing::info!(index, label, "kliknięto etykietę menu aplikacji");
118 + // 5. Kliknięcie etykiety menu w pasku SSD (zdarzenie z kompozytora).
119 + if let Some((index, _x)) = state.pending_menu_click.take() {
120 + open_menu = Some(index);
121 + tracing::info!(index, "otwarto menu z paska tytułu");
94 122 }
95 123
96 - // 5. Przerysowanie tylko wtedy, gdy faktycznie coś się zmieniło (hover, configure, akcja).
124 + // 6. Przerysowanie tylko wtedy, gdy faktycznie coś się zmieniło (hover, configure, akcja).
97 125 if state.needs_redraw && state.configured {
98 126 if let Err(err) = render_and_commit(&mut state, &qh) {
99 127 tracing::error!(error = %err, "nie udało się przerysować okna");
@@ -112,8 +140,8 @@ fn main() {
112 140 ///
113 141 /// Uwaga: `StartMove` i `resize` zostały już przekazane kompozytorowi w `input.rs`
114 142 /// (bo tylko tam mieliśmy świeży serial). Tutaj tylko logujemy i ewentualnie
115 -/// zmieniamy własny stan (np. maksymalizację).
116 -fn handle_action(state: &mut pagan_toolkit::CompositorState, action: WindowAction) {
143 +/// zmieniamy własny stan (np. maksymalizację albo zamknięcie okna).
144 +fn handle_action(state: &mut CompositorState, action: WindowAction, open_menu: &mut Option<usize>) {
117 145 match action {
118 146 WindowAction::Close => {
119 147 // Logujemy w wybranym języku; pętlę zakończy po sprawdzeniu `state.running`.
@@ -141,15 +169,15 @@ fn handle_action(state: &mut pagan_toolkit::CompositorState, action: WindowActio
141 169 tracing::info!("{}", tr(state.locale, MsgKey::LogMaximize));
142 170 }
143 171 WindowAction::ShowMenu => {
144 - // W wersji 0.1 menu jest tylko sygnalizowane w logu i przełącza flagę.
145 - state.menu_open = !state.menu_open;
146 - state.needs_redraw = true;
147 - tracing::info!(
148 - open = state.menu_open,
149 - "{}: {}",
150 - tr(state.locale, MsgKey::Menu),
151 - tr(state.locale, MsgKey::LogMenu)
152 - );
172 + // Wywoływane tylko w trybie CSD (na obcym kompozytorze) — u nas menu
173 + // jest w pasku SSD, więc ten przycisk zwykle nie występuje.
174 + tracing::info!("{}", tr(state.locale, MsgKey::LogMenu));
175 + }
176 + WindowAction::MenuItem(item_index) => {
177 + let menu_index = open_menu.take();
178 + if let Some(menu_index) = menu_index {
179 + run_menu_action(state, menu_index, item_index);
180 + }
153 181 }
154 182 WindowAction::StartMove => {
155 183 tracing::info!("{}", tr(state.locale, MsgKey::LogDrag));
@@ -158,6 +186,32 @@ fn handle_action(state: &mut pagan_toolkit::CompositorState, action: WindowActio
158 186 }
159 187 }
160 188
189 +/// Wykonuje akcję wybranej pozycji menu.
190 +///
191 +/// PO CO osobno: pokazuje pełny łańcuch działania — kompozytor rysuje tytuły,
192 +/// klient rozwija pozycje, a wybór zamienia się na konkretną akcję aplikacji.
193 +fn run_menu_action(state: &mut CompositorState, menu_index: usize, item_index: usize) {
194 + let label = state
195 + .app_menu_def
196 + .items(menu_index)
197 + .get(item_index)
198 + .cloned()
199 + .unwrap_or_default();
200 + tracing::info!(
201 + menu_index,
202 + item_index,
203 + label,
204 + "wybrano pozycję menu aplikacji"
205 + );
206 +
207 + // „Zamknij" to ostatnia pozycja menu „Plik" — najprostszy test, że akcja
208 + // naprawdę dociera do aplikacji.
209 + if menu_index == 0 && item_index == 3 {
210 + tracing::info!("{}", tr(state.locale, MsgKey::LogClosing));
211 + state.running = false;
212 + }
213 +}
214 +
161 215 /// Konfiguruje `tracing-subscriber`; domyślnie poziom `info`, nadpisywalny przez `RUST_LOG`.
162 216 fn init_logging() {
163 217 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
@@ -36,6 +36,53 @@ use wayland_client::{Connection, Dispatch, QueueHandle};
36 36
37 37 use crate::connection::CompositorState;
38 38
39 +/// Definicja menu aplikacji: tytuły górnego poziomu i pozycje każdego z nich.
40 +///
41 +/// PO CO po stronie klienta trzymamy CAŁE menu: kompozytor rysuje tylko tytuły
42 +/// w pasku SSD, a zawartość rozwinięcia pokazuje klient (bo tylko on ją zna).
43 +/// Jedno miejsce definicji = tytuły i pozycje nie mogą się rozjechać.
44 +#[derive(Debug, Clone, Default, PartialEq)]
45 +pub struct AppMenu {
46 + entries: Vec<(String, Vec<String>)>,
47 +}
48 +
49 +impl AppMenu {
50 + /// Puste menu.
51 + pub fn new() -> Self {
52 + Self::default()
53 + }
54 +
55 + /// Dodaje tytuł górnego poziomu wraz z jego pozycjami (kolejność = kolejność w pasku).
56 + pub fn add(&mut self, title: &str, items: &[&str]) -> &mut Self {
57 + self.entries.push((
58 + title.to_string(),
59 + items.iter().map(|item| item.to_string()).collect(),
60 + ));
61 + self
62 + }
63 +
64 + /// Tytuły górnego poziomu (to publikujemy kompozytorowi).
65 + pub fn titles(&self) -> Vec<String> {
66 + self.entries
67 + .iter()
68 + .map(|(title, _)| title.clone())
69 + .collect()
70 + }
71 +
72 + /// Pozycje menu o danym indeksie (zawartość rozwinięcia).
73 + pub fn items(&self, index: usize) -> Vec<String> {
74 + self.entries
75 + .get(index)
76 + .map(|(_, items)| items.clone())
77 + .unwrap_or_default()
78 + }
79 +
80 + /// Czy menu jest puste.
81 + pub fn is_empty(&self) -> bool {
82 + self.entries.is_empty()
83 + }
84 +}
85 +
39 86 /// Zamienia listę etykiet na format protokołu (rozdzielony znakami nowej linii).
40 87 ///
41 88 /// PO CO tak, a nie tablica: napis jest prostszy w oby dwóch bindingach i w pełni
@@ -46,7 +93,7 @@ pub fn encode_labels(labels: &[&str]) -> String {
46 93
47 94 #[cfg(test)]
48 95 mod tests {
49 - use super::{encode_labels, AppMenuEvent};
96 + use super::{encode_labels, AppMenu, AppMenuEvent};
50 97
51 98 #[test]
52 99 fn kodowanie_etykiet_jest_odwracalne() {
@@ -63,14 +110,33 @@ mod tests {
63 110 assert!(encode_labels(&[]).is_empty());
64 111 }
65 112
66 - /// Zdarzenie `clicked` nie może zostać pomylone z żadnym innym.
113 + /// Zdarzenie `clicked` niesie indeks tytułu ORAZ pozycję etykiety.
67 114 #[test]
68 - fn zdarzenie_klikniecia_niesie_indeks() {
69 - let event = AppMenuEvent::Clicked { index: 2 };
115 + fn zdarzenie_klikniecia_niesie_indeks_i_pozycje() {
116 + let event = AppMenuEvent::Clicked { index: 2, x: 40 };
70 117 match event {
71 - AppMenuEvent::Clicked { index } => assert_eq!(index, 2),
118 + AppMenuEvent::Clicked { index, x } => {
119 + assert_eq!(index, 2);
120 + assert_eq!(x, 40);
121 + }
72 122 }
73 123 }
124 +
125 + #[test]
126 + fn menu_trzyma_tytuly_i_pozycje() {
127 + let mut menu = AppMenu::new();
128 + menu.add("Plik", &["Nowy", "Zamknij"])
129 + .add("Pomoc", &["O programie"]);
130 + assert_eq!(menu.titles(), vec!["Plik".to_string(), "Pomoc".to_string()]);
131 + assert_eq!(
132 + menu.items(0),
133 + vec!["Nowy".to_string(), "Zamknij".to_string()]
134 + );
135 + assert_eq!(menu.items(1), vec!["O programie".to_string()]);
136 + assert!(menu.items(9).is_empty());
137 + assert!(!menu.is_empty());
138 + assert!(AppMenu::new().is_empty());
139 + }
74 140 }
75 141
76 142 // Menedżer nie ma zdarzeń — wystarczy puste `Dispatch`, żeby wayland-client
@@ -96,10 +162,16 @@ impl Dispatch<PaganAppMenuV1, ()> for CompositorState {
96 162 _conn: &Connection,
97 163 _qh: &QueueHandle<Self>,
98 164 ) {
99 - let AppMenuEvent::Clicked { index } = event;
100 - // Kliknięcie obsługuje APLIKACJA (ona zna zawartość swojego menu).
101 - // Zapamiętujemy indeks; pętla główna odbierze go z `pending_menu_click`.
102 - state.pending_menu_click = Some(index as usize);
103 - tracing::debug!(index, "kompozytor zgłosił kliknięcie etykiety menu");
165 + let AppMenuEvent::Clicked { index, x } = event;
166 + let index = index as usize;
167 + // Klik w tytuł menu: od razu rozwijamy panel pod tą etykietą. Pozycje
168 + // znamy z lokalnej definicji (`AppMenu`), więc nie ma zbędnego round-tripu.
169 + let items = state.app_menu_def.items(index);
170 + state.pending_menu_click = Some((index, x));
171 + if !items.is_empty() {
172 + state.window.open_menu(index, x, items);
173 + state.needs_redraw = true;
174 + }
175 + tracing::debug!(index, x, "kompozytor zgłosił kliknięcie etykiety menu");
104 176 }
105 177 }
@@ -25,7 +25,7 @@ use wayland_protocols::xdg::shell::client::{
25 25 xdg_wm_base::{self, XdgWmBase},
26 26 };
27 27
28 -use crate::app_menu::{encode_labels, PaganAppMenuManagerV1, PaganAppMenuV1};
28 +use crate::app_menu::{encode_labels, AppMenu, PaganAppMenuManagerV1, PaganAppMenuV1};
29 29 use crate::i18n::Locale;
30 30 use crate::shm::{ShmBuffer, ShmError};
31 31 use crate::window::{MyToolkitWindow, WindowAction};
@@ -151,8 +151,10 @@ pub struct CompositorState {
151 151 pub app_menu_manager: Option<PaganAppMenuManagerV1>,
152 152 /// `pagan_app_menu_v1` dla naszego okna.
153 153 pub app_menu: Option<PaganAppMenuV1>,
154 - /// Indeks etykiety menu klikniętej przez użytkownika (do odebrania w pętli).
155 - pub pending_menu_click: Option<usize>,
154 + /// Lokalna definicja menu (tytuły + pozycje) — z niej bierzemy rozwinięcie.
155 + pub app_menu_def: AppMenu,
156 + /// Indeks tytułu i pozycja X etykiety klikniętej w pasku (do odebrania w pętli).
157 + pub pending_menu_click: Option<(usize, i32)>,
156 158
157 159 // --- bufor ---
158 160 /// Aktualnie używany bufor SHM (tworzony leniwie, dopasowany do rozmiaru okna).
@@ -409,6 +411,7 @@ pub fn create_window(
409 411 decoration,
410 412 app_menu_manager,
411 413 app_menu,
414 + app_menu_def: AppMenu::new(),
412 415 pending_menu_click: None,
413 416 shm_buffer: None,
414 417 configured: false,
@@ -505,23 +508,25 @@ pub fn render_and_commit(
505 508 Ok(())
506 509 }
507 510
508 -/// Publikuje etykiety menu aplikacji w pasku tytułu (własny protokół PaganDE).
511 +/// Publikuje menu aplikacji w pasku tytułu (własny protokół PaganDE).
509 512 ///
510 -/// PO CO osobna funkcja: aplikacja wywołuje ją, gdy zna już swoje menu — a to
511 -/// może zależeć od kontekstu (np. inny zestaw pozycji w trybie edycji). Kompozytor
512 -/// narysuje je w firmowym pasku, a kliknięcia odeśle zdarzeniem `clicked`.
513 +/// PO CO przekazujemy CAŁE menu: kompozytorowi wysyłamy tylko tytuły górnego
514 +/// poziomu, a pozycje zachowujemy lokalnie — to one tworzą rozwinięcie pokazywane
515 +/// po kliknięciu etykiety.
513 516 ///
514 517 /// Brak globala `pagan_app_menu_manager_v1` nie jest błędem — na obcym
515 518 /// kompozytorze po prostu nie ma gdzie pokazać menu.
516 -pub fn publish_app_menu(state: &mut CompositorState, labels: &[&str]) -> Result<(), ToolkitError> {
517 - let Some(menu) = state.app_menu.as_ref() else {
518 - return Ok(());
519 - };
520 - menu.set_labels(encode_labels(labels));
521 - // Wypychamy od razu, żeby pasek zdążył się zaktualizować przed pierwszą klatką.
522 - state
523 - .connection
524 - .flush()
525 - .map_err(|err| ToolkitError::Wayland(err.to_string()))?;
519 +pub fn publish_app_menu(state: &mut CompositorState, menu: &AppMenu) -> Result<(), ToolkitError> {
520 + let titles = menu.titles();
521 + if let Some(app_menu) = state.app_menu.as_ref() {
522 + let refs: Vec<&str> = titles.iter().map(String::as_str).collect();
523 + app_menu.set_labels(encode_labels(&refs));
524 + // Wypychamy od razu, żeby pasek zdążył się zaktualizować przed pierwszą klatką.
525 + state
526 + .connection
527 + .flush()
528 + .map_err(|err| ToolkitError::Wayland(err.to_string()))?;
529 + }
530 + state.app_menu_def = menu.clone();
526 531 Ok(())
527 532 }
@@ -14,12 +14,16 @@
14 14
15 15 use std::f64::consts::{FRAC_PI_2, PI};
16 16
17 -use cairo::{Context, FontSlant, FontWeight, Operator};
17 +use cairo::{Context, FontSlant, FontWeight, Format, ImageSurface, Operator};
18 18
19 19 /// Ciemne tło paska tytułu.
20 20 pub const TITLEBAR_COLOR: [f64; 3] = [0.13, 0.14, 0.18];
21 21 /// Ciemne tło „ciała" okna (poniżej paska tytułu).
22 22 pub const BODY_COLOR: [f64; 3] = [0.09, 0.10, 0.12];
23 +/// Tło rozwiniętego menu aplikacji.
24 +pub const MENU_PANEL_COLOR: [f64; 3] = [0.13, 0.14, 0.18];
25 +/// Tło pozycji menu pod kursorem.
26 +pub const MENU_ITEM_HOVER_COLOR: [f64; 3] = [0.22, 0.26, 0.34];
23 27 /// Tło paska menu rysowanego przez klienta w trybie SSD (pod paskiem kompozytora).
24 28 pub const MENU_STRIP_COLOR: [f64; 3] = [0.11, 0.12, 0.15];
25 29 /// Kolor monochromatycznych ikon w trybie SSD (spójny z glifami kompozytora).
@@ -36,6 +40,20 @@ pub const COLOR_MAXIMIZE: [f64; 3] = [0.32, 0.72, 0.40];
36 40 /// Bazowy kolor przycisku zamknięcia.
37 41 pub const COLOR_CLOSE: [f64; 3] = [0.82, 0.30, 0.30];
38 42
43 +/// Mierzy szerokość napisu w pikselach (bez rysowania).
44 +///
45 +/// PO CO: panel rozwiniętego menu musi mieć szerokość dopasowaną do najdłuższej
46 +/// pozycji — a decydujemy o niej, zanim cokolwiek narysujemy (żeby trafienia myszy
47 +/// i rysowanie zgadzały się co do piksela).
48 +pub fn measure_text(text: &str, font_px: f64) -> Result<f64, cairo::Error> {
49 + // Pomiar wymaga kontekstu, więc tworzymy maleńką powierzchnię pomocniczą.
50 + let surface = ImageSurface::create(Format::ARgb32, 1, 1)?;
51 + let ctx = Context::new(&surface)?;
52 + ctx.select_font_face("sans-serif", FontSlant::Normal, FontWeight::Normal);
53 + ctx.set_font_size(font_px);
54 + Ok(ctx.text_extents(text)?.width())
55 +}
56 +
39 57 /// Ustawia bieżący kolor źródła w Cairo.
40 58 ///
41 59 /// Wydzielone, bo w całym module powtarzamy ten sam wzorzec i chcemy mieć jedno
@@ -117,6 +117,16 @@ impl Dispatch<WlPointer, ()> for CompositorState {
117 117 /// Rozdzielenie jest istotne, bo te dwie akcje mają różne wymagania protokołu:
118 118 /// `resize` potrzebuje `Edges`, a `move` tylko serialu.
119 119 fn handle_press(state: &mut CompositorState, x: i32, y: i32, serial: u32) {
120 + // Rozwinięte menu aplikacji obsługuje klik samo (wybór pozycji albo zamknięcie).
121 + // Sprawdzamy to PRZED krawędziami — inaczej klik przy krawędzi zaczynałby
122 + // resize zamiast zamykać menu.
123 + if state.window.menu_is_open() {
124 + let action = state.window.handle_mouse_click(x, y);
125 + state.pending_action = Some(action);
126 + state.needs_redraw = true;
127 + return;
128 + }
129 +
120 130 // 1. Krawędzie okna mają priorytet — nawet jeśli nakładają się na pasek tytułu.
121 131 if let Some(edges) = state.window.resize_edge_at(x, y) {
122 132 if let (Some(toplevel), Some(seat)) = (state.toplevel.as_ref(), state.seat.as_ref()) {
@@ -33,9 +33,10 @@ pub mod shm;
33 33 pub mod window;
34 34
35 35 // Re-exporty publicznego API — dzięki nim użytkownik nie musi znać rozkładu modułów.
36 +pub use app_menu::AppMenu;
36 37 pub use connection::{
37 38 create_window, publish_app_menu, render_and_commit, CompositorState, ToolkitError,
38 39 };
39 40 pub use i18n::{tr, Locale, MsgKey};
40 41 pub use shm::{ShmBuffer, ShmError};
41 -pub use window::{Edges, MyToolkitWindow, WindowAction};
42 +pub use window::{Edges, MyToolkitWindow, OpenMenu, WindowAction};
@@ -32,6 +32,70 @@ pub const MENU_CENTER_X: f64 = 20.0;
32 32 /// Odstęp prawej krawędzi okna od środka ostatniego przycisku (Zamknij).
33 33 pub const WINDOW_CONTROLS_RIGHT_MARGIN: f64 = 20.0;
34 34
35 +// --- Rozwinięte menu aplikacji (rysowane przez klienta) -----------------------
36 +
37 +/// Rozmiar czcionki pozycji menu (px).
38 +pub const MENU_FONT_SIZE: f64 = 13.0;
39 +/// Wysokość jednej pozycji menu (px).
40 +pub const MENU_ITEM_HEIGHT: i32 = 26;
41 +/// Wewnętrzny odstęp poziomy pozycji menu (z każdej strony).
42 +pub const MENU_ITEM_PAD_X: i32 = 14;
43 +/// Wewnętrzny odstęp górny/dolny panelu menu (px).
44 +pub const MENU_PANEL_PAD_Y: i32 = 6;
45 +/// Minimalna szerokość panelu menu (px).
46 +pub const MENU_PANEL_MIN_WIDTH: i32 = 170;
47 +
48 +/// Rozwinięte menu aplikacji — panel wewnątrz powierzchni klienta.
49 +///
50 +/// PO CO wewnątrz powierzchni, a nie jako `xdg_popup`: wynik widać od razu,
51 +/// bez zależności od tego, czy kompozytor rysuje popupy. Panel pojawia się
52 +/// dokładnie pod etykietą klikniętą w pasku SSD (jej pozycję przysyła kompozytor).
53 +#[derive(Debug, Clone, PartialEq)]
54 +pub struct OpenMenu {
55 + /// Indeks tytułu górnego poziomu (np. 0 = „Plik”).
56 + pub title_index: usize,
57 + /// Lewa krawędź panelu w układzie powierzchni (px).
58 + pub x: i32,
59 + /// Szerokość panelu (wyliczona z najdłuższej pozycji).
60 + pub width: i32,
61 + /// Pozycje menu.
62 + pub items: Vec<String>,
63 + /// Pozycja podświetlona kursorem.
64 + pub hover: Option<usize>,
65 +}
66 +
67 +impl OpenMenu {
68 + /// Prostokąt panelu `(x, y, szerokość, wysokość)` w układzie powierzchni.
69 + pub fn panel_rect(&self) -> (i32, i32, i32, i32) {
70 + let height = self.items.len() as i32 * MENU_ITEM_HEIGHT + MENU_PANEL_PAD_Y * 2;
71 + (self.x, 0, self.width, height)
72 + }
73 +
74 + /// Prostokąt pozycji o danym indeksie.
75 + pub fn item_rect(&self, index: usize) -> (i32, i32, i32, i32) {
76 + let (x, y, width, _) = self.panel_rect();
77 + let top = y + MENU_PANEL_PAD_Y + index as i32 * MENU_ITEM_HEIGHT;
78 + (x, top, width, MENU_ITEM_HEIGHT)
79 + }
80 +
81 + /// Indeks pozycji pod punktem (jeśli trafiona).
82 + pub fn item_at(&self, px: i32, py: i32) -> Option<usize> {
83 + for index in 0..self.items.len() {
84 + let (x, y, w, h) = self.item_rect(index);
85 + if px >= x && px < x + w && py >= y && py < y + h {
86 + return Some(index);
87 + }
88 + }
89 + None
90 + }
91 +
92 + /// Czy punkt leży w obrębie panelu (także w odstępie między pozycjami).
93 + pub fn contains(&self, px: i32, py: i32) -> bool {
94 + let (x, y, w, h) = self.panel_rect();
95 + px >= x && px < x + w && py >= y && py < y + h
96 + }
97 +}
98 +
35 99 /// Szerokość strefy przy krawędziach, w której klik rozpoczyna resize.
36 100 pub const RESIZE_BORDER: i32 = 8;
37 101
@@ -63,6 +127,8 @@ pub enum WindowAction {
63 127 StartMove,
64 128 /// Użytkownik kliknął „Menu".
65 129 ShowMenu,
130 + /// Użytkownik wybrał pozycję rozwiniętego menu aplikacji (indeks w rozwinięciu).
131 + MenuItem(usize),
66 132 }
67 133
68 134 /// Zbiór krawędzi okna, przy których trwa interaktywny resize.
@@ -129,6 +195,8 @@ pub struct MyToolkitWindow {
129 195 /// Gdy `true`, klient NIE rysuje własnego paska tytułu ani przycisków okna —
130 196 /// robi to kompozytor. Zapobiega to podwójnemu paskowi.
131 197 pub ssd: bool,
198 + /// Rozwinięte menu aplikacji (jeśli otwarte).
199 + menu: Option<OpenMenu>,
132 200 }
133 201
134 202 impl MyToolkitWindow {
@@ -148,7 +216,43 @@ impl MyToolkitWindow {
148 216 locale,
149 217 hover: None,
150 218 ssd: false,
219 + menu: None,
220 + }
221 + }
222 +
223 + /// Otwiera rozwinięte menu aplikacji pod etykietą o pozycji `x`.
224 + ///
225 + /// PO CO liczymy szerokość tutaj: panel ma objąć najdłuższą pozycję, a ten
226 + /// sam prostokąt służy potem do rysowania I do trafień myszy.
227 + pub fn open_menu(&mut self, title_index: usize, x: i32, items: Vec<String>) {
228 + if items.is_empty() {
229 + self.menu = None;
230 + return;
151 231 }
232 + let text_width = items
233 + .iter()
234 + .map(|item| draw::measure_text(item, MENU_FONT_SIZE).unwrap_or(0.0))
235 + .fold(0.0_f64, f64::max);
236 + let width = (text_width.ceil() as i32 + MENU_ITEM_PAD_X * 2).max(MENU_PANEL_MIN_WIDTH);
237 + // Panel nie może wyjść za prawą krawędź okna.
238 + let x = x.min((self.width - width - 4).max(0));
239 + self.menu = Some(OpenMenu {
240 + title_index,
241 + x,
242 + width,
243 + items,
244 + hover: None,
245 + });
246 + }
247 +
248 + /// Zamyka rozwinięte menu. Zwraca `true`, jeśli coś było otwarte.
249 + pub fn close_menu(&mut self) -> bool {
250 + self.menu.take().is_some()
251 + }
252 +
253 + /// Czy rozwinięte menu jest otwarte.
254 + pub fn menu_is_open(&self) -> bool {
255 + self.menu.is_some()
152 256 }
153 257
154 258 /// Włącza/wyłącza tryb dekoracji serwerowych (SSD).
@@ -159,6 +263,7 @@ impl MyToolkitWindow {
159 263 if self.ssd != ssd {
160 264 self.ssd = ssd;
161 265 self.hover = None;
266 + self.menu = None;
162 267 }
163 268 }
164 269
@@ -170,6 +275,9 @@ impl MyToolkitWindow {
170 275 self.width = width;
171 276 self.height = height;
172 277 self.hover = None;
278 + // Rozwinięte menu zależało od szerokości okna — po zmianie rozmiaru
279 + // jego pozycja byłaby nieaktualna.
280 + self.menu = None;
173 281 }
174 282
175 283 /// Zwraca środek podanej kontrolki w układzie lokalnym powierzchni.
@@ -284,7 +392,7 @@ impl MyToolkitWindow {
284 392 }
285 393 };
286 394
287 - if let Err(err) = self.draw(&ctx) {
395 + if let Err(err) = self.draw(&ctx).and_then(|()| self.draw_menu(&ctx)) {
288 396 tracing::error!(error = %err, "błąd rysowania okna");
289 397 }
290 398
@@ -375,11 +483,76 @@ impl MyToolkitWindow {
375 483 Ok(())
376 484 }
377 485
486 + /// Rysuje rozwinięte menu aplikacji (jeśli otwarte).
487 + ///
488 + /// PO CO osobno od `draw`/`draw_ssd`: menu jest nakładką nad treścią okna
489 + /// i wygląda tak samo niezależnie od tego, kto rysuje pasek tytułu (klient
490 + /// czy kompozytor).
491 + fn draw_menu(&self, ctx: &Context) -> Result<(), cairo::Error> {
492 + let Some(menu) = &self.menu else {
493 + return Ok(());
494 + };
495 + let (px, py, pw, ph) = menu.panel_rect();
496 + let (x, y, w, h) = (px as f64, py as f64, pw as f64, ph as f64);
497 +
498 + // Cień pod panelem — bez niego menu zlewa się z treścią okna.
499 + draw::rounded_rectangle(ctx, x + 1.0, y + 3.0, w, h, 10.0);
500 + ctx.set_source_rgba(0.0, 0.0, 0.0, 0.35);
501 + ctx.fill()?;
502 +
503 + // Tło panelu.
504 + draw::rounded_rectangle(ctx, x, y, w, h, 10.0);
505 + draw::set_color(ctx, draw::MENU_PANEL_COLOR);
506 + ctx.fill()?;
507 +
508 + // Delikatna obwódka w kolorze akcentu PaganDE.
509 + draw::rounded_rectangle(ctx, x + 0.5, y + 0.5, w - 1.0, h - 1.0, 10.0);
510 + ctx.set_source_rgba(0.31, 0.63, 1.0, 0.35);
511 + ctx.set_line_width(1.0);
512 + ctx.stroke()?;
513 +
514 + // Pozycje menu.
515 + for (index, item) in menu.items.iter().enumerate() {
516 + let (ix, iy, iw, ih) = menu.item_rect(index);
517 + if menu.hover == Some(index) {
518 + draw::rounded_rectangle(
519 + ctx,
520 + ix as f64 + 4.0,
521 + iy as f64 + 1.0,
522 + (iw - 8) as f64,
523 + (ih - 2) as f64,
524 + 6.0,
525 + );
526 + draw::set_color(ctx, draw::MENU_ITEM_HOVER_COLOR);
527 + ctx.fill()?;
528 + }
529 + draw::draw_title_text(
530 + ctx,
531 + item,
532 + ix as f64 + MENU_ITEM_PAD_X as f64,
533 + iy as f64 + f64::from(ih) / 2.0,
534 + draw::TITLE_TEXT_COLOR,
535 + )?;
536 + }
537 +
538 + Ok(())
539 + }
540 +
378 541 /// Hit-testing: zwraca akcję dla kliknięcia w punkcie `(x, y)`.
379 542 ///
380 543 /// Kolejność ma znaczenie: najpierw przyciski (małe, precyzyjne cele), dopiero
381 544 /// potem „pusty" pasek tytułu, który rozpoczyna przeciąganie.
382 545 pub fn handle_mouse_click(&mut self, x: i32, y: i32) -> WindowAction {
546 + // Rozwinięte menu jest nad wszystkim — obsługujemy je pierwsze.
547 + // Kliknięcie poza pozycją (także poza panelem) po prostu zamyka menu.
548 + if self.menu.is_some() {
549 + let hit = self.menu.as_ref().and_then(|menu| menu.item_at(x, y));
550 + self.menu = None;
551 + return hit
552 + .map(WindowAction::MenuItem)
553 + .unwrap_or(WindowAction::None);
554 + }
555 +
383 556 if !self.is_inside(x, y) {
384 557 return WindowAction::None;
385 558 }
@@ -412,6 +585,16 @@ impl MyToolkitWindow {
412 585 /// Zwraca `true`, gdy stan się zmienił — tylko wtedy trzeba przerysować okno,
413 586 /// co oszczędza pełne renderowanie Cairo przy każdym drobnym ruchu myszy.
414 587 pub fn handle_mouse_motion(&mut self, x: i32, y: i32) -> bool {
588 + // Nad otwartym menu podświetlamy pozycję pod kursorem.
589 + if let Some(menu) = self.menu.as_mut() {
590 + let hover = menu.item_at(x, y);
591 + if hover != menu.hover {
592 + menu.hover = hover;
593 + return true;
594 + }
595 + return false;
596 + }
597 +
415 598 if self.ssd {
416 599 // W trybie SSD klient nie rysuje własnych kontrolek — nic nie podświetlamy.
417 600 if self.hover.take().is_some() {
@@ -88,8 +88,13 @@
88 88 Kompozytor informuje, że użytkownik kliknął etykietę o podanym
89 89 indeksie (0 = pierwsza etykieta z ostatniego `set_labels`). Klient
90 90 powinien wtedy pokazać zawartość tego menu.
91 +
92 + `x` to pozioma pozycja LEWEJ krawędzi etykiety we współrzędnych
93 + powierzchni klienta — dzięki temu klient może ustawić rozwinięcie
94 + dokładnie pod właściwą etykietą, nie znając układu paska kompozytora.
91 95 </description>
92 96 <arg name="index" type="uint"/>
97 + <arg name="x" type="int"/>
93 98 </event>
94 99 </interface>
95 100 </protocol>