fractal/utils/
notifications.rs

1use std::hash::Hasher;
2
3use djb_hash::{x33a_u32::X33aU32, HasherU32};
4use gtk::{gdk, glib, graphene, gsk, pango, prelude::*};
5
6/// The notification icon size, according to GNOME Shell's code.
7const NOTIFICATION_ICON_SIZE: i32 = 48;
8
9/// The colors for avatars, according to libadwaita.
10// From: https://gitlab.gnome.org/GNOME/libadwaita/-/blob/817dcaa883a7a366a266c9cd626b2cf2b21e5919/src/stylesheet/widgets/_avatar.scss#L10
11const AVATAR_COLOR_LIST: [(&str, &str, &str); 14] = [
12    ("#cfe1f5", "#83b6ec", "#337fdc"), // blue
13    ("#caeaf2", "#7ad9f1", "#0f9ac8"), // cyan
14    ("#cef8d8", "#8de6b1", "#29ae74"), // green
15    ("#e6f9d7", "#b5e98a", "#6ab85b"), // lime
16    ("#f9f4e1", "#f8e359", "#d29d09"), // yellow
17    ("#ffead1", "#ffcb62", "#d68400"), // gold
18    ("#ffe5c5", "#ffa95a", "#ed5b00"), // orange
19    ("#f8d2ce", "#f78773", "#e62d42"), // raspberry
20    ("#fac7de", "#e973ab", "#e33b6a"), // magenta
21    ("#e7c2e8", "#cb78d4", "#9945b5"), // purple
22    ("#d5d2f5", "#9e91e8", "#7a59ca"), // violet
23    ("#f2eade", "#e3cf9c", "#b08952"), // beige
24    ("#e5d6ca", "#be916d", "#785336"), // brown
25    ("#d8d7d3", "#c0bfbc", "#6e6d71"), // gray
26];
27
28/// Generate a notification icon from the given paintable.
29pub(crate) fn paintable_as_notification_icon(
30    paintable: &gdk::Paintable,
31    scale_factor: i32,
32    renderer: &gsk::Renderer,
33) -> gdk::Texture {
34    let img_width = f64::from(paintable.intrinsic_width());
35    let img_height = f64::from(paintable.intrinsic_height());
36
37    let mut icon_size = f64::from(NOTIFICATION_ICON_SIZE * scale_factor);
38    let mut snap_width = img_width;
39    let mut snap_height = img_height;
40    let mut x_pos = 0.0;
41    let mut y_pos = 0.0;
42
43    if img_width > img_height {
44        // Make the height fit the icon size without distorting the image, but
45        // don't upscale it.
46        if img_height > icon_size {
47            snap_height = icon_size;
48            snap_width = img_width * icon_size / img_height;
49        } else {
50            icon_size = img_height;
51        }
52
53        // Center the clip horizontally.
54        if snap_width > icon_size {
55            x_pos = ((snap_width - icon_size) / 2.0) as f32;
56        }
57    } else {
58        // Make the width fit the icon size without distorting the image, but
59        // don't upscale it.
60        if img_width > icon_size {
61            snap_width = icon_size;
62            snap_height = img_height * icon_size / img_width;
63        } else {
64            icon_size = img_width;
65        }
66
67        // Center the clip vertically.
68        if snap_height > icon_size {
69            y_pos = ((snap_height - icon_size) / 2.0) as f32;
70        }
71    }
72
73    let icon_size = icon_size as f32;
74    let snapshot = gtk::Snapshot::new();
75
76    // Clip the avatar in a circle.
77    let bounds = gsk::RoundedRect::from_rect(
78        graphene::Rect::new(x_pos, y_pos, icon_size, icon_size),
79        icon_size / 2.0,
80    );
81    snapshot.push_rounded_clip(&bounds);
82
83    paintable.snapshot(&snapshot, snap_width, snap_height);
84
85    snapshot.pop();
86
87    // Render the avatar.
88    let node = snapshot
89        .to_node()
90        .expect("snapshot should convert to a node successfully");
91    renderer.render_texture(node, None)
92}
93
94/// Generate a notification icon from a string.
95///
96/// This should match the behavior of `AdwAvatar`.
97pub(crate) fn string_as_notification_icon(
98    string: &str,
99    scale_factor: i32,
100    layout: &pango::Layout,
101    renderer: &gsk::Renderer,
102) -> gdk::Texture {
103    // Get the avatar colors from the string hash.
104    let mut hasher = X33aU32::new();
105    hasher.write(string.as_bytes());
106    let color_nb = hasher.finish_u32() as usize % AVATAR_COLOR_LIST.len();
107    let colors = AVATAR_COLOR_LIST[color_nb];
108
109    let icon_size = (NOTIFICATION_ICON_SIZE * scale_factor) as f32;
110    let snapshot = gtk::Snapshot::new();
111
112    // Clip the avatar in a circle.
113    let bounds = gsk::RoundedRect::from_rect(
114        graphene::Rect::new(0.0, 0.0, icon_size, icon_size),
115        icon_size / 2.0,
116    );
117    snapshot.push_rounded_clip(&bounds);
118
119    // Construct linear gradient background.
120    snapshot.append_linear_gradient(
121        &graphene::Rect::new(0.0, 0.0, icon_size, icon_size),
122        &graphene::Point::new(0.0, 0.0),
123        &graphene::Point::new(0.0, icon_size),
124        &[
125            gsk::ColorStop::new(
126                0.0,
127                gdk::RGBA::parse(colors.1).expect("hex color should parse successfully"),
128            ),
129            gsk::ColorStop::new(
130                1.0,
131                gdk::RGBA::parse(colors.2).expect("hex color should parse successfully"),
132            ),
133        ],
134    );
135
136    snapshot.pop();
137
138    // Add initials.
139    // Logic copied from: https://gitlab.gnome.org/GNOME/libadwaita/-/blob/817dcaa883a7a366a266c9cd626b2cf2b21e5919/src/adw-avatar.c#L85
140    let normalized = glib::normalize(string, glib::NormalizeMode::DefaultCompose);
141    let first_initial = normalized
142        .chars()
143        .next()
144        .and_then(|c| c.to_uppercase().next());
145    let last_initial = normalized
146        .rfind(' ')
147        .and_then(|idx| (idx != normalized.len()).then(|| &normalized[idx + 1..]))
148        .and_then(|s| s.chars().next())
149        .and_then(|c| c.to_uppercase().next());
150    let initials = first_initial
151        .into_iter()
152        .chain(last_initial)
153        .collect::<String>();
154    layout.set_text(&initials);
155
156    // Set the proper weight and size.
157    if let Some(mut font_description) = layout
158        .font_description()
159        .or_else(|| layout.context().font_description())
160    {
161        font_description.set_weight(pango::Weight::Bold);
162        font_description.set_size(18 * scale_factor * pango::SCALE);
163        layout.set_font_description(Some(&font_description));
164    }
165
166    // Center the layout horizontally.
167    layout.set_width(icon_size as i32 * pango::SCALE);
168    layout.set_alignment(pango::Alignment::Center);
169
170    // Center the layout vertically.
171    let (_, lay_height) = layout.pixel_size();
172    let lay_baseline = layout.baseline() / pango::SCALE;
173    // This is not really a padding but the layout reports a bigger height than
174    // it seems to take and this seems like a good approximation.
175    let lay_padding = lay_height - lay_baseline;
176    let pos_y = (icon_size - lay_height as f32 - lay_padding as f32) / 2.0;
177    snapshot.translate(&graphene::Point::new(0.0, pos_y));
178
179    snapshot.append_layout(
180        layout,
181        &gdk::RGBA::parse(colors.0).expect("hex color should parse successfully"),
182    );
183
184    // Render the avatar.
185    let node = snapshot
186        .to_node()
187        .expect("snapshot should convert to a node successfully");
188    renderer.render_texture(node, None)
189}