← luna

Commit cc7875e

3
files
+144
added
-19
deleted
@@ -227,13 +227,23 @@ fn main() -> Result<(), Box<dyn Error>> {
227 227 size.width,
228 228 size.height - THICKNESS,
229 229 );
230 - if let Some(i) = notes.hit(area, pe.position) {
231 - if let Some(n) = notes.get(i) {
232 - if let Some(bus) = &bus {
233 - if let Some(key) = n.actions.first() {
230 + if let Some((item, ak)) =
231 + notes.action_at(area, pe.position, &frame.theme, text)
232 + {
233 + // Klik w przycisk akcji: wywołaj akcję i zamknij toast.
234 + if let Some(bus) = &bus {
235 + if let Some(n) = notes.get(item) {
236 + if let Some((key, _)) = n.actions.get(ak) {
234 237 bus.signal_action(n.id, key);
235 238 }
236 - // 2 = zamknięte przez użytkownika.
239 + bus.signal_closed(n.id, 2);
240 + }
241 + }
242 + notes.remove(item);
243 + } else if let Some(i) = notes.hit(area, pe.position) {
244 + // Klik w treść toasta: zamknij (2 = przez użytkownika).
245 + if let Some(n) = notes.get(i) {
246 + if let Some(bus) = &bus {
237 247 bus.signal_closed(n.id, 2);
238 248 }
239 249 }
@@ -28,8 +28,8 @@ pub struct Incoming {
28 28 pub body: String,
29 29 /// Czas życia w ms: `-1` = domyślny serwera, `0` = trwałe, `> 0` = jawny.
30 30 pub expire_ms: i32,
31 - /// Klucze akcji (bez etykiet) do `ActionInvoked`.
32 - pub actions: Vec<String>,
31 + /// Pary `(klucz, etykieta)` akcji do `ActionInvoked`.
32 + pub actions: Vec<(String, String)>,
33 33 }
34 34
35 35 impl Incoming {
@@ -70,7 +70,10 @@ impl Server {
70 70 _hints: HashMap<String, OwnedValue>,
71 71 expire_ms: i32,
72 72 ) -> u32 {
73 - let keys: Vec<String> = actions.chunks_exact(2).map(|c| c[0].clone()).collect();
73 + let acts: Vec<(String, String)> = actions
74 + .chunks_exact(2)
75 + .map(|c| (c[0].clone(), c[1].clone()))
76 + .collect();
74 77 let id = {
75 78 let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
76 79 let id = q.iter().map(|i| i.id).max().unwrap_or(0) + 1;
@@ -80,7 +83,7 @@ impl Server {
80 83 summary,
81 84 body,
82 85 expire_ms,
83 - actions: keys,
86 + actions: acts,
84 87 });
85 88 id
86 89 };
@@ -5,7 +5,8 @@
5 5 //! `org.freedesktop.Notifications` to kolejny krok (patrz roadmapa).
6 6
7 7 use libluna_core::style::weight;
8 -use libluna_core::{Point, Rect};
8 +use libluna_core::{Point, Rect, Theme};
9 +use libluna_render::TextEngine;
9 10 use libluna_widgets::{paint_icon, IconName, PaintCtx};
10 11
11 12 /// Czas animacji wejścia/wyjścia (s).
@@ -13,9 +14,15 @@ const SLIDE: f64 = 0.22;
13 14 /// Szerokość pojedynczego powiadomienia.
14 15 pub const WIDTH: f64 = 340.0;
15 16 /// Wysokość pojedynczego powiadomienia.
16 -pub const HEIGHT: f64 = 76.0;
17 +pub const HEIGHT: f64 = 96.0;
17 18 /// Odstęp pionowy między powiadomieniami.
18 19 const GAP: f64 = 10.0;
20 +/// Wysokość przycisku akcji.
21 +const ACTION_H: f64 = 24.0;
22 +/// Margines przycisków akcji od krawędzi.
23 +const ACTION_PAD: f64 = 12.0;
24 +/// Odstęp między przyciskami akcji.
25 +const ACTION_GAP: f64 = 8.0;
19 26
20 27 /// Pojedyncze powiadomienie.
21 28 #[derive(Debug, Clone)]
@@ -30,8 +37,8 @@ pub struct Notification {
30 37 pub timeout: f64,
31 38 /// Identyfikator z magistrali D‑Bus (0 = powiadomienie lokalne).
32 39 pub id: u32,
33 - /// Klucze akcji (`ActionInvoked`); puste, gdy brak akcji.
34 - pub actions: Vec<String>,
40 + /// Klucze i etykiety akcji (`ActionInvoked`); puste, gdy brak akcji.
41 + pub actions: Vec<(String, String)>,
35 42 }
36 43
37 44 impl Notification {
@@ -58,8 +65,8 @@ impl Notification {
58 65 self
59 66 }
60 67
61 - /// Ustawia klucze akcji (do wysłania `ActionInvoked`).
62 - pub fn with_actions(mut self, actions: Vec<String>) -> Self {
68 + /// Ustawia akcje jako pary `(klucz, etykieta)` (do wysłania `ActionInvoked`).
69 + pub fn with_actions(mut self, actions: Vec<(String, String)>) -> Self {
63 70 self.actions = actions;
64 71 self
65 72 }
@@ -133,6 +140,54 @@ impl Notifications {
133 140 )
134 141 }
135 142
143 + /// Prostokąty przycisków akcji toasta `i` (od prawej do lewej, u dołu).
144 + pub fn action_rects(
145 + &self,
146 + area: Rect,
147 + i: usize,
148 + theme: &Theme,
149 + text: &TextEngine,
150 + ) -> Vec<Rect> {
151 + let Some((n, _)) = self.items.get(i) else {
152 + return Vec::new();
153 + };
154 + if n.actions.is_empty() {
155 + return Vec::new();
156 + }
157 + let rect = Self::rect_in(area, i);
158 + let cap = theme.typography.caption.size;
159 + let y = rect.bottom() - ACTION_PAD - ACTION_H;
160 + let mut right = rect.right() - ACTION_PAD;
161 + let mut out = vec![Rect::ZERO; n.actions.len()];
162 + for (k, (_, label)) in n.actions.iter().enumerate().rev() {
163 + let w = text.measure(label, cap, weight::MEDIUM).width + 2.0 * theme.spacing.sm;
164 + let r = Rect::from_xywh(right - w, y, w, ACTION_H);
165 + out[k] = r;
166 + right = r.x() - ACTION_GAP;
167 + }
168 + out
169 + }
170 +
171 + /// Trafienie przycisku akcji: `(indeks toasta, indeks akcji)`.
172 + pub fn action_at(
173 + &self,
174 + area: Rect,
175 + p: Point,
176 + theme: &Theme,
177 + text: &TextEngine,
178 + ) -> Option<(usize, usize)> {
179 + for i in (0..self.items.len()).rev() {
180 + if let Some(k) = self
181 + .action_rects(area, i, theme, text)
182 + .iter()
183 + .position(|r| r.contains(p))
184 + {
185 + return Some((i, k));
186 + }
187 + }
188 + None
189 + }
190 +
136 191 /// Rysuje stos powiadomień w obszarze `area` (od góry, do prawej).
137 192 pub fn paint(&self, ctx: &mut PaintCtx, area: Rect) {
138 193 let t = ctx.theme;
@@ -189,8 +244,29 @@ impl Notifications {
189 244 t.colors.dim_text.scale_alpha(a),
190 245 );
191 246
192 - // Pasek czasu (kurczy się).
193 - if n.timeout > 0.0 {
247 + // Przyciski akcji (np. „Otwórz”, „Odpisz”).
248 + if !n.actions.is_empty() {
249 + let mut acts = self.action_rects(area, i, t, ctx.text);
250 + for ((_, label), r) in n.actions.iter().zip(acts.iter_mut()) {
251 + *r = Rect::from_xywh(r.x() + slide, r.y(), r.width(), r.height());
252 + ctx.scene.fill_rounded_rect(
253 + *r,
254 + r.height() / 2.0,
255 + t.colors.accent.scale_alpha(a),
256 + );
257 + ctx.label_center(
258 + r.center().x,
259 + r.center().y,
260 + label,
261 + t.typography.caption.size,
262 + weight::MEDIUM,
263 + t.colors.accent_fg.scale_alpha(a),
264 + );
265 + }
266 + }
267 +
268 + // Pasek czasu (kurczy się) — tylko gdy nie ma akcji.
269 + if n.timeout > 0.0 && n.actions.is_empty() {
194 270 let frac = (1.0 - (*age / n.timeout)).clamp(0.0, 1.0);
195 271 let track = Rect::from_xywh(
196 272 rect.x() + pad,
@@ -251,7 +327,7 @@ mod tests {
251 327 n.push(
252 328 Notification::new("A", "t", "b")
253 329 .with_id(7)
254 - .with_actions(vec!["default".to_owned()]),
330 + .with_actions(vec![("default".to_owned(), "Otwórz".to_owned())]),
255 331 );
256 332 n.advance(0.3);
257 333 let area = Rect::from_xywh(0.0, 40.0, 1280.0, 200.0);
@@ -260,11 +336,47 @@ mod tests {
260 336 assert_eq!(n.hit(area, Point::new(-10.0, -10.0)), None);
261 337 let n0 = n.get(0).unwrap();
262 338 assert_eq!(n0.id, 7);
263 - assert_eq!(n0.actions, vec!["default".to_owned()]);
339 + assert_eq!(
340 + n0.actions,
341 + vec![("default".to_owned(), "Otwórz".to_owned())]
342 + );
264 343 n.remove(0);
265 344 assert!(n.is_empty());
266 345 }
267 346
347 + #[test]
348 + fn action_buttons_hit_test() {
349 + let theme = Theme::dark();
350 + let text = TextEngine::none();
351 + let area = Rect::from_xywh(0.0, 40.0, 1280.0, 300.0);
352 + let mut n = Notifications::new();
353 + n.push(Notification::new("A", "t", "b").with_actions(vec![
354 + ("open".to_owned(), "Otwórz".to_owned()),
355 + ("dismiss".to_owned(), "Zamknij".to_owned()),
356 + ]));
357 + n.advance(0.3);
358 + let rects = n.action_rects(area, 0, &theme, &text);
359 + assert_eq!(rects.len(), 2);
360 + assert_eq!(
361 + n.action_at(area, rects[0].center(), &theme, &text),
362 + Some((0, 0))
363 + );
364 + assert_eq!(
365 + n.action_at(area, rects[1].center(), &theme, &text),
366 + Some((0, 1))
367 + );
368 + // Środek toasta (poza przyciskami) nie jest trafieniem akcji.
369 + assert_eq!(
370 + n.action_at(
371 + area,
372 + Notifications::rect_in(area, 0).center(),
373 + &theme,
374 + &text
375 + ),
376 + None
377 + );
378 + }
379 +
268 380 #[test]
269 381 fn paints_without_window() {
270 382 let theme = Theme::dark();