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 {