Commit e03dd6a
10
plików
+419
dodanych
-156
usuniętych
@@ -175,6 +175,26 @@ pojedynczy przycisk „burger”, rozwijający menu okna.
175
175
Tekst na pasku (tytuł i etykiety menu) rysuje kompozytor: rasteryzuje go Cairo
176
176
i wysyła na GPU jako teksturę (`pagan-compositor/src/text.rs`, cache per napis).
177
177
178
+#### Ikony przycisków (wektorowo)
179
+
180
+Ikony minimalizuj/maksymalizuj/zamknij oraz „burger” są rysowane **wektorowo**
181
+Cairo (`pagan-compositor/src/icons.rs`) — prawdziwe ścieżki z zaokrąglonymi
182
+końcami (`LineCap::Round`), a nie aproksymacja prostokątami. Świadomie NIE
183
+ładujemy plików `.svg`: renderowanie SVG wymaga `librsvg`, które ciągnie stos
184
+GTK/GDK — wbrew założeniu „bez gotowych frameworków UI”. Kształty są te same,
185
+co miałyby w SVG, a rozmiar i kolor steruje motyw (`ssd_icon_px`, `ssd_text`).
186
+
187
+#### Launcher do testów (panel)
188
+
189
+Panel (`pagan-panel`) ma dwa przyciski obok siebie:
190
+
191
+* **Pagan** — uruchamia terminal (jak dotychczas),
192
+* **Demo** — uruchamia naszą aplikację testową (`pagan-toolkit/examples/demo`),
193
+ żeby jednym kliknięciem obejrzeć dekoracje SSD i menu aplikacji.
194
+
195
+Ścieżkę do demo można nadpisać zmienną `PAGAN_DEMO_CMD`. Bez niej panel szuka
196
+`pagan-demo` w `PATH`, a potem w układzie deweloperskim repozytorium.
197
+
178
198
### Testy interakcji bez machania myszką (dev)
179
199
180
200
Na backendzie `winit` kompozytor jest zwykłym oknem X11, więc zdarzenia myszy
@@ -197,6 +217,7 @@ DISPLAY=:1 python3 tools/x11_probe.py find 1280 800 # origin okna klienta
197
217
| 3. Shadery GLSL (zaokrąglenia + cień) | ✅ **cień na GPU** (pixel shader) | `pagan-compositor/src/shaders/`, `window.rs` |
198
218
| 4. Wymuszone dekoracje serwerowe (SSD) + menu aplikacji w pasku | ✅ | `pagan-compositor/src/window.rs`, `text.rs`, `app_menu.rs` |
199
219
| 5. Własny protokół `pagan_app_menu_v1` (menu per aplikacja) | ✅ | `protocols/`, `pagan-compositor/src/app_menu.rs`, `pagan-toolkit/src/app_menu.rs` |
220
+| 6. Wektorowe ikony paska + launcher aplikacji testowej w panelu | ✅ | `pagan-compositor/src/icons.rs`, `pagan-panel/src/main.rs` |
200
221
201
222
## Ograniczenia i następne kroki
202
223
203
224
new file mode 100644
204
225
Binary files /dev/null and b/inne/Przechwycenie obrazu ekranu_2026-09-13_20-53-42.png differ
205
226
new file mode 100644
@@ -0,0 +1,213 @@
1
+//! Wektorowe ikony paska tytułu (przyciski okna), rasteryzowane Cairo.
2
+//!
3
+//! PO CO wektor, a nie aproksymacja prostokątami (jak wcześniej pixel shaderem):
4
+//! ikony z prostokątów miały tylko zaokrąglone krawędzie, więc np. „X" i kwadrat
5
+//! wyglądały kanciasto i wyblakło. Cairo rysuje prawdziwe ścieżki z zaokrąglonymi
6
+//! końcami (`LineCap::Round`) i połączeniami — jak w nowoczesnych pulpitach.
7
+//!
8
+//! PO CO Cairo, a nie pliki SVG: renderowanie SVG wymaga `librsvg`, które ciągnie
9
+//! za sobą stos GTK/GDK — wbrew założeniu „bez gotowych frameworków UI”. Ikony
10
+//! rysujemy więc KODEM (te same kształty, które miałyby w SVG), bez nowych
11
+//! zależności. Jeśli w przyszłości zapragniemy edytowalnych plików `.svg`,
12
+//! wystarczy podmienić tę funkcję na wczytanie pliku przez librsvg.
13
+//!
14
+//! Cache: rasteryzacja jest kosztowna, a ikony zmieniają się tylko przy zmianie
15
+//! motywu — kluczujemy więc rozmiar i kolor.
16
+
17
+use std::{
18
+ collections::HashMap,
19
+ sync::{Mutex, OnceLock},
20
+};
21
+
22
+use cairo::{Context, Format, ImageSurface, LineCap, LineJoin};
23
+use smithay::{
24
+ backend::renderer::element::memory::MemoryRenderBuffer,
25
+ utils::{Logical, Size},
26
+};
27
+
28
+/// Ikona przycisku paska tytułu.
29
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30
+pub enum Icon {
31
+ /// Menu aplikacji — trzy poziome kreski („burger”).
32
+ Menu,
33
+ /// Minimalizuj — pozioma kreska.
34
+ Minimize,
35
+ /// Maksymalizuj — kwadratowy obrys.
36
+ Maximize,
37
+ /// Zamknij — ukośny krzyżyk.
38
+ Close,
39
+}
40
+
41
+/// Gotowa tekstura ikony wraz z rozmiarem w pikselach logicznych.
42
+#[derive(Clone)]
43
+pub struct IconTexture {
44
+ /// Bufor pamięciowy gotowy do importu przez renderer.
45
+ pub buffer: MemoryRenderBuffer,
46
+ /// Rozmiar tekstury (kwadrat, bok = rozmiar obszaru ikony).
47
+ pub size: Size<i32, Logical>,
48
+}
49
+
50
+/// Klucz cache — to, co wpływa na wygląd tekstury.
51
+#[derive(PartialEq, Eq, Hash, Clone)]
52
+struct Key {
53
+ icon: Icon,
54
+ size_px: i32,
55
+ color: [u8; 3],
56
+}
57
+
58
+fn cache() -> &'static Mutex<HashMap<Key, Option<IconTexture>>> {
59
+ static CACHE: OnceLock<Mutex<HashMap<Key, Option<IconTexture>>>> = OnceLock::new();
60
+ CACHE.get_or_init(|| Mutex::new(HashMap::new()))
61
+}
62
+
63
+/// Zwraca (z cache) teksturę ikony. `None`, gdy rasteryzacja się nie powiodła.
64
+pub fn texture(icon: Icon, size_px: i32, color: (f64, f64, f64)) -> Option<IconTexture> {
65
+ let size_px = size_px.clamp(8, 96);
66
+ let key = Key {
67
+ icon,
68
+ size_px,
69
+ color: [
70
+ (color.0.clamp(0.0, 1.0) * 255.0).round() as u8,
71
+ (color.1.clamp(0.0, 1.0) * 255.0).round() as u8,
72
+ (color.2.clamp(0.0, 1.0) * 255.0).round() as u8,
73
+ ],
74
+ };
75
+
76
+ if let Some(cached) = cache()
77
+ .lock()
78
+ .expect("cache ikon zatruty")
79
+ .get(&key)
80
+ .cloned()
81
+ {
82
+ return cached;
83
+ }
84
+
85
+ let raster = rasterize(icon, size_px, color);
86
+ if raster.is_none() {
87
+ tracing::warn!(?icon, size_px, "nie udało się zrasteryzować ikony");
88
+ }
89
+ cache()
90
+ .lock()
91
+ .expect("cache ikon zatruty")
92
+ .insert(key, raster.clone());
93
+ raster
94
+}
95
+
96
+/// Rysuje ikonę na kwadratowej powierzchni i wysyła ją jako teksturę.
97
+fn rasterize(icon: Icon, size_px: i32, color: (f64, f64, f64)) -> Option<IconTexture> {
98
+ let surface = ImageSurface::create(Format::ARgb32, size_px, size_px).ok()?;
99
+ {
100
+ let ctx = Context::new(&surface).ok()?;
101
+ // Cairo włącza antyaliasing domyślnie — krawędzie będą gładkie.
102
+ ctx.set_source_rgb(color.0, color.1, color.2);
103
+ // Grubość kreski rośnie z rozmiarem ikony, ale nie schodzi poniżej 1.5 px.
104
+ let thickness = (size_px as f64 / 8.5).max(1.5);
105
+ ctx.set_line_width(thickness);
106
+ ctx.set_line_cap(LineCap::Round);
107
+ ctx.set_line_join(LineJoin::Round);
108
+
109
+ let center = size_px as f64 / 2.0;
110
+ // Pół-długość boku glifu: ~1/3 obszaru ikony, z zapasem na grubość kreski.
111
+ let half = (size_px as f64 / 2.0) - thickness - 1.5;
112
+
113
+ match icon {
114
+ Icon::Menu => {
115
+ // Trzy kreski; odstęp dobrany do grubości, żeby glif był zwarty.
116
+ let gap = (half * 0.62).max(thickness);
117
+ for line in -1..=1 {
118
+ let y = center + line as f64 * gap;
119
+ ctx.move_to(center - half, y);
120
+ ctx.line_to(center + half, y);
121
+ }
122
+ ctx.stroke().ok()?;
123
+ }
124
+ Icon::Minimize => {
125
+ ctx.move_to(center - half, center);
126
+ ctx.line_to(center + half, center);
127
+ ctx.stroke().ok()?;
128
+ }
129
+ Icon::Maximize => {
130
+ // Kwadrat o wyraźnie zaokrąglonych rogach (styl nowoczesny).
131
+ let radius = (half * 0.35).max(1.0);
132
+ rounded_square(&ctx, center - half, center - half, half * 2.0, radius);
133
+ ctx.stroke().ok()?;
134
+ }
135
+ Icon::Close => {
136
+ ctx.move_to(center - half, center - half);
137
+ ctx.line_to(center + half, center + half);
138
+ ctx.move_to(center - half, center + half);
139
+ ctx.line_to(center + half, center - half);
140
+ ctx.stroke().ok()?;
141
+ }
142
+ }
143
+ }
144
+ surface.flush();
145
+
146
+ // Ponownie używamy wspólnej konwersji ARGB32 → tekstura (patrz `text.rs`).
147
+ let stride = surface.stride();
148
+ let row_bytes = size_px * 4;
149
+ let mut data = Vec::with_capacity((row_bytes * size_px) as usize);
150
+ surface
151
+ .with_data(|raw| {
152
+ if stride == row_bytes {
153
+ data.extend_from_slice(raw);
154
+ } else {
155
+ for row in raw.chunks_exact(stride as usize).take(size_px as usize) {
156
+ data.extend_from_slice(&row[..row_bytes as usize]);
157
+ }
158
+ }
159
+ })
160
+ .ok()?;
161
+
162
+ let (buffer, size) = crate::text::upload(&data, size_px, size_px)?;
163
+ Some(IconTexture { buffer, size })
164
+}
165
+
166
+/// Dodaje do ścieżki kwadrat o zaokrąglonych rogach (bez wypełniania).
167
+fn rounded_square(ctx: &Context, x: f64, y: f64, side: f64, radius: f64) {
168
+ use std::f64::consts::FRAC_PI_2;
169
+ let radius = radius.min(side / 2.0);
170
+ let right = x + side;
171
+ let bottom = y + side;
172
+ ctx.new_sub_path();
173
+ ctx.arc(right - radius, y + radius, radius, -FRAC_PI_2, 0.0);
174
+ ctx.arc(right - radius, bottom - radius, radius, 0.0, FRAC_PI_2);
175
+ ctx.arc(
176
+ x + radius,
177
+ bottom - radius,
178
+ radius,
179
+ FRAC_PI_2,
180
+ std::f64::consts::PI,
181
+ );
182
+ ctx.arc(
183
+ x + radius,
184
+ y + radius,
185
+ radius,
186
+ std::f64::consts::PI,
187
+ 3.0 * FRAC_PI_2,
188
+ );
189
+ ctx.close_path();
190
+}
191
+
192
+#[cfg(test)]
193
+mod tests {
194
+ use super::*;
195
+
196
+ /// Każda ikona rasteryzuje się do niepustej tekstury o zadanym rozmiarze.
197
+ #[test]
198
+ fn ikony_sa_rasteryzowane() {
199
+ for icon in [Icon::Menu, Icon::Minimize, Icon::Maximize, Icon::Close] {
200
+ let texture =
201
+ texture(icon, 18, (0.8, 0.8, 0.8)).expect("ikona powinna się wyrenderować");
202
+ assert_eq!(texture.size.w, 18);
203
+ assert_eq!(texture.size.h, 18);
204
+ }
205
+ }
206
+
207
+ /// Rozmiar jest przycinany do bezpiecznego zakresu (ochrona przed absurdem z motywu).
208
+ #[test]
209
+ fn rozmiar_jest_przycinany() {
210
+ let texture = texture(Icon::Close, 4, (1.0, 1.0, 1.0)).expect("ikona minimalna");
211
+ assert_eq!(texture.size.w, 8);
212
+ }
213
+}
@@ -18,6 +18,7 @@ mod cursor;
18
18
mod focus;
19
19
mod foreign_toplevel;
20
20
mod grabs;
21
+mod icons;
21
22
mod input;
22
23
mod output;
23
24
mod render;
@@ -378,31 +378,6 @@ pub fn top_rounded_rect_element(
378
378
)
379
379
}
380
380
381
-/// Buduje element obróconego prostokąta o zaokrąglonych rogach.
382
-///
383
-/// PO CO osobno: ikona „X" (zamknij) to dwa prostokąty obrócone o ±45°.
384
-/// `area` jest obszarem elementu (kwadrat obejmujący kształt), a `shape_size`
385
-/// — wymiarami samej kreski, która może być od niego mniejsza.
386
-pub fn rotated_rect_element(
387
- renderer: &mut GlesRenderer,
388
- area: Rectangle<i32, Logical>,
389
- shape_size: Size<i32, Logical>,
390
- angle: f32,
391
- color: Color32F,
392
- radius: f32,
393
-) -> Option<PixelShaderElement> {
394
- shape_element(
395
- renderer,
396
- area,
397
- shape_size,
398
- angle,
399
- (radius, radius, radius, radius),
400
- color,
401
- color,
402
- false,
403
- )
404
-}
405
-
406
381
/// Wspólny konstruktor elementu kształtu — jedno miejsce na uniformy shadera.
407
382
#[allow(clippy::too_many_arguments)]
408
383
fn shape_element(
@@ -180,20 +180,29 @@ fn rasterize(text: &str, font_px: f64, color: (f64, f64, f64)) -> Option<TextTex
180
180
}
181
181
})
182
182
.ok()?;
183
- if data.len() < (row_bytes * height) as usize {
183
+ let (buffer, size) = upload(&data, width, height)?;
184
+ Some(TextTexture { buffer, size })
185
+}
186
+
187
+/// Wysyła bajty powierzchni Cairo (ARGB32, premultiplied) jako teksturę GL.
188
+///
189
+/// PO CO wspólny helper: korzystają z niego i tekst ([`texture`]), i ikony
190
+/// (`crate::icons`) — konwersja formatu i wyrównanie wierszy muszą być identyczne.
191
+pub(crate) fn upload(
192
+ data: &[u8],
193
+ width: i32,
194
+ height: i32,
195
+) -> Option<(MemoryRenderBuffer, Size<i32, Logical>)> {
196
+ if data.len() < (width * height * 4) as usize {
184
197
return None;
185
198
}
186
-
187
199
let buffer = MemoryRenderBuffer::from_slice(
188
- &data,
200
+ data,
189
201
Fourcc::Argb8888,
190
202
(width, height),
191
203
1,
192
204
Transform::Normal,
193
205
None,
194
206
);
195
- Some(TextTexture {
196
- buffer,
197
- size: Size::from((width, height)),
198
- })
207
+ Some((buffer, Size::from((width, height))))
199
208
}
@@ -79,6 +79,8 @@ pub struct Theme {
79
79
pub ssd_radius: f32,
80
80
/// Rozmiar czcionki tytułu i etykiet menu (px).
81
81
pub ssd_font_px: f32,
82
+ /// Bok obszaru ikon przycisków okna (px).
83
+ pub ssd_icon_px: i32,
82
84
/// Poniżej tej szerokości okna etykiety menu zwijają się do burgera.
83
85
pub ssd_menu_min_width: i32,
84
86
}
@@ -106,6 +108,7 @@ impl Default for Theme {
106
108
ssd_icon_close: hex(0xe0, 0x6c, 0x6c),
107
109
ssd_radius: 12.0,
108
110
ssd_font_px: 13.0,
111
+ ssd_icon_px: 18,
109
112
ssd_menu_min_width: 560,
110
113
}
111
114
}
@@ -180,6 +183,7 @@ impl Theme {
180
183
"ssd_icon_close" => self.ssd_icon_close = parse_color(value).ok_or_else(bad_color)?,
181
184
"ssd_radius" => self.ssd_radius = parse_float(value)?.max(0.0),
182
185
"ssd_font_px" => self.ssd_font_px = parse_float(value)?.clamp(6.0, 48.0),
186
+ "ssd_icon_px" => self.ssd_icon_px = parse_float(value)?.clamp(8.0, 64.0) as i32,
183
187
"ssd_menu_min_width" => self.ssd_menu_min_width = parse_float(value)?.max(0.0) as i32,
184
188
other => {
185
189
return Err(format!(
@@ -187,7 +191,7 @@ impl Theme {
187
191
shadow_opacity, shadow_blur, shadow_offset_y, shadow_radius, \
188
192
cursor_fill, cursor_border, snap_preview, snap_preview_radius, \
189
193
ssd_bar, ssd_unfocused, ssd_border, ssd_text, ssd_text_unfocused, \
190
- ssd_icon_close, ssd_radius, ssd_font_px, ssd_menu_min_width)"
194
+ ssd_icon_close, ssd_radius, ssd_font_px, ssd_icon_px, ssd_menu_min_width)"
191
195
));
192
196
}
193
197
}
@@ -280,6 +284,7 @@ ssd_text_unfocused = #868b96
280
284
ssd_icon_close = #e06c6c
281
285
ssd_radius = 12
282
286
ssd_font_px = 13
287
+ssd_icon_px = 18
283
288
# Poniżej tej szerokości okna etykiety menu zwijają się do pojedynczego burgera.
284
289
ssd_menu_min_width = 560
285
290
";
@@ -53,7 +53,7 @@ use smithay::{
53
53
},
54
54
};
55
55
56
-use crate::{render, shaders::SHADOW_FRAG, text, theme};
56
+use crate::{icons, render, shaders::SHADOW_FRAG, text, theme};
57
57
58
58
// --- Dekoracje serwerowe (SSD) ------------------------------------------------
59
59
//
@@ -76,16 +76,6 @@ const BAR_PADDING_X: i32 = 12;
76
76
const MENU_LABEL_PAD_X: i32 = 10;
77
77
/// Odstęp górny/dolny pola etykiety menu od krawędzi paska.
78
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
79
90
80
/// Tło panelu menu wyprowadzamy z koloru paska (patrz `render_elements`).
91
81
/// Szerokość panelu menu okna.
@@ -98,24 +88,8 @@ const MENU_PANEL_PADDING: i32 = 6;
98
88
const MENU_ROWS: i32 = 3;
99
89
/// Poziomy środek ikony w wierszu menu (liczony od lewej krawędzi panelu).
100
90
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
-}
91
+/// Odstęp napisu wiersza menu od jego ikony (px).
92
+const MENU_ROW_LABEL_OFFSET: i32 = 18;
119
93
120
94
/// Który przycisk paska tytułu trafiono.
121
95
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -600,8 +574,9 @@ impl AsRenderElements<GlesRenderer> for PaganWindow {
600
574
let theme = theme::current();
601
575
let focused = self.is_focused();
602
576
let radius = theme.ssd_radius.max(0.0);
603
- // Tekst i glify aktywnego okna są jaśniejsze; nieaktywnego — przygaszone.
604
- let (text_color, icon_close) = if focused {
577
+ // Tekst i ikony aktywnego okna są jaśniejsze; nieaktywnego — przygaszone.
578
+ // Kolor RGB podajemy wprost do rasteryzera (Cairo), stąd trójki `f64`.
579
+ let (text_color, close_color) = if focused {
605
580
(theme.ssd_text, theme.ssd_icon_close)
606
581
} else {
607
582
(theme.ssd_text_unfocused, theme.ssd_text_unfocused)
@@ -611,7 +586,13 @@ impl AsRenderElements<GlesRenderer> for PaganWindow {
611
586
text_color.g() as f64,
612
587
text_color.b() as f64,
613
588
);
589
+ let close_rgb = (
590
+ close_color.r() as f64,
591
+ close_color.g() as f64,
592
+ close_color.b() as f64,
593
+ );
614
594
let font_px = theme.ssd_font_px as f64;
595
+ let icon_px = theme.ssd_icon_px;
615
596
616
597
// 0) Menu okna (dropdown z burgera) — musi być NAD treścią okna, dlatego
617
598
// trafia na sam przód listy. Rysujemy je tutaj (a nie w `render.rs`),
@@ -635,16 +616,21 @@ impl AsRenderElements<GlesRenderer> for PaganWindow {
635
616
+ MENU_ROW_HEIGHT * row as i32
636
617
+ MENU_ROW_HEIGHT / 2,
637
618
));
638
- push_glyphs(
619
+ push_icon(
639
620
&mut elements,
640
621
renderer,
641
622
button,
642
623
center,
643
- text_color,
644
- icon_close,
624
+ icon_px,
625
+ if button == TitlebarButton::Close {
626
+ close_rgb
627
+ } else {
628
+ text_rgb
629
+ },
630
+ scale,
645
631
);
646
632
let label = menu_row_label(button);
647
- let text_x = panel.loc.x + MENU_ROW_ICON_X + 18;
633
+ let text_x = panel.loc.x + MENU_ROW_ICON_X + MENU_ROW_LABEL_OFFSET;
648
634
if let Some(texture) = text::texture(label, font_px, text_rgb) {
649
635
let loc = Point::<f64, Logical>::from((
650
636
text_x as f64,
@@ -704,24 +690,31 @@ impl AsRenderElements<GlesRenderer> for PaganWindow {
704
690
);
705
691
}
706
692
} else {
707
- push_glyphs(
693
+ push_icon(
708
694
&mut elements,
709
695
renderer,
710
696
TitlebarButton::Menu,
711
697
menu_center(bar),
712
- text_color,
713
- icon_close,
698
+ icon_px,
699
+ text_rgb,
700
+ scale,
714
701
);
715
702
}
716
703
704
+ // Przyciski okna po prawej — zawsze, niezależnie od trybu menu.
717
705
for (button, center) in window_buttons(bar) {
718
- push_glyphs(
706
+ push_icon(
719
707
&mut elements,
720
708
renderer,
721
709
button,
722
710
center,
723
- text_color,
724
- icon_close,
711
+ icon_px,
712
+ if button == TitlebarButton::Close {
713
+ close_rgb
714
+ } else {
715
+ text_rgb
716
+ },
717
+ scale,
725
718
);
726
719
}
727
720
@@ -872,95 +865,49 @@ fn push_text(
872
865
}
873
866
}
874
867
875
-/// Dodaje do listy elementy glifów danego przycisku (spójne w pasku i w menu).
868
+/// Dodaje do listy element ikony danego przycisku (spójne w pasku i w menu).
876
869
///
877
870
/// PO CO osobno: pasek tytułu i wiersze menu rysują dokładnie te same ikony.
878
871
/// Jedna funkcja = brak rozjazdu wizualnego między nimi.
879
-fn push_glyphs(
872
+#[allow(clippy::too_many_arguments)]
873
+fn push_icon(
880
874
elements: &mut Vec<WindowRenderElement>,
881
875
renderer: &mut GlesRenderer,
882
876
button: TitlebarButton,
883
877
center: Point<i32, Logical>,
884
- icon: Color32F,
885
- icon_close: Color32F,
878
+ size_px: i32,
879
+ color: (f64, f64, f64),
880
+ scale: Scale<f64>,
886
881
) {
887
- for glyph in glyphs_for(button, center, icon, icon_close) {
888
- if let Some(element) = render::rotated_rect_element(
889
- renderer,
890
- glyph.area,
891
- glyph.shape,
892
- glyph.angle,
893
- glyph.color,
894
- glyph.radius,
895
- ) {
896
- elements.push(WindowRenderElement::Pixel(element));
897
- }
882
+ let Some(texture) = icons::texture(icon_for(button), size_px, color) else {
883
+ return;
884
+ };
885
+ let location = Point::<f64, Logical>::from((
886
+ (center.x - texture.size.w / 2) as f64,
887
+ (center.y - texture.size.h / 2) as f64,
888
+ ))
889
+ .to_physical(scale);
890
+ match MemoryRenderBufferRenderElement::from_buffer(
891
+ renderer,
892
+ location,
893
+ &texture.buffer,
894
+ None,
895
+ None,
896
+ None,
897
+ Kind::Unspecified,
898
+ ) {
899
+ Ok(element) => elements.push(WindowRenderElement::Text(element)),
900
+ Err(err) => tracing::warn!(?err, "nie udało się narysować ikony paska"),
898
901
}
899
902
}
900
903
901
-/// Kształty ikony danego przycisku paska/menu okna.
902
-///
903
-/// PO CO monochromatyczne glify, a nie kolorowe koła: nowoczesny, płaski styl
904
-/// (bliżej GNOME niż macOS) — kolor zostaje tylko jako delikatny akcent na
905
-/// przycisku zamykania. Kompozytor nie ma rasteryzera czcionek, więc ikony
906
-/// składamy z prostokątów, a „X" z dwóch kresek obróconych o ±45°.
907
-fn glyphs_for(
908
- button: TitlebarButton,
909
- center: Point<i32, Logical>,
910
- icon: Color32F,
911
- icon_close: Color32F,
912
-) -> Vec<Glyph> {
913
- // Pomocnik: kwadratowy obszar glifu (mieści też kreski obrócone o 45°).
914
- let boxed = || {
915
- Rectangle::new(
916
- Point::from((center.x - GLYPH_BOX / 2, center.y - GLYPH_BOX / 2)),
917
- Size::from((GLYPH_BOX, GLYPH_BOX)),
918
- )
919
- };
920
- let bar = |w: i32, h: i32, dx: i32, dy: i32| Glyph {
921
- area: Rectangle::new(
922
- Point::from((center.x + dx - w / 2, center.y + dy - h / 2)),
923
- Size::from((w, h)),
924
- ),
925
- shape: Size::from((w, h)),
926
- angle: 0.0,
927
- color: icon,
928
- radius: 1.5,
929
- };
930
-
904
+/// Ikona odpowiadająca przyciskowi paska.
905
+fn icon_for(button: TitlebarButton) -> icons::Icon {
931
906
match button {
932
- // Menu: trzy poziome kreski (burger).
933
- TitlebarButton::Menu => (-1..=1)
934
- .map(|line| bar(GLYPH_HALF * 2, GLYPH_THICKNESS, 0, line * BURGER_GAP))
935
- .collect(),
936
- // Minimalizuj: jedna pozioma kreska.
937
- TitlebarButton::Minimize => vec![bar(GLYPH_HALF * 2, GLYPH_THICKNESS, 0, 0)],
938
- // Maksymalizuj: kwadratowy obrys (cztery kreski).
939
- TitlebarButton::Maximize => {
940
- let side = GLYPH_HALF * 2;
941
- let t = GLYPH_THICKNESS;
942
- let off = side / 2 - t / 2;
943
- vec![
944
- bar(side, t, 0, -off),
945
- bar(side, t, 0, off),
946
- bar(t, side, -off, 0),
947
- bar(t, side, off, 0),
948
- ]
949
- }
950
- // Zamknij: dwa ramiona ukośne („X") — stąd obrót w shaderze.
951
- TitlebarButton::Close => {
952
- let len = GLYPH_BOX - 2;
953
- [ANGLE_45, -ANGLE_45]
954
- .into_iter()
955
- .map(|angle| Glyph {
956
- area: boxed(),
957
- shape: Size::from((len, GLYPH_THICKNESS)),
958
- angle,
959
- color: icon_close,
960
- radius: 1.5,
961
- })
962
- .collect()
963
- }
907
+ TitlebarButton::Menu => icons::Icon::Menu,
908
+ TitlebarButton::Minimize => icons::Icon::Minimize,
909
+ TitlebarButton::Maximize => icons::Icon::Maximize,
910
+ TitlebarButton::Close => icons::Icon::Close,
964
911
}
965
912
}
966
913
@@ -428,9 +428,11 @@ dependencies = [
428
428
"tempfile",
429
429
"tracing",
430
430
"tracing-subscriber",
431
+ "wayland-backend",
431
432
"wayland-client",
432
433
"wayland-protocols",
433
434
"wayland-protocols-wlr",
435
+ "wayland-scanner",
434
436
]
435
437
436
438
[[package]]
@@ -59,8 +59,10 @@ const PAD: f64 = 10.0;
59
59
const GAP: f64 = 8.0;
60
60
/// Wysokość „pigułek” (kafelków/przycisków).
61
61
const PILL_H: f64 = 24.0;
62
-/// Szerokość przycisku launchera.
62
+/// Szerokość przycisku launchera terminala.
63
63
const LAUNCHER_W: f64 = 92.0;
64
+/// Szerokość przycisku launchera aplikacji testowej (PaganDE demo).
65
+const DEMO_W: f64 = 74.0;
64
66
/// Maksymalna szerokość kafelka okna.
65
67
const TASK_MAX_W: f64 = 190.0;
66
68
/// Minimalna szerokość kafelka okna.
@@ -113,11 +115,14 @@ struct Panel {
113
115
tasks: Vec<Task>,
114
116
/// Indeks kafelka pod kursorem.
115
117
hover: Option<usize>,
116
- /// Czy kursor jest nad przyciskiem launchera.
118
+ /// Czy kursor jest nad przyciskiem launchera (terminal).
117
119
hover_launcher: bool,
120
+ /// Czy kursor jest nad przyciskiem launchera aplikacji testowej.
121
+ hover_demo: bool,
118
122
/// Ostatnia pozycja kursora w układzie powierzchni.
119
123
pointer_pos: (f64, f64),
120
124
launcher_rect: (f64, f64, f64, f64),
125
+ demo_rect: (f64, f64, f64, f64),
121
126
clock: String,
122
127
dirty: bool,
123
128
closed: bool,
@@ -195,7 +200,11 @@ impl Panel {
195
200
196
201
let pill_y = (height as f64 - PILL_H) / 2.0;
197
202
198
- // 1) Launcher.
203
+ // 1) Launcher: terminal („Pagan”) + aplikacja testowa („Demo”).
204
+ //
205
+ // PO CO dwa przyciski: pierwszy uruchamia terminal (codzienna praca),
206
+ // drugi naszą aplikację testową, żeby jednym kliknięciem sprawdzić
207
+ // dekoracje SSD i menu aplikacji.
199
208
let launcher_bg = if self.hover_launcher {
200
209
TASK_HOVER_BG
201
210
} else {
@@ -208,10 +217,21 @@ impl Panel {
208
217
ctx.set_font_size(13.0);
209
218
self.launcher_rect = (PAD, pill_y, LAUNCHER_W, PILL_H);
210
219
220
+ let demo_x = PAD + LAUNCHER_W + GAP;
221
+ let demo_bg = if self.hover_demo {
222
+ TASK_HOVER_BG
223
+ } else {
224
+ TASK_BG
225
+ };
226
+ draw_pill(&ctx, demo_x, pill_y, DEMO_W, PILL_H, demo_bg);
227
+ ctx.set_source_rgb(TEXT.0, TEXT.1, TEXT.2);
228
+ draw_centered_text(&ctx, "Demo", demo_x, pill_y, DEMO_W, PILL_H);
229
+ self.demo_rect = (demo_x, pill_y, DEMO_W, PILL_H);
230
+
211
231
// 2) Kafelki okien.
212
232
let clock_w = 64.0;
213
233
let right_limit = width as f64 - PAD - clock_w - GAP * 2.0;
214
- let mut x = PAD + LAUNCHER_W + GAP * 2.0;
234
+ let mut x = demo_x + DEMO_W + GAP * 2.0;
215
235
216
236
for (index, task) in self.tasks.iter_mut().enumerate() {
217
237
let label = truncate(&ctx, &task.title, TASK_MAX_W - 16.0);
@@ -273,6 +293,11 @@ impl Panel {
273
293
px >= x && px <= x + w && py >= y && py <= y + h
274
294
}
275
295
296
+ fn demo_contains(&self, (px, py): (f64, f64)) -> bool {
297
+ let (x, y, w, h) = self.demo_rect;
298
+ px >= x && px <= x + w && py >= y && py <= y + h
299
+ }
300
+
276
301
/// Kafelek pod danym punktem.
277
302
fn task_at(&self, (px, py): (f64, f64)) -> Option<usize> {
278
303
let pill_y = (self.size.1 as f64 - PILL_H) / 2.0;
@@ -287,9 +312,14 @@ impl Panel {
287
312
let point = self.pointer_pos;
288
313
let new_task = self.task_at(point);
289
314
let new_launcher = self.launcher_contains(point);
290
- if new_task != self.hover || new_launcher != self.hover_launcher {
315
+ let new_demo = self.demo_contains(point);
316
+ if new_task != self.hover
317
+ || new_launcher != self.hover_launcher
318
+ || new_demo != self.hover_demo
319
+ {
291
320
self.hover = new_task;
292
321
self.hover_launcher = new_launcher;
322
+ self.hover_demo = new_demo;
293
323
self.dirty = true;
294
324
self.redraw();
295
325
}
@@ -329,6 +359,62 @@ impl Panel {
329
359
}
330
360
warn!("nie znalazłem terminala (foot/kitty/xterm/…) — launcher nic nie zrobił");
331
361
}
362
+
363
+ /// Launcher aplikacji testowej PaganDE (nasze demo toolkitu).
364
+ ///
365
+ /// PO CO: jednym kliknięciem sprawdzamy dekoracje SSD, menu aplikacji
366
+ /// i wspólny wygląd okna. Kolejność szukania:
367
+ /// 1. `$PAGAN_DEMO_CMD` (jawne nadpisanie — najwygodniejsze w testach),
368
+ /// 2. nazwy w `PATH` (instalacja systemowa),
369
+ /// 3. układ deweloperski repozytorium (`pagan-toolkit/target/…/examples/demo`).
370
+ fn launch_demo(&self) {
371
+ if let Ok(command) = std::env::var("PAGAN_DEMO_CMD") {
372
+ if !command.trim().is_empty() {
373
+ match Command::new(&command).spawn() {
374
+ Ok(_) => {
375
+ info!(command, "uruchomiono aplikację demo (PAGAN_DEMO_CMD)");
376
+ return;
377
+ }
378
+ Err(err) => warn!(command, %err, "PAGAN_DEMO_CMD nie uruchomił się"),
379
+ }
380
+ }
381
+ }
382
+
383
+ let mut candidates: Vec<std::path::PathBuf> = Vec::new();
384
+ for name in ["pagan-demo", "pagan-toolkit-demo"] {
385
+ if which(name) {
386
+ candidates.push(name.into());
387
+ }
388
+ }
389
+ // Układ deweloperski: <repo>/pagan-panel/target/debug/pagan-panel,
390
+ // więc demo leży w <repo>/pagan-toolkit/target/debug/examples/demo.
391
+ if let Some(exe_dir) = std::env::current_exe()
392
+ .ok()
393
+ .and_then(|exe| exe.parent().map(|parent| parent.to_path_buf()))
394
+ {
395
+ let repo = exe_dir.join("..").join("..").join("..");
396
+ candidates.push(repo.join("pagan-toolkit/target/debug/examples/demo"));
397
+ candidates.push(repo.join("pagan-toolkit/target/release/examples/demo"));
398
+ }
399
+
400
+ for candidate in candidates {
401
+ if candidate.exists() {
402
+ match Command::new(&candidate).spawn() {
403
+ Ok(_) => {
404
+ info!(path = %candidate.display(), "uruchomiono aplikację demo");
405
+ return;
406
+ }
407
+ Err(err) => {
408
+ warn!(path = %candidate.display(), %err, "nie udało się uruchomić demo")
409
+ }
410
+ }
411
+ }
412
+ }
413
+ warn!(
414
+ "nie znalazłem aplikacji demo — ustaw PAGAN_DEMO_CMD na ścieżkę do \
415
+ pagan-toolkit/target/debug/examples/demo"
416
+ );
417
+ }
332
418
}
333
419
334
420
// --- Pomocnicze rysowanie ----------------------------------------------------
@@ -566,6 +652,8 @@ impl Dispatch<wl_pointer::WlPointer, ()> for Panel {
566
652
let point = state.pointer_pos;
567
653
if state.launcher_contains(point) {
568
654
state.launch_terminal();
655
+ } else if state.demo_contains(point) {
656
+ state.launch_demo();
569
657
} else if let Some(index) = state.task_at(point) {
570
658
match button {
571
659
0x112 => state.close_task(index),
@@ -755,8 +843,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
755
843
tasks: Vec::new(),
756
844
hover: None,
757
845
hover_launcher: false,
846
+ hover_demo: false,
758
847
pointer_pos: (0.0, 0.0),
759
848
launcher_rect: (0.0, 0.0, 0.0, 0.0),
849
+ demo_rect: (0.0, 0.0, 0.0, 0.0),
760
850
clock: String::new(),
761
851
dirty: false,
762
852
closed: false,