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>