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();