← PaganDE

Commit 1839d1e

15
plików
+1702
dodanych
-154
usuniętych
@@ -162,6 +162,29 @@ version = "1.12.1"
162 162 source = "registry+https://github.com/rust-lang/crates.io-index"
163 163 checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
164 164
165 +[[package]]
166 +name = "cairo-rs"
167 +version = "0.22.9"
168 +source = "registry+https://github.com/rust-lang/crates.io-index"
169 +checksum = "df683f1d30b457964673a5541a4603208ef95ab6873d2a55871895cf310f3b56"
170 +dependencies = [
171 + "bitflags 2.13.2",
172 + "cairo-sys-rs",
173 + "glib",
174 + "libc",
175 +]
176 +
177 +[[package]]
178 +name = "cairo-sys-rs"
179 +version = "0.22.9"
180 +source = "registry+https://github.com/rust-lang/crates.io-index"
181 +checksum = "ee548131103ad8f698c6725669647b9c757b3b66992dd6a026037596417bee21"
182 +dependencies = [
183 + "glib-sys",
184 + "libc",
185 + "system-deps",
186 +]
187 +
165 188 [[package]]
166 189 name = "calloop"
167 190 version = "0.13.0"
@@ -213,6 +236,16 @@ dependencies = [
213 236 "shlex",
214 237 ]
215 238
239 +[[package]]
240 +name = "cfg-expr"
241 +version = "0.20.9"
242 +source = "registry+https://github.com/rust-lang/crates.io-index"
243 +checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917"
244 +dependencies = [
245 + "smallvec",
246 + "target-lexicon",
247 +]
248 +
216 249 [[package]]
217 250 name = "cfg-if"
218 251 version = "1.0.4"
@@ -578,6 +611,43 @@ dependencies = [
578 611 "xml-rs",
579 612 ]
580 613
614 +[[package]]
615 +name = "glib"
616 +version = "0.22.9"
617 +source = "registry+https://github.com/rust-lang/crates.io-index"
618 +checksum = "18b9b8d350db41f690ec1b87109f202782748bbd8ab2f2ede17cb40d29e2838c"
619 +dependencies = [
620 + "bitflags 2.13.2",
621 + "glib-macros",
622 + "glib-sys",
623 + "gobject-sys",
624 + "libc",
625 + "memchr",
626 + "smallvec",
627 +]
628 +
629 +[[package]]
630 +name = "glib-macros"
631 +version = "0.22.9"
632 +source = "registry+https://github.com/rust-lang/crates.io-index"
633 +checksum = "c9597c68fa7bdf4154a3079642cd21c4dccac0b0bdd2897d82861523bf91e7b9"
634 +dependencies = [
635 + "heck",
636 + "proc-macro2",
637 + "quote",
638 + "syn 3.0.5",
639 +]
640 +
641 +[[package]]
642 +name = "glib-sys"
643 +version = "0.22.9"
644 +source = "registry+https://github.com/rust-lang/crates.io-index"
645 +checksum = "99b38907e67e40dec9b60f858bcada952598719cb512a13eb13d6cdcd16f8295"
646 +dependencies = [
647 + "libc",
648 + "system-deps",
649 +]
650 +
581 651 [[package]]
582 652 name = "glow"
583 653 version = "0.16.0"
@@ -590,12 +660,29 @@ dependencies = [
590 660 "web-sys",
591 661 ]
592 662
663 +[[package]]
664 +name = "gobject-sys"
665 +version = "0.22.9"
666 +source = "registry+https://github.com/rust-lang/crates.io-index"
667 +checksum = "07595c9ba696bd9819cb6a8df39f08078f3dd679508fe052dd558492c2053834"
668 +dependencies = [
669 + "glib-sys",
670 + "libc",
671 + "system-deps",
672 +]
673 +
593 674 [[package]]
594 675 name = "hashbrown"
595 676 version = "0.17.1"
596 677 source = "registry+https://github.com/rust-lang/crates.io-index"
597 678 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
598 679
680 +[[package]]
681 +name = "heck"
682 +version = "0.5.0"
683 +source = "registry+https://github.com/rust-lang/crates.io-index"
684 +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
685 +
599 686 [[package]]
600 687 name = "hermit-abi"
601 688 version = "0.3.9"
@@ -1185,6 +1272,7 @@ name = "pagan-compositor"
1185 1272 version = "0.1.0"
1186 1273 dependencies = [
1187 1274 "anyhow",
1275 + "cairo-rs",
1188 1276 "inotify",
1189 1277 "rustix 1.1.4",
1190 1278 "smithay",
@@ -1496,6 +1584,15 @@ dependencies = [
1496 1584 "syn 3.0.5",
1497 1585 ]
1498 1586
1587 +[[package]]
1588 +name = "serde_spanned"
1589 +version = "1.1.1"
1590 +source = "registry+https://github.com/rust-lang/crates.io-index"
1591 +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
1592 +dependencies = [
1593 + "serde_core",
1594 +]
1595 +
1499 1596 [[package]]
1500 1597 name = "sha2"
1501 1598 version = "0.10.9"
@@ -1671,6 +1768,25 @@ dependencies = [
1671 1768 "unicode-ident",
1672 1769 ]
1673 1770
1771 +[[package]]
1772 +name = "system-deps"
1773 +version = "9.0.0"
1774 +source = "registry+https://github.com/rust-lang/crates.io-index"
1775 +checksum = "8a0dae2cbca1f0ff93795713cfe0a4c02f760e3c0b8ae2ab4ec4045808ff429f"
1776 +dependencies = [
1777 + "cfg-expr",
1778 + "heck",
1779 + "pkg-config",
1780 + "toml",
1781 + "version-compare",
1782 +]
1783 +
1784 +[[package]]
1785 +name = "target-lexicon"
1786 +version = "0.13.5"
1787 +source = "registry+https://github.com/rust-lang/crates.io-index"
1788 +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
1789 +
1674 1790 [[package]]
1675 1791 name = "tempfile"
1676 1792 version = "3.27.0"
@@ -1733,6 +1849,21 @@ dependencies = [
1733 1849 "cfg-if",
1734 1850 ]
1735 1851
1852 +[[package]]
1853 +name = "toml"
1854 +version = "1.1.6+spec-1.1.0"
1855 +source = "registry+https://github.com/rust-lang/crates.io-index"
1856 +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
1857 +dependencies = [
1858 + "indexmap",
1859 + "serde_core",
1860 + "serde_spanned",
1861 + "toml_datetime",
1862 + "toml_parser",
1863 + "toml_writer",
1864 + "winnow",
1865 +]
1866 +
1736 1867 [[package]]
1737 1868 name = "toml_datetime"
1738 1869 version = "1.1.1+spec-1.1.0"
@@ -1763,6 +1894,12 @@ dependencies = [
1763 1894 "winnow",
1764 1895 ]
1765 1896
1897 +[[package]]
1898 +name = "toml_writer"
1899 +version = "1.1.2+spec-1.1.0"
1900 +source = "registry+https://github.com/rust-lang/crates.io-index"
1901 +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
1902 +
1766 1903 [[package]]
1767 1904 name = "tracing"
1768 1905 version = "0.1.44"
@@ -1861,6 +1998,12 @@ version = "0.1.1"
1861 1998 source = "registry+https://github.com/rust-lang/crates.io-index"
1862 1999 checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1863 2000
2001 +[[package]]
2002 +name = "version-compare"
2003 +version = "0.2.1"
2004 +source = "registry+https://github.com/rust-lang/crates.io-index"
2005 +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
2006 +
1864 2007 [[package]]
1865 2008 name = "version_check"
1866 2009 version = "0.9.5"
@@ -42,6 +42,10 @@ rustix = { version = "1.0", optional = true, features = ["fs"] }
42 42 # `display-info` (a to pociągnęłoby zależność od libdisplay-info).
43 43 smithay-drm-extras = { path = "../smithay-0.7.0/smithay-drm-extras", default-features = false, optional = true }
44 44 inotify = { version = "0.11.5", default-features = false }
45 +# Rasteryzacja tekstu w kompozytorze (tytuł, etykiety menu aplikacji).
46 +# Cairo jest już pakietem w PaganOS (używa go też toolkit), więc nie dokładamy
47 +# nowej zależności systemowej — tylko tę samą, sprawdzoną bibliotekę.
48 +cairo-rs = "0.22"
45 49
46 50 [features]
47 51 default = []
@@ -321,47 +321,128 @@ impl MyCompositor {
321 321 None
322 322 }
323 323
324 - /// Obsługa wciśnięcia przycisku na pasku tytułu (SSD).
324 + /// Najwyżej położone okno, którego ROZWINIĘTE menu zawiera punkt.
325 + fn ssd_menu_hit(
326 + &self,
327 + point: Point<f64, Logical>,
328 + ) -> Option<(PaganWindow, Point<i32, Logical>)> {
329 + for window in self.space.elements().rev() {
330 + let Some(location) = self.space.element_location(window) else {
331 + continue;
332 + };
333 + if window.ssd_menu_contains(location, point) {
334 + return Some((window.clone(), location));
335 + }
336 + }
337 + None
338 + }
339 +
340 + /// Czy jakiekolwiek okno ma otwarte menu.
341 + fn any_ssd_menu_open(&self) -> bool {
342 + self.space.elements().any(PaganWindow::ssd_menu_open)
343 + }
344 +
345 + /// Zamyka menu we wszystkich oknach.
346 + ///
347 + /// PO CO globalnie, a nie per okno: w danej chwili sensowne jest tylko jedno
348 + /// otwarte menu — inaczej kliknięcia „gubią się” między nakładającymi się panelami.
349 + fn close_all_ssd_menus(&self) {
350 + for window in self.space.elements() {
351 + if window.ssd_menu_open() {
352 + window.set_ssd_menu_open(false);
353 + }
354 + }
355 + }
356 +
357 + /// Obsługa wciśnięcia przycisku na pasku tytułu (SSD) oraz w menu okna.
325 358 /// `true` = zdarzenie skonsumowane (nie idzie do klienta).
326 359 fn handle_ssd_press(&mut self, button: u32, serial: Serial) -> bool {
327 360 let point = self.pointer_location;
328 - let Some((window, location)) = self.ssd_hit(point) else {
329 - return false;
330 - };
331 -
332 - // Klik w pasek też aktywuje okno (jak klik w treść).
333 - self.raise_and_focus(&window, serial);
334 361
335 - match window.titlebar_button_at(location, point) {
336 - Some(TitlebarButton::Close) => self.close_window(&window),
337 - Some(TitlebarButton::Minimize) => self.minimize_window(&window),
338 - Some(TitlebarButton::Maximize) => {
339 - if window.is_maximized() {
340 - self.unmaximize_window(&window);
341 - } else {
342 - self.maximize_window(&window);
362 + // 1) Klik w rozwinięte menu okna — wykonaj akcję wiersza i zamknij menu.
363 + if let Some((window, location)) = self.ssd_menu_hit(point) {
364 + self.raise_and_focus(&window, serial);
365 + let row = window.ssd_menu_row_at(location, point);
366 + window.set_ssd_menu_open(false);
367 + match row {
368 + Some(TitlebarButton::Minimize) => self.minimize_window(&window),
369 + Some(TitlebarButton::Maximize) => {
370 + if window.is_maximized() {
371 + self.unmaximize_window(&window);
372 + } else {
373 + self.maximize_window(&window);
374 + }
343 375 }
376 + Some(TitlebarButton::Close) => self.close_window(&window),
377 + // Klik w tło panelu (poza wierszem) tylko zamyka menu.
378 + _ => {}
344 379 }
345 - None => {
346 - // Przeciąganie za pasek — ten sam grab co przy `xdg_toplevel.move`.
347 - let start_data = PointerGrabStartData {
348 - focus: None,
349 - button,
350 - location: point,
351 - };
352 - let grab = PointerMoveSurfaceGrab {
353 - start_data,
354 - initial_window_location: location,
355 - initial_window_size: window.geometry().size,
356 - window,
357 - current_snap: None,
358 - };
359 - self.snap_preview = None;
360 - let pointer = self.pointer.clone();
361 - pointer.set_grab(self, grab, serial, Focus::Clear);
380 + return true;
381 + }
382 +
383 + // 2) Klik w pasek tytułu.
384 + if let Some((window, location)) = self.ssd_hit(point) {
385 + self.raise_and_focus(&window, serial);
386 + match window.titlebar_button_at(location, point) {
387 + Some(TitlebarButton::Menu) => {
388 + // Przełączamy: zapamiętujemy stan PRZED zamknięciem wszystkich,
389 + // bo inaczej ponowny klik w burger nigdy by nie zamknął menu.
390 + let was_open = window.ssd_menu_open();
391 + self.close_all_ssd_menus();
392 + window.set_ssd_menu_open(!was_open);
393 + }
394 + Some(TitlebarButton::Close) => {
395 + self.close_all_ssd_menus();
396 + self.close_window(&window);
397 + }
398 + Some(TitlebarButton::Minimize) => {
399 + self.close_all_ssd_menus();
400 + self.minimize_window(&window);
401 + }
402 + Some(TitlebarButton::Maximize) => {
403 + self.close_all_ssd_menus();
404 + if window.is_maximized() {
405 + self.unmaximize_window(&window);
406 + } else {
407 + self.maximize_window(&window);
408 + }
409 + }
410 + None => {
411 + self.close_all_ssd_menus();
412 + // Etykieta menu aplikacji (Plik/Edycja/Widok/Pomoc) leży w pasku,
413 + // ale nie jest przyciskiem okna. Na razie klik tylko konsumujemy
414 + // (nie przeciągamy okna za menu). Docelowo trafi do aplikacji
415 + // przez własny protokół menu.
416 + if window.ssd_menu_label_at(location, point).is_some() {
417 + return true;
418 + }
419 + // Przeciąganie za pasek — ten sam grab co przy `xdg_toplevel.move`.
420 + let start_data = PointerGrabStartData {
421 + focus: None,
422 + button,
423 + location: point,
424 + };
425 + let grab = PointerMoveSurfaceGrab {
426 + start_data,
427 + initial_window_location: location,
428 + initial_window_size: window.geometry().size,
429 + window,
430 + current_snap: None,
431 + };
432 + self.snap_preview = None;
433 + let pointer = self.pointer.clone();
434 + pointer.set_grab(self, grab, serial, Focus::Clear);
435 + }
362 436 }
437 + return true;
363 438 }
364 - true
439 +
440 + // 3) Klik poza paskiem i poza menu — zamknij ewentualne menu okna.
441 + // Gdy menu było otwarte, konsumujemy klik, żeby nie trafił „przez”
442 + // w treść okna leżącą pod menu.
443 + let menu_was_open = self.any_ssd_menu_open();
444 + self.close_all_ssd_menus();
445 + menu_was_open
365 446 }
366 447
367 448 /// Scroll. Przekazujemy do klienta pod kursorem (np. przewijanie treści).
@@ -24,6 +24,7 @@ mod screencopy;
24 24 mod shaders;
25 25 mod snap;
26 26 mod state;
27 +mod text;
27 28 mod theme;
28 29 mod window;
29 30 mod winit;
@@ -310,9 +310,13 @@ fn preview_program(renderer: &mut GlesRenderer) -> Option<GlesPixelProgram> {
310 310 }
311 311
312 312 let uniforms = [
313 - UniformName::new("radius", UniformType::_1f),
313 + UniformName::new("radii", UniformType::_4f),
314 314 UniformName::new("fill_color", UniformType::_4f),
315 + UniformName::new("fill_color2", UniformType::_4f),
316 + UniformName::new("gradient_mix", UniformType::_1f),
315 317 UniformName::new("softness", UniformType::_1f),
318 + UniformName::new("rect_size", UniformType::_2f),
319 + UniformName::new("angle", UniformType::_1f),
316 320 ];
317 321
318 322 match renderer.compile_custom_pixel_shader(ROUNDED_CORNERS_FRAG, &uniforms) {
@@ -330,25 +334,125 @@ fn preview_program(renderer: &mut GlesRenderer) -> Option<GlesPixelProgram> {
330 334 }
331 335 }
332 336
333 -/// Buduje element zaokrąglonego prostokąta o zadanym kolorze i promieniu.
337 +/// Buduje element zaokrąglonego prostokąta o zadanym kolorze i promieniu
338 +/// (promień dotyczy wszystkich czterech rogów).
334 339 ///
335 -/// Wspólny dla „ducha” doklejania i dekoracji serwerowych (SSD): obie rzeczy to
336 -/// po prostu zaokrąglone, kolorowe kształty rysowane pixel shaderem.
340 +/// Wspólny dla „ducha” doklejania, panelu menu i glifów dekoracji.
337 341 pub fn rounded_rect_element(
338 342 renderer: &mut GlesRenderer,
339 343 area: Rectangle<i32, Logical>,
340 344 color: Color32F,
341 345 radius: f32,
342 346 ) -> Option<PixelShaderElement> {
343 - if area.size.w <= 0 || area.size.h <= 0 {
347 + shape_element(
348 + renderer,
349 + area,
350 + area.size,
351 + 0.0,
352 + (radius, radius, radius, radius),
353 + color,
354 + color,
355 + false,
356 + )
357 +}
358 +
359 +/// Buduje element prostokąta zaokrąglonego TYLKO u góry (dolne rogi ostre).
360 +///
361 +/// PO CO: belka dekoracji SSD musi mieć proste dolne rogi — stykają się one
362 +/// z treścią okna i zaokrąglenie zostawiłoby prześwit na pulpit.
363 +pub fn top_rounded_rect_element(
364 + renderer: &mut GlesRenderer,
365 + area: Rectangle<i32, Logical>,
366 + color: Color32F,
367 + radius: f32,
368 +) -> Option<PixelShaderElement> {
369 + shape_element(
370 + renderer,
371 + area,
372 + area.size,
373 + 0.0,
374 + (radius, radius, 0.0, 0.0),
375 + color,
376 + color,
377 + false,
378 + )
379 +}
380 +
381 +/// Buduje element paska z pionowym gradientem i zaokrąglonymi górnymi rogami.
382 +///
383 +/// PO CO: firmowy pasek tytułu PaganDE — gradient to element tożsamości wizualnej.
384 +pub fn gradient_bar_element(
385 + renderer: &mut GlesRenderer,
386 + area: Rectangle<i32, Logical>,
387 + top: Color32F,
388 + bottom: Color32F,
389 + radius: f32,
390 +) -> Option<PixelShaderElement> {
391 + shape_element(
392 + renderer,
393 + area,
394 + area.size,
395 + 0.0,
396 + (radius, radius, 0.0, 0.0),
397 + top,
398 + bottom,
399 + true,
400 + )
401 +}
402 +
403 +/// Buduje element obróconego prostokąta o zaokrąglonych rogach.
404 +///
405 +/// PO CO osobno: ikona „X" (zamknij) to dwa prostokąty obrócone o ±45°.
406 +/// `area` jest obszarem elementu (kwadrat obejmujący kształt), a `shape_size`
407 +/// — wymiarami samej kreski, która może być od niego mniejsza.
408 +pub fn rotated_rect_element(
409 + renderer: &mut GlesRenderer,
410 + area: Rectangle<i32, Logical>,
411 + shape_size: Size<i32, Logical>,
412 + angle: f32,
413 + color: Color32F,
414 + radius: f32,
415 +) -> Option<PixelShaderElement> {
416 + shape_element(
417 + renderer,
418 + area,
419 + shape_size,
420 + angle,
421 + (radius, radius, radius, radius),
422 + color,
423 + color,
424 + false,
425 + )
426 +}
427 +
428 +/// Wspólny konstruktor elementu kształtu — jedno miejsce na uniformy shadera.
429 +#[allow(clippy::too_many_arguments)]
430 +fn shape_element(
431 + renderer: &mut GlesRenderer,
432 + area: Rectangle<i32, Logical>,
433 + shape_size: Size<i32, Logical>,
434 + angle: f32,
435 + radii: (f32, f32, f32, f32),
436 + top: Color32F,
437 + bottom: Color32F,
438 + gradient: bool,
439 +) -> Option<PixelShaderElement> {
440 + if area.size.w <= 0 || area.size.h <= 0 || shape_size.w <= 0 || shape_size.h <= 0 {
344 441 return None;
345 442 }
346 443 let program = preview_program(renderer)?;
347 444 let uniforms = vec![
348 - Uniform::new("radius", radius),
349 - Uniform::new("fill_color", (color.r(), color.g(), color.b(), color.a())),
445 + Uniform::new("radii", (radii.0, radii.1, radii.2, radii.3)),
446 + Uniform::new("fill_color", (top.r(), top.g(), top.b(), top.a())),
447 + Uniform::new(
448 + "fill_color2",
449 + (bottom.r(), bottom.g(), bottom.b(), bottom.a()),
450 + ),
451 + Uniform::new("gradient_mix", if gradient { 1.0f32 } else { 0.0f32 }),
350 452 // ~1 px wygładzenia krawędzi (antyaliasing kształtu).
351 453 Uniform::new("softness", 1.0f32),
454 + Uniform::new("rect_size", (shape_size.w as f32, shape_size.h as f32)),
455 + Uniform::new("angle", angle),
352 456 ];
353 457
354 458 Some(PixelShaderElement::new(
@@ -32,23 +32,62 @@ varying vec2 v_coords;
32 32 uniform vec2 size;
33 33 uniform float alpha;
34 34
35 -uniform float radius;
36 35 uniform vec4 fill_color;
36 +// Drugi kolor gradientu i siła mieszania po osi Y (0 = brak gradientu).
37 +//
38 +// PO CO: firmowy pasek tytułu PaganDE ma delikatny gradient (jaśniejsza góra),
39 +// co odróżnia go od płaskich, generycznych dekoracji innych kompozytorów.
40 +uniform vec4 fill_color2;
41 +uniform float gradient_mix;
37 42 uniform float softness;
43 +// Promienie zaokrąglenia KAŻDEGO rogu osobno: (lewy-górny, prawy-górny,
44 +// prawy-dolny, lewy-dolny).
45 +//
46 +// PO CO per róg: pasek tytułu ma zaokrąglone TYLKO górne rogi (dolne muszą być
47 +// proste, bo stykają się z treścią okna — inaczej zostałaby szczelina).
48 +uniform vec4 radii;
49 +// Wymiary SAMEgo KSZTAŁTU (nie obszaru elementu) oraz jego obrót w radianach.
50 +//
51 +// PO CO osobno od `size`: obszar elementu (uniform `size` od Smithaya) bywa
52 +// większy niż kształt — tak rysujemy np. ukośne kreski ikony „X", gdzie
53 +// prostokąt 3×17 obracamy o 45° w kwadratowym obszarze 20×20.
54 +uniform vec2 rect_size;
55 +uniform float angle;
38 56
39 -// Odległość ze znakiem do zaokrąglonego prostokąta (ujemna = wnętrze).
57 +// Odległość ze znakiem do prostokąta o zadanym promieniu jednego rogu.
40 58 float sd_rounded_rect(vec2 p, vec2 half_size, float r) {
41 59 vec2 d = abs(p) - (half_size - vec2(r));
42 60 return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - r;
43 61 }
44 62
63 +// Wybiera promień właściwy dla ćwiartki, w której leży punkt (y rośnie w dół).
64 +float corner_radius(vec2 p) {
65 + if (p.x >= 0.0) {
66 + return p.y >= 0.0 ? radii.z : radii.y;
67 + }
68 + return p.y >= 0.0 ? radii.w : radii.x;
69 +}
70 +
45 71 void main() {
46 72 // `v_coords` jest w UV [0..1]; skalujemy do pikseli i centrujemy.
47 73 vec2 p = (v_coords - vec2(0.5)) * size;
48 - float dist = sd_rounded_rect(p, size * 0.5, radius);
74 +
75 + // Obrót punktu o `-angle` = obrót kształtu o `+angle`.
76 + float c = cos(angle);
77 + float s = sin(angle);
78 + vec2 pr = vec2(c * p.x + s * p.y, -s * p.x + c * p.y);
79 +
80 + // Promień wybieramy w układzie NIEobróconym (rogi prostokąta przed obrotem).
81 + float r = corner_radius(p);
82 + float dist = sd_rounded_rect(pr, rect_size * 0.5, r);
49 83
50 84 // smoothstep daje ~1px antyaliasing na krawędzi kształtu.
51 85 float mask = 1.0 - smoothstep(-softness, softness, dist);
52 86
53 - gl_FragColor = vec4(fill_color.rgb * mask, fill_color.a * mask * alpha);
87 + // Gradient pionowy: `t` = 0 na górze, 1 na dole (mieszane wg `gradient_mix`).
88 + float t = clamp(v_coords.y, 0.0, 1.0) * gradient_mix;
89 + vec3 rgb = mix(fill_color.rgb, fill_color2.rgb, t);
90 + float a = mix(fill_color.a, fill_color2.a, t);
91 +
92 + gl_FragColor = vec4(rgb * mask, a * mask * alpha);
54 93 }
@@ -430,7 +430,13 @@ impl MyCompositor {
430 430 /// Wariant publicznego API z Zadania 1 (`map_window`). Właściwa logika
431 431 /// (wybór monitora + kaskada) żyje w `crate::output`.
432 432 pub fn map_window(&mut self, window: PaganWindow) {
433 - self.place_new_window(window);
433 + // Kolejność: najpierw umieszczenie w przestrzeni (kaskada), potem fokus.
434 + self.place_new_window(window.clone());
435 + // Nowo otwarte okno OD RAZU przejmuje fokus klawiatury. Dzięki temu jest
436 + // od razu wizualnie aktywne (jaśniejszy pasek) i przyjmuje klawisze —
437 + // bez tego użytkownik musiałby najpierw kliknąć w nowe okno.
438 + let serial = SCOUNTER.next_serial();
439 + self.raise_and_focus(&window, serial);
434 440 }
435 441
436 442 /// Przesuwa okno o wektor (dx, dy) w pikselach logicznych.
@@ -982,10 +988,24 @@ impl SeatHandler for MyCompositor {
982 988 /// pierwotnej — bez tego kopiuj/wklej "nie wie", który klient jest aktywny.
983 989 fn focus_changed(&mut self, seat: &Seat<Self>, target: Option<&KeyboardFocusTarget>) {
984 990 let dh = &self.display_handle;
985 - let wl_surface = target.and_then(WaylandFocus::wl_surface);
986 - let client = wl_surface.and_then(|surface| dh.get_client(surface.id()).ok());
991 + let wl_surface = target
992 + .and_then(WaylandFocus::wl_surface)
993 + .map(|surface| surface.into_owned());
994 + let client = wl_surface
995 + .as_ref()
996 + .and_then(|surface| dh.get_client(surface.id()).ok());
987 997 set_data_device_focus(dh, seat, client.clone());
988 998 set_primary_focus(dh, seat, client);
999 +
1000 + // Wyróżnij wizualnie okno AKTYWNE (firmowy gradient + linia akcentu),
1001 + // a pozostałe przygaś. Bez tego wszystkie okna wyglądałyby identycznie.
1002 + for window in self.space.elements() {
1003 + let is_focused = matches!(
1004 + (window.wl_surface(), wl_surface.as_ref()),
1005 + (Some(surface), Some(target)) if surface.as_ref() == target
1006 + );
1007 + window.set_focused(is_focused);
1008 + }
989 1009 }
990 1010
991 1011 fn cursor_image(&mut self, _seat: &Seat<Self>, image: CursorImageStatus) {
992 1012 new file mode 100644
@@ -0,0 +1,199 @@
1 +//! Rasteryzacja tekstu do tekstury GL (Cairo → `MemoryRenderBuffer`).
2 +//!
3 +//! PO CO: kompozytor rysuje pasek tytułu dla WSZYSTKICH okien (wymuszony SSD).
4 +//! Żeby dekoracja była kompletna (tytuł okna oraz etykiety menu aplikacji:
5 +//! Plik/Edycja/Widok/Pomoc), kompozytor musi umieć narysować TEKST — inaczej
6 +//! pasek jest „ślepy”.
7 +//!
8 +//! DLACZEGO Cairo: to ta sama biblioteka, której używa nasz toolkit, i jest już
9 +//! spakowana w PaganOS. Nie dokładamy więc nowej zależności systemowej.
10 +//!
11 +//! DLACZEGO cache: rasteryzacja napisu jest kosztowna, a teksty (tytuły, etykiety
12 +//! menu) zmieniają się rzadko. Trzymamy gotowe tekstury kluczowane treścią,
13 +//! rozmiarem czcionki i kolorem — klatka tylko je składa.
14 +//!
15 +//! FORMAT: Cairo `ARgb32` jest premultiplied, a renderer Smithaya miesza kolory
16 +//! funkcją `ONE, ONE_MINUS_SRC_ALPHA` (czyli też premultiplied) — dlatego bajty
17 +//! Cairo można wysłać na GPU bez żadnej konwersji, jako `Fourcc::Argb8888`.
18 +
19 +use std::{
20 + collections::HashMap,
21 + sync::{Mutex, OnceLock},
22 +};
23 +
24 +use cairo::{Context, FontSlant, FontWeight, Format, ImageSurface};
25 +use smithay::{
26 + backend::{allocator::Fourcc, renderer::element::memory::MemoryRenderBuffer},
27 + utils::{Logical, Size, Transform},
28 +};
29 +
30 +/// Gotowa tekstura napisu wraz z jej rozmiarem w pikselach logicznych.
31 +#[derive(Clone)]
32 +pub struct TextTexture {
33 + /// Bufor pamięciowy gotowy do importu przez renderer.
34 + pub buffer: MemoryRenderBuffer,
35 + /// Rozmiar tekstury (szerokość × wysokość).
36 + pub size: Size<i32, Logical>,
37 +}
38 +
39 +/// Klucz cache — dokładnie to, co wpływa na wygląd tekstury.
40 +#[derive(PartialEq, Eq, Hash, Clone)]
41 +struct Key {
42 + text: String,
43 + font_px: i32,
44 + color: [u8; 3],
45 +}
46 +
47 +fn cache() -> &'static Mutex<HashMap<Key, Option<TextTexture>>> {
48 + static CACHE: OnceLock<Mutex<HashMap<Key, Option<TextTexture>>>> = OnceLock::new();
49 + CACHE.get_or_init(|| Mutex::new(HashMap::new()))
50 +}
51 +
52 +/// Cache samych POMIARÓW (szerokość napisu) — używany przy układaniu menu,
53 +/// gdzie potrzebujemy rozmiaru, a nie tekstury.
54 +fn measures() -> &'static Mutex<HashMap<(String, i32), Option<Size<i32, Logical>>>> {
55 + static MEASURES: OnceLock<Mutex<HashMap<(String, i32), Option<Size<i32, Logical>>>>> =
56 + OnceLock::new();
57 + MEASURES.get_or_init(|| Mutex::new(HashMap::new()))
58 +}
59 +
60 +/// Zwraca (z cache) rozmiar napisu w pikselach, bez tworzenia tekstury.
61 +///
62 +/// PO CO osobno: układ menu (pozycje etykiet) liczymy także przy obsłudze myszy,
63 +/// gdzie renderera nie ma pod ręką — wystarczy nam wtedy sam pomiar.
64 +pub fn measure(text: &str, font_px: f64) -> Option<Size<i32, Logical>> {
65 + if text.trim().is_empty() {
66 + return None;
67 + }
68 + let key = (text.to_string(), font_px.round().max(1.0) as i32);
69 + if let Some(cached) = measures()
70 + .lock()
71 + .expect("cache pomiarów zatruty")
72 + .get(&key)
73 + .cloned()
74 + {
75 + return cached;
76 + }
77 +
78 + let result = measure_uncached(text, font_px);
79 + measures()
80 + .lock()
81 + .expect("cache pomiarów zatruty")
82 + .insert(key, result);
83 + result
84 +}
85 +
86 +fn measure_uncached(text: &str, font_px: f64) -> Option<Size<i32, Logical>> {
87 + let dummy = ImageSurface::create(Format::ARgb32, 1, 1).ok()?;
88 + let ctx = Context::new(&dummy).ok()?;
89 + ctx.select_font_face("sans-serif", FontSlant::Normal, FontWeight::Normal);
90 + ctx.set_font_size(font_px);
91 + let ink = ctx.text_extents(text).ok()?;
92 + let font = ctx.font_extents().ok()?;
93 + Some(Size::from((
94 + ink.width().ceil() as i32,
95 + (font.ascent() + font.descent()).ceil() as i32,
96 + )))
97 +}
98 +
99 +/// Zwraca (z cache) teksturę napisu. `None`, gdy rasteryzacja się nie powiodła —
100 +/// wtedy wołający po prostu nie rysuje tekstu (dekoracja działa dalej).
101 +pub fn texture(text: &str, font_px: f64, color: (f64, f64, f64)) -> Option<TextTexture> {
102 + if text.trim().is_empty() {
103 + return None;
104 + }
105 +
106 + let key = Key {
107 + text: text.to_string(),
108 + font_px: font_px.round().max(1.0) as i32,
109 + color: [
110 + (color.0.clamp(0.0, 1.0) * 255.0).round() as u8,
111 + (color.1.clamp(0.0, 1.0) * 255.0).round() as u8,
112 + (color.2.clamp(0.0, 1.0) * 255.0).round() as u8,
113 + ],
114 + };
115 +
116 + if let Some(cached) = cache()
117 + .lock()
118 + .expect("cache tekstu zatruty")
119 + .get(&key)
120 + .cloned()
121 + {
122 + return cached;
123 + }
124 +
125 + let raster = rasterize(text, key.font_px as f64, color);
126 + if raster.is_none() {
127 + tracing::warn!(text, "nie udało się zrasteryzować tekstu");
128 + }
129 + cache()
130 + .lock()
131 + .expect("cache tekstu zatruty")
132 + .insert(key, raster.clone());
133 + raster
134 +}
135 +
136 +/// Właściwa rasteryzacja: mierzy napis, tworzy powierzchnię i rysuje tekst.
137 +fn rasterize(text: &str, font_px: f64, color: (f64, f64, f64)) -> Option<TextTexture> {
138 + /// Zapas wokół napisu, żeby antyaliasing nie został obcięty na krawędzi.
139 + const PADDING: i32 = 2;
140 +
141 + // 1) Pomiar na powierzchni pomocniczej (Cairo nie mierzy tekstu bez kontekstu).
142 + let dummy = ImageSurface::create(Format::ARgb32, 1, 1).ok()?;
143 + let measure = Context::new(&dummy).ok()?;
144 + measure.select_font_face("sans-serif", FontSlant::Normal, FontWeight::Normal);
145 + measure.set_font_size(font_px);
146 + let ink = measure.text_extents(text).ok()?;
147 + let font = measure.font_extents().ok()?;
148 +
149 + let width = ink.width().ceil() as i32 + PADDING * 2 + 1;
150 + let height = (font.ascent() + font.descent()).ceil() as i32 + PADDING * 2;
151 + if width <= 0 || height <= 0 {
152 + return None;
153 + }
154 +
155 + // 2) Właściwa powierzchnia + rysunek.
156 + let surface = ImageSurface::create(Format::ARgb32, width, height).ok()?;
157 + {
158 + let ctx = Context::new(&surface).ok()?;
159 + ctx.set_source_rgb(color.0, color.1, color.2);
160 + ctx.select_font_face("sans-serif", FontSlant::Normal, FontWeight::Normal);
161 + ctx.set_font_size(font_px);
162 + ctx.move_to(PADDING as f64, PADDING as f64 + font.ascent());
163 + ctx.show_text(text).ok()?;
164 + }
165 + surface.flush();
166 +
167 + // 3) Kopiujemy bajty, zwijając wiersze do `width * 4`, gdyby Cairo dodał
168 + // wyrównanie (dla ARGB32 zwykle go nie ma, ale nie zakładamy tego).
169 + let stride = surface.stride();
170 + let row_bytes = width * 4;
171 + let mut data = Vec::with_capacity((row_bytes * height) as usize);
172 + surface
173 + .with_data(|raw| {
174 + if stride == row_bytes {
175 + data.extend_from_slice(raw);
176 + } else {
177 + for row in raw.chunks_exact(stride as usize).take(height as usize) {
178 + data.extend_from_slice(&row[..row_bytes as usize]);
179 + }
180 + }
181 + })
182 + .ok()?;
183 + if data.len() < (row_bytes * height) as usize {
184 + return None;
185 + }
186 +
187 + let buffer = MemoryRenderBuffer::from_slice(
188 + &data,
189 + Fourcc::Argb8888,
190 + (width, height),
191 + 1,
192 + Transform::Normal,
193 + None,
194 + );
195 + Some(TextTexture {
196 + buffer,
197 + size: Size::from((width, height)),
198 + })
199 +}
@@ -58,6 +58,29 @@ pub struct Theme {
58 58 pub snap_preview: Color32F,
59 59 /// Promień zaokrąglenia „ducha” (px).
60 60 pub snap_preview_radius: f32,
61 +
62 + // --- Dekoracje serwerowe (SSD) — firmowy wygląd PaganDE -----------------
63 + //
64 + // Styl: PŁASKI (bez gradientów). Tożsamość PaganDE budują: menu aplikacji
65 + // w tym samym rzędzie co przyciski okna, typografia oraz chłodna paleta.
66 + /// Płaski kolor paska tytułu AKTYWNEGO okna.
67 + pub ssd_bar: Color32F,
68 + /// Płaski kolor paska okna NIEAKTYWNEGO (przygaszony).
69 + pub ssd_unfocused: Color32F,
70 + /// Cienka linia oddzielająca pasek od treści okna (1 px, płaska).
71 + pub ssd_border: Color32F,
72 + /// Kolor tekstu (tytuł okna i etykiety menu) na pasku aktywnym.
73 + pub ssd_text: Color32F,
74 + /// Kolor tekstu na pasku okna nieaktywnego.
75 + pub ssd_text_unfocused: Color32F,
76 + /// Kolor glifu „zamknij” (delikatnie czerwony akcent).
77 + pub ssd_icon_close: Color32F,
78 + /// Promień zaokrąglenia górnych rogów ramki SSD (px).
79 + pub ssd_radius: f32,
80 + /// Rozmiar czcionki tytułu i etykiet menu (px).
81 + pub ssd_font_px: f32,
82 + /// Poniżej tej szerokości okna etykiety menu zwijają się do burgera.
83 + pub ssd_menu_min_width: i32,
61 84 }
62 85
63 86 impl Default for Theme {
@@ -75,6 +98,15 @@ impl Default for Theme {
75 98 cursor_border: hex(0x14, 0x14, 0x1a),
76 99 snap_preview: rgba(0x4e, 0xa1, 0xff, 0x66),
77 100 snap_preview_radius: 12.0,
101 + ssd_bar: hex(0x1c, 0x20, 0x2b),
102 + ssd_unfocused: hex(0x14, 0x16, 0x1c),
103 + ssd_border: hex(0x0d, 0x0e, 0x13),
104 + ssd_text: hex(0xd6, 0xda, 0xe4),
105 + ssd_text_unfocused: hex(0x86, 0x8b, 0x96),
106 + ssd_icon_close: hex(0xe0, 0x6c, 0x6c),
107 + ssd_radius: 12.0,
108 + ssd_font_px: 13.0,
109 + ssd_menu_min_width: 560,
78 110 }
79 111 }
80 112 }
@@ -138,11 +170,24 @@ impl Theme {
138 170 "cursor_border" => self.cursor_border = parse_color(value).ok_or_else(bad_color)?,
139 171 "snap_preview" => self.snap_preview = parse_color(value).ok_or_else(bad_color)?,
140 172 "snap_preview_radius" => self.snap_preview_radius = parse_float(value)?.max(0.0),
173 + "ssd_bar" => self.ssd_bar = parse_color(value).ok_or_else(bad_color)?,
174 + "ssd_unfocused" => self.ssd_unfocused = parse_color(value).ok_or_else(bad_color)?,
175 + "ssd_border" => self.ssd_border = parse_color(value).ok_or_else(bad_color)?,
176 + "ssd_text" => self.ssd_text = parse_color(value).ok_or_else(bad_color)?,
177 + "ssd_text_unfocused" => {
178 + self.ssd_text_unfocused = parse_color(value).ok_or_else(bad_color)?
179 + }
180 + "ssd_icon_close" => self.ssd_icon_close = parse_color(value).ok_or_else(bad_color)?,
181 + "ssd_radius" => self.ssd_radius = parse_float(value)?.max(0.0),
182 + "ssd_font_px" => self.ssd_font_px = parse_float(value)?.clamp(6.0, 48.0),
183 + "ssd_menu_min_width" => self.ssd_menu_min_width = parse_float(value)?.max(0.0) as i32,
141 184 other => {
142 185 return Err(format!(
143 186 "nieznany klucz '{other}' (dostępne: background, shadow_color, \
144 187 shadow_opacity, shadow_blur, shadow_offset_y, shadow_radius, \
145 - cursor_fill, cursor_border, snap_preview, snap_preview_radius)"
188 + cursor_fill, cursor_border, snap_preview, snap_preview_radius, \
189 + ssd_bar, ssd_unfocused, ssd_border, ssd_text, ssd_text_unfocused, \
190 + ssd_icon_close, ssd_radius, ssd_font_px, ssd_menu_min_width)"
146 191 ));
147 192 }
148 193 }
@@ -221,6 +266,22 @@ cursor_border = #14141a
221 266 # Ostatnia para cyfr to przezroczystość (tu ~40%).
222 267 snap_preview = #4ea1ff66
223 268 snap_preview_radius = 12
269 +
270 +# --- Dekoracje serwerowe (SSD) — firmowy wygląd PaganDE ---
271 +# Pasek tytułu rysuje kompozytor dla KAŻDEGO okna (wymuszony tryb serwerowy).
272 +# Styl jest PŁASKI (bez gradientów). Na pasku, w JEDNYM rzędzie, leżą:
273 +# * z lewej: menu aplikacji (Plik/Edycja/Widok/Pomoc) albo burger przy wąskim oknie,
274 +# * z prawej: przyciski minimalizuj / maksymalizuj / zamknij.
275 +ssd_bar = #1c202b
276 +ssd_unfocused = #14161c
277 +ssd_border = #0d0e13
278 +ssd_text = #d6dae4
279 +ssd_text_unfocused = #868b96
280 +ssd_icon_close = #e06c6c
281 +ssd_radius = 12
282 +ssd_font_px = 13
283 +# Poniżej tej szerokości okna etykiety menu zwijają się do pojedynczego burgera.
284 +ssd_menu_min_width = 560
224 285 ";
225 286
226 287 /// Globalny, współdzielony motyw (ładowany leniwie).
@@ -20,12 +20,18 @@
20 20
21 21 use std::{
22 22 collections::HashMap,
23 - sync::{Mutex, OnceLock},
23 + sync::{
24 + atomic::{AtomicBool, Ordering},
25 + Mutex, OnceLock,
26 + },
24 27 };
25 28
26 29 use smithay::{
27 30 backend::renderer::{
28 - element::{surface::WaylandSurfaceRenderElement, AsRenderElements, Kind},
31 + element::{
32 + memory::MemoryRenderBufferRenderElement, surface::WaylandSurfaceRenderElement,
33 + AsRenderElements, Kind,
34 + },
29 35 gles::{
30 36 element::PixelShaderElement, GlesPixelProgram, GlesRenderer, Uniform, UniformName,
31 37 UniformType,
@@ -35,45 +41,103 @@ use smithay::{
35 41 desktop::{space::SpaceElement, Window, WindowSurface},
36 42 output::Output,
37 43 reexports::{
38 - wayland_protocols::xdg::{
39 - decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode,
40 - shell::server::xdg_toplevel,
41 - },
44 + wayland_protocols::xdg::shell::server::xdg_toplevel,
42 45 wayland_server::protocol::wl_surface::WlSurface,
43 46 },
44 47 render_elements,
45 48 utils::{user_data::UserDataMap, IsAlive, Logical, Physical, Point, Rectangle, Scale, Size},
46 49 wayland::{
47 - compositor::SurfaceData as WlSurfaceData, seat::WaylandFocus, shell::xdg::ToplevelSurface,
50 + compositor::{with_states, SurfaceData as WlSurfaceData},
51 + seat::WaylandFocus,
52 + shell::xdg::{ToplevelSurface, XdgToplevelSurfaceData},
48 53 },
49 54 };
50 55
51 -use crate::{render, shaders::SHADOW_FRAG, theme};
56 +use crate::{render, shaders::SHADOW_FRAG, text, theme};
52 57
53 58 // --- Dekoracje serwerowe (SSD) ------------------------------------------------
59 +//
60 +// Styl: ciemny, płaski, monochromatyczne glify (bez kolorowych \"świateł\").
61 +// Inspiracja nowoczesnymi pulpitami (GNOME), ale z własnym, chłodnym akcentem
62 +// PaganDE — dlatego ikona zamykania jest delikatnie czerwona, a nie szara.
54 63 /// Wysokość paska tytułu rysowanego przez kompozytor.
55 64 pub const TITLEBAR_HEIGHT: i32 = 40;
56 -/// Promień okrągłych przycisków okna.
57 -const BUTTON_RADIUS: i32 = 6;
58 -/// Odległość środka skrajnego (zamykającego) przycisku od prawej krawędzi.
59 -const BUTTON_MARGIN: i32 = 22;
60 -/// Odstęp między środkami przycisków.
61 -const BUTTON_SPACING: i32 = 26;
62 -/// Kolor belki paska (ciemny, spójny z panelem/motywem).
63 -const TITLEBAR_COLOR: Color32F = Color32F::new(0.090, 0.098, 0.125, 1.0);
64 -/// Kolory przycisków — jak w toolkicie (minimalizuj/maksymalizuj/zamknij).
65 -const BUTTON_MINIMIZE: Color32F = Color32F::new(0.90, 0.72, 0.28, 1.0);
66 -const BUTTON_MAXIMIZE: Color32F = Color32F::new(0.32, 0.72, 0.40, 1.0);
67 -const BUTTON_CLOSE: Color32F = Color32F::new(0.82, 0.30, 0.30, 1.0);
65 +/// Promień pola trafienia przycisku (używany w hit-testingu).
66 +const BUTTON_RADIUS: i32 = 10;
67 +/// Odległość środka skrajnego przycisku (Menu/Zamknij) od krawędzi paska.
68 +const BUTTON_MARGIN: i32 = 24;
69 +/// Odstęp między środkami przycisków okna po prawej.
70 +const BUTTON_SPACING: i32 = 30;
71 +/// Grubość cienkiej linii oddzielającej pasek od treści (px).
72 +const BORDER_HEIGHT: i32 = 1;
73 +/// Lewy margines paska (odstęp pierwszej etykiety/burgera od krawędzi).
74 +const BAR_PADDING_X: i32 = 12;
75 +/// Wewnętrzny odstęp poziomy klikalnej etykiety menu (z każdej strony).
76 +const MENU_LABEL_PAD_X: i32 = 10;
77 +/// Odstęp górny/dolny pola etykiety menu od krawędzi paska.
78 +const MENU_LABEL_PAD_Y: i32 = 6;
79 +/// Połowa długości kreski glifu.
80 +const GLYPH_HALF: i32 = 7;
81 +/// Grubość kreski glifu.
82 +const GLYPH_THICKNESS: i32 = 3;
83 +/// Odstęp środków sąsiednich kresek burgera.
84 +const BURGER_GAP: i32 = 4;
85 +/// Bok kwadratowego obszaru glifu (mieści kreski obrócone o 45°).
86 +const GLYPH_BOX: i32 = 18;
87 +/// Kąt 45° w radianach (ramiona ikony „X").
88 +const ANGLE_45: f32 = std::f32::consts::FRAC_PI_4;
89 +
90 +/// Tło panelu menu wyprowadzamy z koloru paska (patrz `render_elements`).
91 +/// Szerokość panelu menu okna.
92 +const MENU_PANEL_WIDTH: i32 = 176;
93 +/// Wysokość jednego wiersza menu okna.
94 +const MENU_ROW_HEIGHT: i32 = 36;
95 +/// Wewnętrzny odstęp panelu menu (góra/dół).
96 +const MENU_PANEL_PADDING: i32 = 6;
97 +/// Liczba wierszy menu okna (minimalizuj / maksymalizuj / zamknij).
98 +const MENU_ROWS: i32 = 3;
99 +/// Poziomy środek ikony w wierszu menu (liczony od lewej krawędzi panelu).
100 +const MENU_ROW_ICON_X: i32 = 24;
101 +
102 +/// Pojedynczy prostokątny kształt glifu (kreska/ramiona ikony).
103 +///
104 +/// PO CO własny typ: ten sam glif rysujemy w pasku tytułu i w wierszach menu.
105 +/// Rozdzielenie opisu kształtu od wywołania renderera pozwala użyć JEDNEJ
106 +/// definicji ikon w obu miejscach (spójność wizualna bez duplikacji kodu).
107 +struct Glyph {
108 + /// Obszar elementu (kwadrat obejmujący kształt).
109 + area: Rectangle<i32, Logical>,
110 + /// Wymiary samej kreski.
111 + shape: Size<i32, Logical>,
112 + /// Obrót w radianach.
113 + angle: f32,
114 + /// Kolor.
115 + color: Color32F,
116 + /// Promień zaokrąglenia kreski.
117 + radius: f32,
118 +}
68 119
69 120 /// Który przycisk paska tytułu trafiono.
70 121 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
71 122 pub enum TitlebarButton {
123 + /// „Burger" po lewej — rozwija menu okna.
124 + Menu,
72 125 Minimize,
73 126 Maximize,
74 127 Close,
75 128 }
76 129
130 +/// Stan dekoracji okna trzymany w danych użytkownika okna.
131 +///
132 +/// PO CO w danych okna, a nie w `MyCompositor`: dekorację rysujemy razem z oknem
133 +/// w `AsRenderElements`, a ta metoda nie widzi stanu kompozytora. `AtomicBool`
134 +/// wystarcza — potrzebujemy tylko flag „menu otwarte" i „okno aktywne".
135 +#[derive(Debug, Default)]
136 +struct SsdState {
137 + open: AtomicBool,
138 + focused: AtomicBool,
139 +}
140 +
77 141 // Parametry cienia (margines, rozmycie, przesunięcie, siła, promień rogów)
78 142 // pochodzą z motywu (`theme::Theme`) — patrz `~/.config/pagan/theme.conf`.
79 143 // Zmiana motywu jest widoczna w następnej klatce, bo parametry są uniformami.
@@ -166,25 +230,118 @@ impl PaganWindow {
166 230
167 231 /// Czy to okno ma dostać nasz pasek tytułu (dekoracja serwerowa)?
168 232 ///
169 - /// PO CO to rozróżnienie: nasz toolkit nie tworzy obiektu dekoracji, więc
170 - /// zachowuje własne CSD — gdybyśmy dołożyli mu SSD, powstałyby dwa paski.
171 - /// Dlatego SSD dajemy tylko tam, gdzie klient sam się nie dekoruje:
172 - /// X11 (zawsze) oraz Wayland, który wynegocjował tryb serwerowy.
233 + /// PO CO wymuszamy to dla WSZYSTKICH okien najwyższego poziomu: chcemy
234 + /// spójnego wyglądu całego środowiska. Aplikacje obce (X11, GTK) nie znają
235 + /// naszego stylu, więc gdyby rysowały własny pasek, każda wyglądałaby inaczej.
236 + /// Negocjacja `xdg-decoration` i tak wymusza tryb serwerowy (patrz `state.rs`),
237 + /// więc klient nie dorysuje drugiego paska.
173 238 pub fn wants_ssd(&self) -> bool {
174 - if self.x11_surface().is_some() {
175 - return true;
239 + self.x11_surface().is_some() || self.toplevel().is_some()
240 + }
241 +
242 + /// Dane użytkownika okna z flagami dekoracji (tworzone leniwie).
243 + fn ssd_state(&self) -> &SsdState {
244 + self.0
245 + .user_data()
246 + .get_or_insert_threadsafe(SsdState::default)
247 + }
248 +
249 + /// Czy rozwinięte jest menu okna (dropdown z burgera).
250 + pub fn ssd_menu_open(&self) -> bool {
251 + self.ssd_state().open.load(Ordering::Relaxed)
252 + }
253 +
254 + /// Ustawia stan menu okna (otwarte/zamknięte).
255 + pub fn set_ssd_menu_open(&self, open: bool) {
256 + self.ssd_state().open.store(open, Ordering::Relaxed);
257 + }
258 +
259 + /// Czy okno jest aktywne (ma fokus klawiatury).
260 + ///
261 + /// PO CO: firmowy wygląd PaganDE wyróżnia okno aktywne (jaśniejszy, płaski
262 + /// pasek), a pozostałe przygasza. Bez tego stanu wszystkie okna wyglądałyby
263 + /// identycznie.
264 + pub fn is_focused(&self) -> bool {
265 + self.ssd_state().focused.load(Ordering::Relaxed)
266 + }
267 +
268 + /// Ustawia stan aktywności okna (wołane z `SeatHandler::focus_changed`).
269 + pub fn set_focused(&self, focused: bool) {
270 + self.ssd_state().focused.store(focused, Ordering::Relaxed);
271 + }
272 +
273 + /// Identyfikator aplikacji (Wayland: `app_id`; X11: klasa okna).
274 + ///
275 + /// PO CO przez `with_states`: `app_id` i `title` żyją w danych roli powierzchni
276 + /// xdg (`XdgToplevelSurfaceData`), a nie w stanie konfiguracji toplevela.
277 + fn app_id(&self) -> Option<String> {
278 + if let Some(toplevel) = self.toplevel() {
279 + let surface = toplevel.wl_surface().clone();
280 + return with_states(&surface, |states| {
281 + states
282 + .data_map
283 + .get::<XdgToplevelSurfaceData>()
284 + .and_then(|data| data.lock().ok())
285 + .and_then(|data| data.app_id.clone())
286 + });
176 287 }
177 - self.toplevel()
178 - .map(|toplevel| {
179 - let mut server_side = false;
180 - toplevel.with_pending_state(|state| {
181 - server_side = state.decoration_mode == Some(DecorationMode::ServerSide);
182 - });
183 - server_side
288 + self.x11_surface().map(|x11| x11.class())
289 + }
290 +
291 + /// Tytuł okna (Wayland: `xdg_toplevel`; X11: `_NET_WM_NAME`).
292 + pub fn window_title(&self) -> Option<String> {
293 + if let Some(toplevel) = self.toplevel() {
294 + let surface = toplevel.wl_surface().clone();
295 + return with_states(&surface, |states| {
296 + states
297 + .data_map
298 + .get::<XdgToplevelSurfaceData>()
299 + .and_then(|data| data.lock().ok())
300 + .and_then(|data| data.title.clone())
184 301 })
302 + .filter(|value| !value.is_empty());
303 + }
304 + self.x11_surface()
305 + .map(|x11| x11.title())
306 + .filter(|value| !value.is_empty())
307 + }
308 +
309 + /// Czy okno należy do aplikacji PaganDE (czyli ma nasze menu aplikacji)?
310 + ///
311 + /// PO CO: etykiety menu aplikacji pokazujemy tylko tym oknom, które faktycznie
312 + /// je mają. Aplikacje obce dostają burger (rozwijający menu okna). Docelowo
313 + /// zamiast konwencji `app_id` będzie tu mały własny protokół menu.
314 + pub fn has_pagan_menu(&self) -> bool {
315 + self.app_id()
316 + .map(|id| id.starts_with("org.pagan."))
185 317 .unwrap_or(false)
186 318 }
187 319
320 + /// Czy pasek ma pokazać pełne etykiety menu (zamiast burgera)?
321 + ///
322 + /// Warunki: okno ma menu aplikacji ORAZ jest wystarczająco szerokie.
323 + /// Wąskie okno zwija menu do burgera, żeby nie zabrakło miejsca na tytuł.
324 + pub fn ssd_bar_uses_labels(&self) -> bool {
325 + self.has_pagan_menu() && self.geometry().size.w >= theme::current().ssd_menu_min_width
326 + }
327 +
328 + /// Indeks etykiety menu pod punktem (jeśli któraś trafiona).
329 + pub fn ssd_menu_label_at(
330 + &self,
331 + location: Point<i32, Logical>,
332 + point: Point<f64, Logical>,
333 + ) -> Option<usize> {
334 + if !self.wants_ssd() || !self.ssd_bar_uses_labels() {
335 + return None;
336 + }
337 + let bar = self.titlebar_rect(location);
338 + let labels = app_menu_labels();
339 + let widths = menu_label_widths(labels, theme::current().ssd_font_px as f64);
340 + menu_label_rects(bar, &widths)
341 + .iter()
342 + .position(|rect| rect.to_f64().contains(point))
343 + }
344 +
188 345 /// Czy okno jest zmaksymalizowane (Wayland: stan xdg, X11: `_NET_WM_STATE`).
189 346 pub fn is_maximized(&self) -> bool {
190 347 if let Some(toplevel) = self.toplevel() {
@@ -202,36 +359,33 @@ impl PaganWindow {
202 359
203 360 /// Prostokąt paska tytułu dla okna postawionego w `location` (układ logiczny).
204 361 pub fn titlebar_rect(&self, location: Point<i32, Logical>) -> Rectangle<i32, Logical> {
205 - Rectangle::new(
206 - Point::from((location.x, location.y - TITLEBAR_HEIGHT)),
207 - Size::from((self.geometry().size.w, TITLEBAR_HEIGHT)),
208 - )
362 + bar_rect(location, self.geometry().size.w)
209 363 }
210 364
211 - /// Środki i kolory trzech przycisków paska (od prawej: zamknij, maks., min.).
212 - fn titlebar_buttons(
365 + /// Prostokąt panelu rozwiniętego menu okna (pod burgerem).
366 + pub fn ssd_menu_rect(&self, location: Point<i32, Logical>) -> Rectangle<i32, Logical> {
367 + menu_panel_rect(self.titlebar_rect(location))
368 + }
369 +
370 + /// Czy punkt leży wewnątrz panelu menu (niezależnie od wiersza).
371 + pub fn ssd_menu_contains(
213 372 &self,
214 - bar: Rectangle<i32, Logical>,
215 - ) -> [(TitlebarButton, Point<i32, Logical>, Color32F); 3] {
216 - let center_y = bar.loc.y + TITLEBAR_HEIGHT / 2;
217 - let right = bar.loc.x + bar.size.w;
218 - [
219 - (
220 - TitlebarButton::Close,
221 - Point::from((right - BUTTON_MARGIN, center_y)),
222 - BUTTON_CLOSE,
223 - ),
224 - (
225 - TitlebarButton::Maximize,
226 - Point::from((right - BUTTON_MARGIN - BUTTON_SPACING, center_y)),
227 - BUTTON_MAXIMIZE,
228 - ),
229 - (
230 - TitlebarButton::Minimize,
231 - Point::from((right - BUTTON_MARGIN - BUTTON_SPACING * 2, center_y)),
232 - BUTTON_MINIMIZE,
233 - ),
234 - ]
373 + location: Point<i32, Logical>,
374 + point: Point<f64, Logical>,
375 + ) -> bool {
376 + self.ssd_menu_open() && self.ssd_menu_rect(location).to_f64().contains(point)
377 + }
378 +
379 + /// Który wiersz menu trafiono (minimalizuj / maksymalizuj / zamknij).
380 + pub fn ssd_menu_row_at(
381 + &self,
382 + location: Point<i32, Logical>,
383 + point: Point<f64, Logical>,
384 + ) -> Option<TitlebarButton> {
385 + if !self.ssd_menu_open() {
386 + return None;
387 + }
388 + menu_row_at(self.ssd_menu_rect(location), point)
235 389 }
236 390
237 391 /// Czy punkt (w układzie przestrzeni) leży na pasku tytułu tego okna.
@@ -253,14 +407,118 @@ impl PaganWindow {
253 407 return None;
254 408 }
255 409 let bar = self.titlebar_rect(location);
256 - for (button, center, _) in self.titlebar_buttons(bar) {
257 - let dx = point.x - center.x as f64;
258 - let dy = point.y - center.y as f64;
259 - if dx * dx + dy * dy <= ((BUTTON_RADIUS + 3) as f64).powi(2) {
260 - return Some(button);
261 - }
410 + // W trybie etykiet menu NIE ma burgera — trafiają tylko przyciski okna.
411 + // (Etykiety obsługuje osobno `ssd_menu_label_at`.)
412 + if self.ssd_bar_uses_labels() {
413 + window_button_at(bar, point)
414 + } else {
415 + bar_button_at(bar, point)
416 + }
417 + }
418 +}
419 +
420 +// --- Czysta geometria paska SSD (bez zależności od Waylanda) -------------------
421 +//
422 +// PO CO osobne funkcje: logika trafień to najłatwiejsze do zepsucia miejsce
423 +// w SSD, a metody wyżej wymagają żywego `ToplevelSurface`. Wydzielenie geometrii
424 +// pozwala przetestować ją jednostkowo, bez kompozytora i bez XTestu.
425 +
426 +/// Prostokąt paska tytułu dla treści o pozycji `content_loc` i zadanej szerokości.
427 +fn bar_rect(content_loc: Point<i32, Logical>, content_width: i32) -> Rectangle<i32, Logical> {
428 + Rectangle::new(
429 + Point::from((content_loc.x, content_loc.y - TITLEBAR_HEIGHT)),
430 + Size::from((content_width, TITLEBAR_HEIGHT)),
431 + )
432 +}
433 +
434 +/// Środek przycisku „Menu" (burger). Ta sama oś Y co przyciski okna —
435 +/// to wymóg wizualny: menu i przyciski muszą leżeć w jednej linii.
436 +fn menu_center(bar: Rectangle<i32, Logical>) -> Point<i32, Logical> {
437 + Point::from((bar.loc.x + BUTTON_MARGIN, bar.loc.y + TITLEBAR_HEIGHT / 2))
438 +}
439 +
440 +/// Środki trzech przycisków paska (od prawej: zamknij, maks., min.).
441 +fn window_buttons(bar: Rectangle<i32, Logical>) -> [(TitlebarButton, Point<i32, Logical>); 3] {
442 + let center_y = bar.loc.y + TITLEBAR_HEIGHT / 2;
443 + let right = bar.loc.x + bar.size.w;
444 + [
445 + (
446 + TitlebarButton::Close,
447 + Point::from((right - BUTTON_MARGIN, center_y)),
448 + ),
449 + (
450 + TitlebarButton::Maximize,
451 + Point::from((right - BUTTON_MARGIN - BUTTON_SPACING, center_y)),
452 + ),
453 + (
454 + TitlebarButton::Minimize,
455 + Point::from((right - BUTTON_MARGIN - BUTTON_SPACING * 2, center_y)),
456 + ),
457 + ]
458 +}
459 +
460 +/// Panel rozwijanego menu okna (tuż pod belką paska).
461 +fn menu_panel_rect(bar: Rectangle<i32, Logical>) -> Rectangle<i32, Logical> {
462 + Rectangle::new(
463 + Point::from((bar.loc.x + MENU_PANEL_PADDING, bar.loc.y + TITLEBAR_HEIGHT)),
464 + Size::from((
465 + MENU_PANEL_WIDTH,
466 + MENU_PANEL_PADDING * 2 + MENU_ROW_HEIGHT * MENU_ROWS,
467 + )),
468 + )
469 +}
470 +
471 +/// Przycisk paska pod punktem (menu po lewej albo przycisk okna po prawej).
472 +///
473 +/// Promień trafienia jest o 3 px większy niż promień rysowania — mały „luz"
474 +/// znacząco poprawia ergonomię klikania niewielkich glifów.
475 +fn bar_button_at(
476 + bar: Rectangle<i32, Logical>,
477 + point: Point<f64, Logical>,
478 +) -> Option<TitlebarButton> {
479 + if within_radius(menu_center(bar), point) {
480 + return Some(TitlebarButton::Menu);
481 + }
482 + window_button_at(bar, point)
483 +}
484 +
485 +/// Przycisk OKNA pod punktem (bez burgera) — używane w trybie etykiet menu.
486 +fn window_button_at(
487 + bar: Rectangle<i32, Logical>,
488 + point: Point<f64, Logical>,
489 +) -> Option<TitlebarButton> {
490 + for (button, center) in window_buttons(bar) {
491 + if within_radius(center, point) {
492 + return Some(button);
262 493 }
263 - None
494 + }
495 + None
496 +}
497 +
498 +/// Czy punkt trafia w przycisk o zadanym środku (promień z „luzem").
499 +fn within_radius(center: Point<i32, Logical>, point: Point<f64, Logical>) -> bool {
500 + let hit_radius_sq = ((BUTTON_RADIUS + 3) as f64).powi(2);
501 + let dx = point.x - center.x as f64;
502 + let dy = point.y - center.y as f64;
503 + dx * dx + dy * dy <= hit_radius_sq
504 +}
505 +
506 +/// Wiersz menu okna pod punktem (prostokąty wierszy są rozłączne i pokrywają
507 +/// cały panel, więc wystarczy podzielić wysokość panelu przez wysokość wiersza).
508 +fn menu_row_at(
509 + panel: Rectangle<i32, Logical>,
510 + point: Point<f64, Logical>,
511 +) -> Option<TitlebarButton> {
512 + let panel = panel.to_f64();
513 + if !panel.contains(point) {
514 + return None;
515 + }
516 + let rel = point.y - panel.loc.y - MENU_PANEL_PADDING as f64;
517 + match (rel / MENU_ROW_HEIGHT as f64).floor() as i32 {
518 + 0 => Some(TitlebarButton::Minimize),
519 + 1 => Some(TitlebarButton::Maximize),
520 + 2 => Some(TitlebarButton::Close),
521 + _ => None,
264 522 }
265 523 }
266 524
@@ -321,7 +579,9 @@ impl SpaceElement for PaganWindow {
321 579 render_elements! {
322 580 pub WindowRenderElement<=GlesRenderer>;
323 581 Window=WaylandSurfaceRenderElement<GlesRenderer>,
324 - // Element rysowany pixel shaderem: cień okna ORAZ pasek tytułu/przyciski SSD.
582 + // Tekst (tytuł, etykiety menu) rasteryzowany Cairo i wysłany jako tekstura.
583 + Text=MemoryRenderBufferRenderElement<GlesRenderer>,
584 + // Element rysowany pixel shaderem: cień okna ORAZ beleczki/glify SSD.
325 585 // (Jedna wariant, bo makro generuje `From` per typ — dwa warianty tego samego
326 586 // typu kolidowałyby.)
327 587 Pixel=PixelShaderElement,
@@ -331,6 +591,7 @@ impl std::fmt::Debug for WindowRenderElement {
331 591 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332 592 match self {
333 593 Self::Window(arg0) => f.debug_tuple("Window").field(arg0).finish(),
594 + Self::Text(arg0) => f.debug_tuple("Text").field(arg0).finish(),
334 595 Self::Pixel(arg0) => f.debug_tuple("Pixel").field(arg0).finish(),
335 596 Self::_GenericCatcher(arg0) => f.debug_tuple("_GenericCatcher").field(arg0).finish(),
336 597 }
@@ -351,41 +612,195 @@ impl AsRenderElements<GlesRenderer> for PaganWindow {
351 612
352 613 // Kolejność ma znaczenie: Smithay oczekuje elementów OD PRZODU DO TYŁU
353 614 // (indeks 0 = rysowany na wierzchu; `render_output` rysuje listę od końca).
354 - // Dlatego najpierw treść okna (na wierzchu), a cień dopiero na końcu —
355 - // czyli POD oknem.
615 + // Dlatego menu okna i treść idą na początek listy, a cień na koniec
616 + // — czyli POD oknem.
617 +
618 + let logical_loc = location.to_f64().to_logical(scale).to_i32_round::<i32>();
619 + let bar = Rectangle::new(
620 + Point::from((logical_loc.x, logical_loc.y - TITLEBAR_HEIGHT)),
621 + Size::from((self.geometry().size.w, TITLEBAR_HEIGHT)),
622 + );
623 +
624 + // Firmowy wygląd PaganDE pochodzi z motywu (`~/.config/pagan/theme.conf`) —
625 + // zmiana kolorów jest widoczna w następnej klatce bez restartu.
626 + let theme = theme::current();
627 + let focused = self.is_focused();
628 + let radius = theme.ssd_radius.max(0.0);
629 + // Tekst i glify aktywnego okna są jaśniejsze; nieaktywnego — przygaszone.
630 + let (text_color, icon_close) = if focused {
631 + (theme.ssd_text, theme.ssd_icon_close)
632 + } else {
633 + (theme.ssd_text_unfocused, theme.ssd_text_unfocused)
634 + };
635 + let text_rgb = (
636 + text_color.r() as f64,
637 + text_color.g() as f64,
638 + text_color.b() as f64,
639 + );
640 + let font_px = theme.ssd_font_px as f64;
641 +
642 + // 0) Menu okna (dropdown z burgera) — musi być NAD treścią okna, dlatego
643 + // trafia na sam przód listy. Rysujemy je tutaj (a nie w `render.rs`),
644 + // bo jest nierozerwalnie związane z konkretnym oknem i jego Z-orderem.
645 + if self.wants_ssd() && self.ssd_menu_open() {
646 + let panel = self.ssd_menu_rect(logical_loc);
647 + // Kolejność: NAJPIERW glify wierszy, POTEM tło panelu — lista jest
648 + // front-to-back, więc tło (wypchnięte później) wyląduje POD glifami.
649 + for (row, button) in [
650 + TitlebarButton::Minimize,
651 + TitlebarButton::Maximize,
652 + TitlebarButton::Close,
653 + ]
654 + .into_iter()
655 + .enumerate()
656 + {
657 + let center = Point::from((
658 + panel.loc.x + MENU_ROW_ICON_X,
659 + panel.loc.y
660 + + MENU_PANEL_PADDING
661 + + MENU_ROW_HEIGHT * row as i32
662 + + MENU_ROW_HEIGHT / 2,
663 + ));
664 + push_glyphs(
665 + &mut elements,
666 + renderer,
667 + button,
668 + center,
669 + text_color,
670 + icon_close,
671 + );
672 + let label = menu_row_label(button);
673 + let text_x = panel.loc.x + MENU_ROW_ICON_X + 18;
674 + if let Some(texture) = text::texture(label, font_px, text_rgb) {
675 + let loc = Point::<f64, Logical>::from((
676 + text_x as f64,
677 + (center.y - texture.size.h / 2) as f64,
678 + ))
679 + .to_physical(scale);
680 + if let Ok(element) = MemoryRenderBufferRenderElement::from_buffer(
681 + renderer,
682 + loc,
683 + &texture.buffer,
684 + None,
685 + None,
686 + None,
687 + Kind::Unspecified,
688 + ) {
689 + elements.push(WindowRenderElement::Text(element));
690 + }
691 + }
692 + }
693 + let panel_bg = Color32F::new(
694 + theme.ssd_bar.r(),
695 + theme.ssd_bar.g(),
696 + theme.ssd_bar.b(),
697 + 0.99,
698 + );
699 + if let Some(element) = render::rounded_rect_element(renderer, panel, panel_bg, 10.0) {
700 + elements.push(WindowRenderElement::Pixel(element));
701 + }
702 + }
356 703
357 704 // 1) Treść okna (klient narysował już swoje wnętrze).
358 705 elements.extend(AsRenderElements::<GlesRenderer>::render_elements::<
359 706 WindowRenderElement,
360 707 >(&self.0, renderer, location, scale, alpha));
361 708
362 - // 1b) SSD: pasek tytułu + okrągłe przyciski (rysuje kompozytor).
709 + // 1b) SSD: PŁASKI pasek tytułu z menu aplikacji w JEDNYM rzędzie
710 + // z przyciskami okna (menu po lewej, przyciski po prawej).
363 711 if self.wants_ssd() {
364 - let logical_loc = location.to_f64().to_logical(scale).to_i32_round::<i32>();
365 - let bar = Rectangle::new(
366 - Point::from((logical_loc.x, logical_loc.y - TITLEBAR_HEIGHT)),
367 - Size::from((self.geometry().size.w, TITLEBAR_HEIGHT)),
368 - );
369 - // Kolejność: NAJPIERW przyciski, POTEM belka — bo lista jest
370 - // front-to-back (mniejszy indeks = bliżej przodu), a belka i przyciski
371 - // nachodzą na siebie. Odwrotna kolejność zasłoniłaby przyciski.
372 - for (_, center, color) in self.titlebar_buttons(bar) {
373 - let area = Rectangle::new(
374 - Point::from((center.x - BUTTON_RADIUS, center.y - BUTTON_RADIUS)),
375 - Size::from((BUTTON_RADIUS * 2, BUTTON_RADIUS * 2)),
712 + // Lista elementów jest OD PRZODU DO TYŁU, więc tekst i glify muszą być
713 + // wypchnięte PRZED belką — inaczej belka by je zasłoniła.
714 + let labels_mode = self.ssd_bar_uses_labels();
715 +
716 + if labels_mode {
717 + let labels = app_menu_labels();
718 + let widths = menu_label_widths(labels, font_px);
719 + for (rect, label) in menu_label_rects(bar, &widths).into_iter().zip(labels) {
720 + push_text(
721 + &mut elements,
722 + renderer,
723 + label,
724 + font_px,
725 + text_rgb,
726 + Point::from((rect.loc.x + rect.size.w / 2, rect.loc.y + rect.size.h / 2)),
727 + scale,
728 + );
729 + }
730 + } else {
731 + push_glyphs(
732 + &mut elements,
733 + renderer,
734 + TitlebarButton::Menu,
735 + menu_center(bar),
736 + text_color,
737 + icon_close,
376 738 );
377 - if let Some(element) =
378 - render::rounded_rect_element(renderer, area, color, BUTTON_RADIUS as f32)
379 - {
380 - elements.push(WindowRenderElement::Pixel(element));
739 + }
740 +
741 + for (button, center) in window_buttons(bar) {
742 + push_glyphs(
743 + &mut elements,
744 + renderer,
745 + button,
746 + center,
747 + text_color,
748 + icon_close,
749 + );
750 + }
751 +
752 + // Tytuł okna — tylko gdy zmieści się między menu a przyciskami.
753 + if let Some(title) = self.window_title() {
754 + let menu_right = if labels_mode {
755 + let labels = app_menu_labels();
756 + let widths = menu_label_widths(labels, font_px);
757 + menu_label_rects(bar, &widths)
758 + .last()
759 + .map(|rect| rect.loc.x + rect.size.w)
760 + .unwrap_or(bar.loc.x + BAR_PADDING_X)
761 + } else {
762 + menu_center(bar).x + 18
763 + };
764 + // Najbardziej na lewo wysunięty przycisk okna to minimalizacja
765 + // (ostatni element listy), stąd indeks 2.
766 + let buttons_left = window_buttons(bar)[2].1.x - 18;
767 + let title_width = text::measure(&title, font_px).map(|s| s.w).unwrap_or(0);
768 + let available = buttons_left - menu_right;
769 + if title_width > 0 && title_width + 24 <= available {
770 + push_text(
771 + &mut elements,
772 + renderer,
773 + &title,
774 + font_px,
775 + text_rgb,
776 + Point::from((menu_right + available / 2, bar.loc.y + bar.size.h / 2)),
777 + scale,
778 + );
381 779 }
382 780 }
383 - // Belka (delikatnie zaokrąglona — górne rogi okna).
384 - if let Some(element) = render::rounded_rect_element(renderer, bar, TITLEBAR_COLOR, 10.0)
781 +
782 + // Cienka, płaska linia oddzielająca pasek od treści okna.
783 + let border = Rectangle::new(
784 + Point::from((bar.loc.x, bar.loc.y + bar.size.h - BORDER_HEIGHT)),
785 + Size::from((bar.size.w, BORDER_HEIGHT)),
786 + );
787 + if let Some(element) =
788 + render::rounded_rect_element(renderer, border, theme.ssd_border, 0.0)
789 + {
790 + elements.push(WindowRenderElement::Pixel(element));
791 + }
792 +
793 + // Belka: płaski kolor (bez gradientu — zgodnie z firmowym stylem).
794 + let bar_color = if focused {
795 + theme.ssd_bar
796 + } else {
797 + theme.ssd_unfocused
798 + };
799 + if let Some(element) =
800 + render::top_rounded_rect_element(renderer, bar, bar_color, radius)
385 801 {
386 802 elements.push(WindowRenderElement::Pixel(element));
387 803 }
388 - tracing::info!(count = elements.len(), "DIAG: SSD gotowe");
389 804 }
390 805
391 806 // 2) Cień POD RAMĄ (pasek + treść) — ta sama grupa elementów = wspólny Z-order.
@@ -406,6 +821,191 @@ impl AsRenderElements<GlesRenderer> for PaganWindow {
406 821 }
407 822 }
408 823
824 +/// Etykiety menu aplikacji, lokalizowane przez `PAGAN_LANG`.
825 +///
826 +/// PO CO cache (`OnceLock`): odczyt zmiennej środowiskowej i wybór tablicy
827 +/// wykonujemy raz na proces — funkcja jest wołana przy każdej klatce i kliku.
828 +fn app_menu_labels() -> &'static [&'static str] {
829 + static LABELS: OnceLock<&'static [&'static str]> = OnceLock::new();
830 + LABELS.get_or_init(|| {
831 + let polish = std::env::var("PAGAN_LANG")
832 + .map(|value| value.trim().to_ascii_lowercase().starts_with("pl"))
833 + .unwrap_or(false);
834 + if polish {
835 + &["Plik", "Edycja", "Widok", "Pomoc"]
836 + } else {
837 + &["File", "Edit", "View", "Help"]
838 + }
839 + })
840 +}
841 +
842 +/// Szerokości klikalnych pól etykiet menu (sam tekst + odstęp po bokach).
843 +fn menu_label_widths(labels: &[&str], font_px: f64) -> Vec<i32> {
844 + labels
845 + .iter()
846 + .map(|label| {
847 + text::measure(label, font_px)
848 + .map(|size| size.w)
849 + .unwrap_or(0)
850 + + MENU_LABEL_PAD_X * 2
851 + })
852 + .collect()
853 +}
854 +
855 +/// Prostokąty pól etykiet menu, układane od lewej krawędzi paska.
856 +///
857 +/// Czysta funkcja (bez Cairo i bez Waylanda) — dzięki temu układ menu można
858 +/// przetestować jednostkowo, a ten sam wynik służy do rysowania i do trafień myszy.
859 +fn menu_label_rects(bar: Rectangle<i32, Logical>, widths: &[i32]) -> Vec<Rectangle<i32, Logical>> {
860 + let height = TITLEBAR_HEIGHT - MENU_LABEL_PAD_Y * 2;
861 + let y = bar.loc.y + MENU_LABEL_PAD_Y;
862 + let mut x = bar.loc.x + BAR_PADDING_X;
863 + widths
864 + .iter()
865 + .map(|width| {
866 + let rect = Rectangle::new(Point::from((x, y)), Size::from((*width, height)));
867 + x += width;
868 + rect
869 + })
870 + .collect()
871 +}
872 +
873 +/// Podpis wiersza menu okna (rozwijanego z burgera).
874 +fn menu_row_label(button: TitlebarButton) -> &'static str {
875 + match button {
876 + TitlebarButton::Minimize => "Minimalizuj",
877 + TitlebarButton::Maximize => "Maksymalizuj",
878 + TitlebarButton::Close => "Zamknij",
879 + TitlebarButton::Menu => "Menu",
880 + }
881 +}
882 +
883 +/// Rysuje napis wyśrodkowany w zadanym punkcie (tekstura Cayro → element GL).
884 +///
885 +/// PO CO osobno: ta sama operacja dotyczy tytułu okna i etykiet menu.
886 +fn push_text(
887 + elements: &mut Vec<WindowRenderElement>,
888 + renderer: &mut GlesRenderer,
889 + text_value: &str,
890 + font_px: f64,
891 + color: (f64, f64, f64),
892 + center: Point<i32, Logical>,
893 + scale: Scale<f64>,
894 +) {
895 + let Some(texture) = text::texture(text_value, font_px, color) else {
896 + return;
897 + };
898 + let location = Point::<f64, Logical>::from((
899 + (center.x - texture.size.w / 2) as f64,
900 + (center.y - texture.size.h / 2) as f64,
901 + ))
902 + .to_physical(scale);
903 + match MemoryRenderBufferRenderElement::from_buffer(
904 + renderer,
905 + location,
906 + &texture.buffer,
907 + None,
908 + None,
909 + None,
910 + Kind::Unspecified,
911 + ) {
912 + Ok(element) => elements.push(WindowRenderElement::Text(element)),
913 + Err(err) => tracing::warn!(?err, "nie udało się narysować tekstu paska"),
914 + }
915 +}
916 +
917 +/// Dodaje do listy elementy glifów danego przycisku (spójne w pasku i w menu).
918 +///
919 +/// PO CO osobno: pasek tytułu i wiersze menu rysują dokładnie te same ikony.
920 +/// Jedna funkcja = brak rozjazdu wizualnego między nimi.
921 +fn push_glyphs(
922 + elements: &mut Vec<WindowRenderElement>,
923 + renderer: &mut GlesRenderer,
924 + button: TitlebarButton,
925 + center: Point<i32, Logical>,
926 + icon: Color32F,
927 + icon_close: Color32F,
928 +) {
929 + for glyph in glyphs_for(button, center, icon, icon_close) {
930 + if let Some(element) = render::rotated_rect_element(
931 + renderer,
932 + glyph.area,
933 + glyph.shape,
934 + glyph.angle,
935 + glyph.color,
936 + glyph.radius,
937 + ) {
938 + elements.push(WindowRenderElement::Pixel(element));
939 + }
940 + }
941 +}
942 +
943 +/// Kształty ikony danego przycisku paska/menu okna.
944 +///
945 +/// PO CO monochromatyczne glify, a nie kolorowe koła: nowoczesny, płaski styl
946 +/// (bliżej GNOME niż macOS) — kolor zostaje tylko jako delikatny akcent na
947 +/// przycisku zamykania. Kompozytor nie ma rasteryzera czcionek, więc ikony
948 +/// składamy z prostokątów, a „X" z dwóch kresek obróconych o ±45°.
949 +fn glyphs_for(
950 + button: TitlebarButton,
951 + center: Point<i32, Logical>,
952 + icon: Color32F,
953 + icon_close: Color32F,
954 +) -> Vec<Glyph> {
955 + // Pomocnik: kwadratowy obszar glifu (mieści też kreski obrócone o 45°).
956 + let boxed = || {
957 + Rectangle::new(
958 + Point::from((center.x - GLYPH_BOX / 2, center.y - GLYPH_BOX / 2)),
959 + Size::from((GLYPH_BOX, GLYPH_BOX)),
960 + )
961 + };
962 + let bar = |w: i32, h: i32, dx: i32, dy: i32| Glyph {
963 + area: Rectangle::new(
964 + Point::from((center.x + dx - w / 2, center.y + dy - h / 2)),
965 + Size::from((w, h)),
966 + ),
967 + shape: Size::from((w, h)),
968 + angle: 0.0,
969 + color: icon,
970 + radius: 1.5,
971 + };
972 +
973 + match button {
974 + // Menu: trzy poziome kreski (burger).
975 + TitlebarButton::Menu => (-1..=1)
976 + .map(|line| bar(GLYPH_HALF * 2, GLYPH_THICKNESS, 0, line * BURGER_GAP))
977 + .collect(),
978 + // Minimalizuj: jedna pozioma kreska.
979 + TitlebarButton::Minimize => vec![bar(GLYPH_HALF * 2, GLYPH_THICKNESS, 0, 0)],
980 + // Maksymalizuj: kwadratowy obrys (cztery kreski).
981 + TitlebarButton::Maximize => {
982 + let side = GLYPH_HALF * 2;
983 + let t = GLYPH_THICKNESS;
984 + let off = side / 2 - t / 2;
985 + vec![
986 + bar(side, t, 0, -off),
987 + bar(side, t, 0, off),
988 + bar(t, side, -off, 0),
989 + bar(t, side, off, 0),
990 + ]
991 + }
992 + // Zamknij: dwa ramiona ukośne („X") — stąd obrót w shaderze.
993 + TitlebarButton::Close => {
994 + let len = GLYPH_BOX - 2;
995 + [ANGLE_45, -ANGLE_45]
996 + .into_iter()
997 + .map(|angle| Glyph {
998 + area: boxed(),
999 + shape: Size::from((len, GLYPH_THICKNESS)),
1000 + angle,
1001 + color: icon_close,
1002 + radius: 1.5,
1003 + })
1004 + .collect()
1005 + }
1006 + }
1007 +}
1008 +
409 1009 /// Cache skompilowanych programów cienia, kluczowany uchwytem kontekstu EGL.
410 1010 ///
411 1011 /// PO CO klucz po kontekście: program GL żyje w konkretnym kontekście. Backend
@@ -514,3 +1114,145 @@ fn shadow_element(
514 1114 Kind::Unspecified,
515 1115 )))
516 1116 }
1117 +
1118 +#[cfg(test)]
1119 +mod tests {
1120 + use super::*;
1121 +
1122 + /// Pasek leży DOKŁADNIE nad treścią (treść zaczyna się pod paskiem).
1123 + #[test]
1124 + fn pasek_jest_nad_trescia() {
1125 + let bar = bar_rect(Point::from((20, 40)), 800);
1126 + assert_eq!(bar.loc, Point::from((20, 0)));
1127 + assert_eq!(bar.size, Size::from((800, TITLEBAR_HEIGHT)));
1128 + }
1129 +
1130 + /// Wymóg wizualny: menu (burger) i przyciski okna leżą na TEJ SAMEJ osi Y.
1131 + #[test]
1132 + fn menu_i_przyciski_na_tej_samej_osi_y() {
1133 + let bar = bar_rect(Point::from((20, 40)), 800);
1134 + let menu_y = menu_center(bar).y;
1135 + for (_, center) in window_buttons(bar) {
1136 + assert_eq!(center.y, menu_y, "przycisk rozjechał się z menu");
1137 + }
1138 + }
1139 +
1140 + /// Klik w środek burgera trafia w „Menu", a nie w pusty pasek.
1141 + #[test]
1142 + fn trafienie_w_burger() {
1143 + let bar = bar_rect(Point::from((20, 40)), 800);
1144 + let center = menu_center(bar);
1145 + let point = Point::from((center.x as f64, center.y as f64));
1146 + assert_eq!(bar_button_at(bar, point), Some(TitlebarButton::Menu));
1147 + }
1148 +
1149 + /// Każdy z trzech przycisków okna trafia we właściwą akcję.
1150 + #[test]
1151 + fn trafienie_w_przyciski_okna() {
1152 + let bar = bar_rect(Point::from((0, 40)), 600);
1153 + for (button, center) in window_buttons(bar) {
1154 + let point = Point::from((center.x as f64, center.y as f64));
1155 + assert_eq!(bar_button_at(bar, point), Some(button));
1156 + }
1157 + }
1158 +
1159 + /// Środek pustej części paska nie należy do żadnego przycisku.
1160 + #[test]
1161 + fn pusty_pasek_nie_trafia() {
1162 + let bar = bar_rect(Point::from((0, 40)), 600);
1163 + let point = Point::from((300.0, 20.0));
1164 + assert_eq!(bar_button_at(bar, point), None);
1165 + }
1166 +
1167 + /// Wiersze menu są w ustalonej kolejności i pokrywają cały panel.
1168 + #[test]
1169 + fn wiersze_menu_maja_stala_kolejnosc() {
1170 + let bar = bar_rect(Point::from((0, 40)), 600);
1171 + let panel = menu_panel_rect(bar);
1172 + assert_eq!(
1173 + panel.loc,
1174 + Point::from((MENU_PANEL_PADDING, TITLEBAR_HEIGHT))
1175 + );
1176 +
1177 + let row_center = |index: i32| {
1178 + Point::from((
1179 + (panel.loc.x + MENU_ROW_ICON_X) as f64,
1180 + (panel.loc.y + MENU_PANEL_PADDING + MENU_ROW_HEIGHT * index + MENU_ROW_HEIGHT / 2)
1181 + as f64,
1182 + ))
1183 + };
1184 + assert_eq!(
1185 + menu_row_at(panel, row_center(0)),
1186 + Some(TitlebarButton::Minimize)
1187 + );
1188 + assert_eq!(
1189 + menu_row_at(panel, row_center(1)),
1190 + Some(TitlebarButton::Maximize)
1191 + );
1192 + assert_eq!(
1193 + menu_row_at(panel, row_center(2)),
1194 + Some(TitlebarButton::Close)
1195 + );
1196 + }
1197 +
1198 + /// Punkt poza panelem menu nie trafia w żaden wiersz.
1199 + #[test]
1200 + fn punkt_poza_panelem_nie_trafia() {
1201 + let bar = bar_rect(Point::from((0, 40)), 600);
1202 + let panel = menu_panel_rect(bar);
1203 + // Po prawej stronie panelu (poza jego szerokością).
1204 + let point = Point::from(((panel.loc.x + panel.size.w + 5) as f64, 100.0));
1205 + assert_eq!(menu_row_at(panel, point), None);
1206 + }
1207 +
1208 + /// Panel menu zaczyna się pod belką paska i jest szerszy niż wysoki.
1209 + #[test]
1210 + fn panel_menu_jest_pod_belka() {
1211 + let bar = bar_rect(Point::from((0, 40)), 800);
1212 + let panel = menu_panel_rect(bar);
1213 + assert_eq!(panel.loc.y, bar.loc.y + TITLEBAR_HEIGHT);
1214 + assert_eq!(panel.size.w, MENU_PANEL_WIDTH);
1215 + assert_eq!(
1216 + panel.size.h,
1217 + MENU_PANEL_PADDING * 2 + MENU_ROW_HEIGHT * MENU_ROWS
1218 + );
1219 + }
1220 +
1221 + /// Etykiety menu układają się od lewej, jedna za drugą, na jednej osi Y.
1222 + #[test]
1223 + fn etykiety_menu_na_tej_samej_osi_y() {
1224 + let bar = bar_rect(Point::from((20, 40)), 900);
1225 + let widths = [40, 50, 45, 48];
1226 + let rects = menu_label_rects(bar, &widths);
1227 + assert_eq!(rects.len(), widths.len());
1228 +
1229 + let first_center_y = rects[0].loc.y + rects[0].size.h / 2;
1230 + for (index, rect) in rects.iter().enumerate() {
1231 + assert_eq!(rect.size.w, widths[index]);
1232 + assert_eq!(
1233 + rect.loc.y + rect.size.h / 2,
1234 + first_center_y,
1235 + "etykieta rozjechała się w pionie"
1236 + );
1237 + if index > 0 {
1238 + // Etykiety sąsiadują bez przerw i bez nakładania się.
1239 + let previous = rects[index - 1];
1240 + assert_eq!(rect.loc.x, previous.loc.x + previous.size.w);
1241 + }
1242 + }
1243 + }
1244 +
1245 + /// Etykiety menu siedzą na tej samej osi Y co przyciski okna.
1246 + #[test]
1247 + fn etykiety_menu_w_linii_z_przyciskami() {
1248 + let bar = bar_rect(Point::from((0, 40)), 1000);
1249 + let labels = menu_label_rects(bar, &[40, 40, 40, 40]);
1250 + let buttons = window_buttons(bar);
1251 + let label_y = labels[0].loc.y + labels[0].size.h / 2;
1252 + for (_, center) in buttons {
1253 + assert_eq!(center.y, bar.loc.y + TITLEBAR_HEIGHT / 2);
1254 + }
1255 + // Środki etykiet i przycisków leżą w pionowym środku paska.
1256 + assert_eq!(label_y, bar.loc.y + TITLEBAR_HEIGHT / 2);
1257 + }
1258 +}
@@ -14,9 +14,7 @@ use wayland_client::protocol::{
14 14 wl_buffer, wl_compositor, wl_pointer, wl_registry, wl_seat, wl_shm, wl_shm_pool,
15 15 wl_surface::WlSurface,
16 16 };
17 -use wayland_client::{
18 - ConnectError, Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum,
19 -};
17 +use wayland_client::{ConnectError, Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum};
20 18 use wayland_protocols::xdg::decoration::zv1::client::{
21 19 zxdg_decoration_manager_v1::ZxdgDecorationManagerV1,
22 20 zxdg_toplevel_decoration_v1::{self, Mode, ZxdgToplevelDecorationV1},
@@ -312,11 +310,14 @@ impl Dispatch<ZxdgToplevelDecorationV1, ()> for CompositorState {
312 310 if let zxdg_toplevel_decoration_v1::Event::Configure { mode } = event {
313 311 if let WEnum::Value(mode) = mode {
314 312 state.decoration_mode = Some(mode);
315 - if mode == Mode::ServerSide {
316 - tracing::warn!(
317 - "kompozytor wymusił dekoracje serwerowe; nasze CSD może się nakładać"
318 - );
313 + // Kompozytor PaganDE wymusza tryb serwerowy. Wtedy klient musi
314 + // zrezygnować z własnego paska tytułu, inaczej powstałyby dwa paski.
315 + let ssd = mode == Mode::ServerSide;
316 + if state.window.ssd != ssd {
317 + state.window.set_ssd(ssd);
318 + state.needs_redraw = true;
319 319 }
320 + tracing::info!(?mode, "wynegocjowano tryb dekoracji okna");
320 321 }
321 322 }
322 323 }
@@ -358,16 +359,17 @@ pub fn create_window(
358 359 toplevel.set_app_id("org.pagan.toolkit.demo".to_string());
359 360 toplevel.set_min_size(120, 80);
360 361
361 - // Prosimy o dekoracje klienckie (CSD) — rysujemy własny pasek tytułu.
362 + // Prosimy o dekoracje serwerowe (SSD) — w PaganDE pasek tytułu rysuje
363 + // kompozytor, żeby wszystkie okna wyglądały spójnie. Kompozytor i tak wymusza
364 + // ten tryb, ale jawne `set_mode` jest zgodne z protokołem i działa też tam,
365 + // gdzie wybór należy do klienta.
362 366 let decoration = decoration_manager.as_ref().map(|manager| {
363 367 let decoration = manager.get_toplevel_decoration(&toplevel, &qh, ());
364 - decoration.set_mode(Mode::ClientSide);
368 + decoration.set_mode(Mode::ServerSide);
365 369 decoration
366 370 });
367 371 if decoration.is_none() {
368 - tracing::info!(
369 - "kompozytor nie oferuje zxdg_decoration_manager_v1 — pozostajemy przy CSD"
370 - );
372 + tracing::info!("kompozytor nie oferuje zxdg_decoration_manager_v1 — pozostajemy przy CSD");
371 373 }
372 374
373 375 let mut state = CompositorState {
@@ -411,6 +413,12 @@ pub fn create_window(
411 413 ));
412 414 }
413 415
416 + // Tryb dekoracji mógł przyjść w trakcie roundtripu — zastosuj go, zanim
417 + // aplikacja narysuje pierwszą ramkę (inaczej błysnąłby zbędny pasek CSD).
418 + if state.decoration_mode == Some(Mode::ServerSide) {
419 + state.window.set_ssd(true);
420 + }
421 +
414 422 Ok((event_queue, state))
415 423 }
416 424
@@ -20,6 +20,10 @@ use cairo::{Context, FontSlant, FontWeight, Operator};
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 paska menu rysowanego przez klienta w trybie SSD (pod paskiem kompozytora).
24 +pub const MENU_STRIP_COLOR: [f64; 3] = [0.11, 0.12, 0.15];
25 +/// Kolor monochromatycznych ikon w trybie SSD (spójny z glifami kompozytora).
26 +pub const ICON_COLOR: [f64; 3] = [0.82, 0.84, 0.88];
23 27 /// Kolor napisu tytułu.
24 28 pub const TITLE_TEXT_COLOR: [f64; 3] = [0.82, 0.84, 0.88];
25 29
@@ -88,6 +92,48 @@ pub fn rounded_rectangle(ctx: &Context, x: f64, y: f64, w: f64, h: f64, r: f64)
88 92 ctx.close_path();
89 93 }
90 94
95 +/// Buduje ścieżkę prostokąta z zaokrąglonymi **tylko dolnymi** rogami.
96 +///
97 +/// PO CO: w trybie SSD pasek tytułu rysuje kompozytor NAD treścią okna. Gdybyśmy
98 +/// zaokrąglili też górne rogi korpusu, w narożnikach prześwitywałby pulpit
99 +/// (pasek kompozytora ma ostre dolne krawędzie).
100 +pub fn rounded_bottom_rectangle(ctx: &Context, x: f64, y: f64, w: f64, h: f64, r: f64) {
101 + let r = r.max(0.0).min(w.min(h) / 2.0);
102 + let right = x + w;
103 + let bottom = y + h;
104 +
105 + ctx.new_sub_path();
106 + ctx.move_to(right, y);
107 + ctx.line_to(right, bottom - r);
108 + ctx.arc(right - r, bottom - r, r, 0.0, FRAC_PI_2);
109 + ctx.arc(x + r, bottom - r, r, FRAC_PI_2, PI);
110 + ctx.line_to(x, y);
111 + ctx.close_path();
112 +}
113 +
114 +/// Rysuje tekst wyśrodkowany względem punktu (`center_x`, `center_y`).
115 +///
116 +/// Mierzymy szerokość napisu (`text_extents`) i przesuwamy punkt startowy o jej
117 +/// połowę — bez tego tytuł okna byłby wyrównany do lewej i wyglądałby krzywo.
118 +pub fn draw_centered_text(
119 + ctx: &Context,
120 + text: &str,
121 + center_x: f64,
122 + center_y: f64,
123 + color: [f64; 3],
124 +) -> Result<(), cairo::Error> {
125 + const FONT_SIZE: f64 = 15.0;
126 + ctx.select_font_face("sans-serif", FontSlant::Normal, FontWeight::Normal);
127 + ctx.set_font_size(FONT_SIZE);
128 + let extents = ctx.text_extents(text)?;
129 + set_color(ctx, color);
130 + ctx.move_to(
131 + center_x - extents.width() / 2.0,
132 + center_y + FONT_SIZE * 0.35,
133 + );
134 + ctx.show_text(text)
135 +}
136 +
91 137 /// Rysuje pasek tytułu z zaokrąglonymi **tylko górnymi** rogami.
92 138 ///
93 139 /// Dolne rogi są ostre, ponieważ pasek styka się bezpośrednio z treścią okna —
@@ -157,6 +203,31 @@ pub fn draw_menu_button(
157 203 ctx.stroke()
158 204 }
159 205
206 +/// Rysuje samą ikonę „burger" (trzy kreski), bez koła tła.
207 +///
208 +/// PO CO osobno: w trybie SSD pasek tytułu rysuje kompozytor w płaskim, monochromatycznym
209 +/// stylu. Klient dokłada w swoim pasku menu ikonę w tym samym stylu — kolorowe koło
210 +/// wyglądałoby tam niespójnie.
211 +pub fn draw_burger(
212 + ctx: &Context,
213 + center_x: f64,
214 + center_y: f64,
215 + half_width: f64,
216 + color: [f64; 3],
217 +) -> Result<(), cairo::Error> {
218 + set_color(ctx, color);
219 + ctx.set_line_width(2.0);
220 + // Zaokrąglone końce kresek — spójne z glifami rysowanymi przez kompozytor.
221 + ctx.set_line_cap(cairo::LineCap::Round);
222 + let gap = 4.0;
223 + for line in -1..=1 {
224 + let y = center_y + (line as f64) * gap;
225 + ctx.move_to(center_x - half_width, y);
226 + ctx.line_to(center_x + half_width, y);
227 + }
228 + ctx.stroke()
229 +}
230 +
160 231 /// Rysuje trzy przyciski okna (minimalizuj / maksymalizuj / zamknij).
161 232 ///
162 233 /// Wszystkie trzy leżą na tej samej osi Y (`center_y`), którą wołający ustawia na
@@ -31,6 +31,14 @@ pub enum MsgKey {
31 31 WindowTitle,
32 32 /// Etykieta przycisku „Menu".
33 33 Menu,
34 + /// Pozycja paska menu: plik.
35 + MenuFile,
36 + /// Pozycja paska menu: edycja.
37 + MenuEdit,
38 + /// Pozycja paska menu: widok.
39 + MenuView,
40 + /// Pozycja paska menu: pomoc.
41 + MenuHelp,
34 42 /// Etykieta/tooltip przycisku zamykania.
35 43 Close,
36 44 /// Etykieta/tooltip przycisku minimalizacji.
@@ -62,6 +70,10 @@ pub fn tr(locale: Locale, key: MsgKey) -> &'static str {
62 70 Locale::Pl => match key {
63 71 MsgKey::WindowTitle => "Pagan Toolkit — okno demo",
64 72 MsgKey::Menu => "Menu",
73 + MsgKey::MenuFile => "Plik",
74 + MsgKey::MenuEdit => "Edycja",
75 + MsgKey::MenuView => "Widok",
76 + MsgKey::MenuHelp => "Pomoc",
65 77 MsgKey::Close => "Zamknij",
66 78 MsgKey::Minimize => "Minimalizuj",
67 79 MsgKey::Maximize => "Maksymalizuj",
@@ -76,6 +88,10 @@ pub fn tr(locale: Locale, key: MsgKey) -> &'static str {
76 88 Locale::En => match key {
77 89 MsgKey::WindowTitle => "Pagan Toolkit — demo window",
78 90 MsgKey::Menu => "Menu",
91 + MsgKey::MenuFile => "File",
92 + MsgKey::MenuEdit => "Edit",
93 + MsgKey::MenuView => "View",
94 + MsgKey::MenuHelp => "Help",
79 95 MsgKey::Close => "Close",
80 96 MsgKey::Minimize => "Minimize",
81 97 MsgKey::Maximize => "Maximize",
@@ -33,7 +33,10 @@ impl fmt::Display for ShmError {
33 33 ShmError::Io(err) => write!(f, "błąd I/O pamięci współdzielonej: {err}"),
34 34 ShmError::ZeroSize => write!(f, "rozmiar bufora SHM musi być dodatni"),
35 35 ShmError::TooLarge(size) => {
36 - write!(f, "rozmiar bufora SHM ({size} B) przekracza zakres protokołu")
36 + write!(
37 + f,
38 + "rozmiar bufora SHM ({size} B) przekracza zakres protokołu"
39 + )
37 40 }
38 41 }
39 42 }
@@ -98,9 +101,7 @@ impl ShmBuffer {
98 101
99 102 let format = Format::Argb8888;
100 103 // ARGB8888 to zawsze 4 bajty na piksel.
101 - let stride = width
102 - .checked_mul(4)
103 - .ok_or(ShmError::TooLarge(usize::MAX))?;
104 + let stride = width.checked_mul(4).ok_or(ShmError::TooLarge(usize::MAX))?;
104 105 let size = (stride as usize)
105 106 .checked_mul(height as usize)
106 107 .ok_or(ShmError::TooLarge(usize::MAX))?;
@@ -124,6 +124,11 @@ pub struct MyToolkitWindow {
124 124 pub locale: Locale,
125 125 /// Aktualnie podświetlona kontrolka (stan hover) — prywatne, bo wyliczane z pozycji.
126 126 hover: Option<Control>,
127 + /// Czy kompozytor rysuje dekoracje serwerowe (SSD).
128 + ///
129 + /// Gdy `true`, klient NIE rysuje własnego paska tytułu ani przycisków okna —
130 + /// robi to kompozytor. Zapobiega to podwójnemu paskowi.
131 + pub ssd: bool,
127 132 }
128 133
129 134 impl MyToolkitWindow {
@@ -142,6 +147,18 @@ impl MyToolkitWindow {
142 147 title: title.into(),
143 148 locale,
144 149 hover: None,
150 + ssd: false,
151 + }
152 + }
153 +
154 + /// Włącza/wyłącza tryb dekoracji serwerowych (SSD).
155 + ///
156 + /// Wołane po zdarzeniu `zxdg_toplevel_decoration_v1.configure`: gdy kompozytor
157 + /// wymusza SSD, klient musi zrezygnować z własnego paska tytułu.
158 + pub fn set_ssd(&mut self, ssd: bool) {
159 + if self.ssd != ssd {
160 + self.ssd = ssd;
161 + self.hover = None;
145 162 }
146 163 }
147 164
@@ -278,6 +295,12 @@ impl MyToolkitWindow {
278 295
279 296 /// Właściwa kolejność warstw rysowania okna.
280 297 fn draw(&self, ctx: &Context) -> Result<(), cairo::Error> {
298 + // W trybie SSD pasek tytułu i przyciski rysuje kompozytor — klient
299 + // rysuje tylko korpus oraz własny pasek menu.
300 + if self.ssd {
301 + return self.draw_ssd(ctx);
302 + }
303 +
281 304 let width = self.width as f64;
282 305 let height = self.height as f64;
283 306
@@ -331,6 +354,27 @@ impl MyToolkitWindow {
331 354 Ok(())
332 355 }
333 356
357 + /// Rysuje okno w trybie dekoracji serwerowych (SSD).
358 + ///
359 + /// PO CO osobna ścieżka: w trybie SSD CAŁĄ dekorację (pasek tytułu, menu
360 + /// aplikacji, przyciski okna) rysuje kompozytor. Klient nie może dołożyć
361 + /// własnego paska — powstałyby dwa. Zostaje mu tylko korpus okna
362 + /// (z zaokrąglonymi dolnymi rogami, bo górę zasłania ramka kompozytora).
363 + fn draw_ssd(&self, ctx: &Context) -> Result<(), cairo::Error> {
364 + let width = self.width as f64;
365 + let height = self.height as f64;
366 +
367 + draw::clear_transparent(ctx)?;
368 +
369 + // Korpus: zaokrąglone tylko DOLNE rogi (górę zasłania pasek SSD
370 + // o ostrych dolnych krawędziach — zaokrąglenie góry dałoby prześwit).
371 + draw::rounded_bottom_rectangle(ctx, 0.0, 0.0, width, height, self.corner_radius);
372 + draw::set_color(ctx, draw::BODY_COLOR);
373 + ctx.fill()?;
374 +
375 + Ok(())
376 + }
377 +
334 378 /// Hit-testing: zwraca akcję dla kliknięcia w punkcie `(x, y)`.
335 379 ///
336 380 /// Kolejność ma znaczenie: najpierw przyciski (małe, precyzyjne cele), dopiero
@@ -340,6 +384,12 @@ impl MyToolkitWindow {
340 384 return WindowAction::None;
341 385 }
342 386
387 + // W trybie SSD całą dekorację rysuje kompozytor — klient nie ma tu
388 + // żadnych własnych kontrolek (menu aplikacji jest w pasku kompozytora).
389 + if self.ssd {
390 + return WindowAction::None;
391 + }
392 +
343 393 if let Some(control) = self.hit_control(x, y) {
344 394 return match control {
345 395 Control::Menu => WindowAction::ShowMenu,
@@ -362,6 +412,14 @@ impl MyToolkitWindow {
362 412 /// Zwraca `true`, gdy stan się zmienił — tylko wtedy trzeba przerysować okno,
363 413 /// co oszczędza pełne renderowanie Cairo przy każdym drobnym ruchu myszy.
364 414 pub fn handle_mouse_motion(&mut self, x: i32, y: i32) -> bool {
415 + if self.ssd {
416 + // W trybie SSD klient nie rysuje własnych kontrolek — nic nie podświetlamy.
417 + if self.hover.take().is_some() {
418 + return true;
419 + }
420 + return false;
421 + }
422 +
365 423 let new_hover = if self.is_inside(x, y) {
366 424 self.hit_control(x, y)
367 425 } else {