← PaganDE

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,