fractal/components/avatar/
editable.rs

1use std::time::Duration;
2
3use adw::subclass::prelude::*;
4use gettextrs::gettext;
5use gtk::{
6    gdk, gio, glib,
7    glib::{clone, closure, closure_local},
8    prelude::*,
9    CompositeTemplate,
10};
11use tracing::{debug, error};
12
13use super::{AvatarData, AvatarImage};
14use crate::{
15    components::{ActionButton, ActionState, AnimatedImagePaintable},
16    toast,
17    utils::{
18        expression,
19        media::{
20            image::{ImageError, IMAGE_QUEUE},
21            FrameDimensions,
22        },
23        BoundObject, BoundObjectWeakRef, CountedRef,
24    },
25};
26
27/// The state of the editable avatar.
28#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
29#[repr(u32)]
30#[enum_type(name = "EditableAvatarState")]
31pub enum EditableAvatarState {
32    /// Nothing is currently happening.
33    #[default]
34    Default = 0,
35    /// An edit is in progress.
36    EditInProgress = 1,
37    /// An edit was successful.
38    EditSuccessful = 2,
39    // A removal is in progress.
40    RemovalInProgress = 3,
41}
42
43mod imp {
44    use std::{
45        cell::{Cell, RefCell},
46        sync::LazyLock,
47    };
48
49    use glib::subclass::{InitializingObject, Signal};
50
51    use super::*;
52
53    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
54    #[template(resource = "/org/gnome/Fractal/ui/components/avatar/editable.ui")]
55    #[properties(wrapper_type = super::EditableAvatar)]
56    pub struct EditableAvatar {
57        #[template_child]
58        stack: TemplateChild<gtk::Stack>,
59        #[template_child]
60        temp_avatar: TemplateChild<adw::Avatar>,
61        #[template_child]
62        error_img: TemplateChild<gtk::Image>,
63        #[template_child]
64        button_remove: TemplateChild<ActionButton>,
65        #[template_child]
66        button_edit: TemplateChild<ActionButton>,
67        /// The [`AvatarData`] to display.
68        #[property(get, set = Self::set_data, explicit_notify)]
69        data: BoundObject<AvatarData>,
70        /// The avatar image to watch.
71        #[property(get)]
72        image: BoundObjectWeakRef<AvatarImage>,
73        /// Whether this avatar is changeable.
74        #[property(get, set = Self::set_editable, explicit_notify)]
75        editable: Cell<bool>,
76        /// Whether to prevent the remove button from showing.
77        #[property(get, set = Self::set_inhibit_remove, explicit_notify)]
78        inhibit_remove: Cell<bool>,
79        /// The current state of the edit.
80        #[property(get, set = Self::set_state, explicit_notify, builder(EditableAvatarState::default()))]
81        state: Cell<EditableAvatarState>,
82        /// The state of the avatar edit.
83        edit_state: Cell<ActionState>,
84        /// Whether the edit button is sensitive.
85        edit_sensitive: Cell<bool>,
86        /// The state of the avatar removal.
87        remove_state: Cell<ActionState>,
88        /// Whether the remove button is sensitive.
89        remove_sensitive: Cell<bool>,
90        /// A temporary paintable to show instead of the avatar.
91        #[property(get)]
92        temp_paintable: RefCell<Option<gdk::Paintable>>,
93        /// The error encountered when loading the temporary avatar, if any.
94        temp_error: Cell<Option<ImageError>>,
95        temp_paintable_animation_ref: RefCell<Option<CountedRef>>,
96    }
97
98    #[glib::object_subclass]
99    impl ObjectSubclass for EditableAvatar {
100        const NAME: &'static str = "EditableAvatar";
101        type Type = super::EditableAvatar;
102        type ParentType = adw::Bin;
103
104        fn class_init(klass: &mut Self::Class) {
105            Self::bind_template(klass);
106            klass.set_css_name("editable-avatar");
107
108            klass.install_action_async(
109                "editable-avatar.edit-avatar",
110                None,
111                |obj, _, _| async move {
112                    obj.choose_avatar().await;
113                },
114            );
115            klass.install_action("editable-avatar.remove-avatar", None, |obj, _, _| {
116                obj.emit_by_name::<()>("remove-avatar", &[]);
117            });
118        }
119
120        fn instance_init(obj: &InitializingObject<Self>) {
121            obj.init_template();
122        }
123    }
124
125    #[glib::derived_properties]
126    impl ObjectImpl for EditableAvatar {
127        fn signals() -> &'static [Signal] {
128            static SIGNALS: LazyLock<Vec<Signal>> = LazyLock::new(|| {
129                vec![
130                    Signal::builder("edit-avatar")
131                        .param_types([gio::File::static_type()])
132                        .build(),
133                    Signal::builder("remove-avatar").build(),
134                ]
135            });
136            SIGNALS.as_ref()
137        }
138
139        fn constructed(&self) {
140            self.parent_constructed();
141            let obj = self.obj();
142
143            self.button_remove
144                .set_extra_classes(&["destructive-action"]);
145
146            // Watch whether we can remove the avatar.
147            let image_present_expr = obj
148                .property_expression("data")
149                .chain_property::<AvatarData>("image")
150                .chain_property::<AvatarImage>("uri-string")
151                .chain_closure::<bool>(closure!(|_: Option<glib::Object>, uri: Option<String>| {
152                    uri.is_some()
153                }));
154
155            let editable_expr = obj.property_expression("editable");
156            let remove_not_inhibited_expr =
157                expression::not(obj.property_expression("inhibit-remove"));
158            let can_remove_expr = expression::and(editable_expr, remove_not_inhibited_expr);
159
160            let button_remove_visible = expression::and(can_remove_expr, image_present_expr);
161            button_remove_visible.bind(&*self.button_remove, "visible", glib::Object::NONE);
162
163            // Watch whether the temp avatar is mapped for animations.
164            self.temp_avatar.connect_map(clone!(
165                #[weak(rename_to = imp)]
166                self,
167                move |_| {
168                    imp.update_temp_paintable_state();
169                }
170            ));
171            self.temp_avatar.connect_unmap(clone!(
172                #[weak(rename_to = imp)]
173                self,
174                move |_| {
175                    imp.update_temp_paintable_state();
176                }
177            ));
178        }
179    }
180
181    impl WidgetImpl for EditableAvatar {}
182    impl BinImpl for EditableAvatar {}
183
184    impl EditableAvatar {
185        /// Set the [`AvatarData`] to display.
186        fn set_data(&self, data: Option<AvatarData>) {
187            if self.data.obj() == data {
188                return;
189            }
190
191            self.data.disconnect_signals();
192
193            if let Some(data) = data {
194                let image_handler = data.connect_image_notify(clone!(
195                    #[weak(rename_to = imp)]
196                    self,
197                    move |_| {
198                        imp.update_image();
199                    }
200                ));
201
202                self.data.set(data, vec![image_handler]);
203            }
204
205            self.update_image();
206            self.obj().notify_data();
207        }
208
209        /// Update the avatar image to watch.
210        fn update_image(&self) {
211            let image = self.data.obj().and_then(|data| data.image());
212
213            if self.image.obj() == image {
214                return;
215            }
216
217            self.image.disconnect_signals();
218
219            if let Some(image) = &image {
220                let error_handler = image.connect_error_changed(clone!(
221                    #[weak(rename_to = imp)]
222                    self,
223                    move |_| {
224                        imp.update_error();
225                    }
226                ));
227
228                self.image.set(image, vec![error_handler]);
229            }
230
231            self.update_error();
232            self.obj().notify_image();
233        }
234
235        /// Set whether this avatar is editable.
236        fn set_editable(&self, editable: bool) {
237            if self.editable.get() == editable {
238                return;
239            }
240
241            self.editable.set(editable);
242            self.obj().notify_editable();
243        }
244
245        /// Set whether to prevent the remove button from showing..
246        fn set_inhibit_remove(&self, inhibit: bool) {
247            if self.inhibit_remove.get() == inhibit {
248                return;
249            }
250
251            self.inhibit_remove.set(inhibit);
252            self.obj().notify_inhibit_remove();
253        }
254
255        /// Set the state of the edit.
256        pub(super) fn set_state(&self, state: EditableAvatarState) {
257            if self.state.get() == state {
258                return;
259            }
260
261            match state {
262                EditableAvatarState::Default => {
263                    self.show_temp_paintable(false);
264                    self.set_edit_state(ActionState::Default);
265                    self.set_edit_sensitive(true);
266                    self.set_remove_state(ActionState::Default);
267                    self.set_remove_sensitive(true);
268
269                    self.set_temp_paintable(Ok(None));
270                }
271                EditableAvatarState::EditInProgress => {
272                    self.show_temp_paintable(true);
273                    self.set_edit_state(ActionState::Loading);
274                    self.set_edit_sensitive(true);
275                    self.set_remove_state(ActionState::Default);
276                    self.set_remove_sensitive(false);
277                }
278                EditableAvatarState::EditSuccessful => {
279                    self.show_temp_paintable(false);
280                    self.set_edit_sensitive(true);
281                    self.set_remove_state(ActionState::Default);
282                    self.set_remove_sensitive(true);
283
284                    self.set_temp_paintable(Ok(None));
285
286                    // Animation for success.
287                    self.set_edit_state(ActionState::Success);
288                    glib::timeout_add_local_once(
289                        Duration::from_secs(2),
290                        clone!(
291                            #[weak(rename_to =imp)]
292                            self,
293                            move || {
294                                imp.set_state(EditableAvatarState::Default);
295                            }
296                        ),
297                    );
298                }
299                EditableAvatarState::RemovalInProgress => {
300                    self.show_temp_paintable(true);
301                    self.set_edit_state(ActionState::Default);
302                    self.set_edit_sensitive(false);
303                    self.set_remove_state(ActionState::Loading);
304                    self.set_remove_sensitive(true);
305                }
306            }
307
308            self.state.set(state);
309            self.obj().notify_state();
310        }
311
312        /// The dimensions of the avatar in this widget.
313        fn avatar_dimensions(&self) -> FrameDimensions {
314            let scale_factor = self.obj().scale_factor();
315            let avatar_size = self.temp_avatar.size();
316            let size = (avatar_size * scale_factor)
317                .try_into()
318                .expect("size and scale factor are positive integers");
319
320            FrameDimensions {
321                width: size,
322                height: size,
323            }
324        }
325
326        /// Load the temporary paintable from the given file.
327        pub(super) async fn set_temp_paintable_from_file(&self, file: gio::File) {
328            let handle = IMAGE_QUEUE
329                .add_file_request(file.into(), Some(self.avatar_dimensions()))
330                .await;
331            let paintable = handle.await.map(|image| Some(image.into()));
332            self.set_temp_paintable(paintable);
333        }
334
335        /// Set the temporary paintable.
336        fn set_temp_paintable(&self, paintable: Result<Option<gdk::Paintable>, ImageError>) {
337            let (paintable, error) = match paintable {
338                Ok(paintable) => (paintable, None),
339                Err(error) => (None, Some(error)),
340            };
341
342            if *self.temp_paintable.borrow() == paintable {
343                return;
344            }
345
346            self.temp_paintable.replace(paintable);
347
348            self.update_temp_paintable_state();
349            self.set_temp_error(error);
350            self.obj().notify_temp_paintable();
351        }
352
353        /// Show the temporary paintable instead of the current avatar.
354        fn show_temp_paintable(&self, show: bool) {
355            let child_name = if show { "temp" } else { "default" };
356            self.stack.set_visible_child_name(child_name);
357            self.update_error();
358        }
359
360        /// Update the state of the temp paintable.
361        fn update_temp_paintable_state(&self) {
362            self.temp_paintable_animation_ref.take();
363
364            let Some(paintable) = self
365                .temp_paintable
366                .borrow()
367                .clone()
368                .and_downcast::<AnimatedImagePaintable>()
369            else {
370                return;
371            };
372
373            if self.temp_avatar.is_mapped() {
374                self.temp_paintable_animation_ref
375                    .replace(Some(paintable.animation_ref()));
376            }
377        }
378
379        /// Set the error encountered when loading the temporary avatar, if any.
380        fn set_temp_error(&self, error: Option<ImageError>) {
381            if self.temp_error.get() == error {
382                return;
383            }
384
385            self.temp_error.set(error);
386
387            self.update_error();
388        }
389
390        /// Update the error that is displayed.
391        fn update_error(&self) {
392            let error = if self
393                .stack
394                .visible_child_name()
395                .is_some_and(|name| name == "default")
396            {
397                self.image.obj().and_then(|image| image.error())
398            } else {
399                self.temp_error.get()
400            };
401
402            if let Some(error) = error {
403                self.error_img.set_tooltip_text(Some(&error.to_string()));
404            }
405            self.error_img.set_visible(error.is_some());
406        }
407
408        /// The state of the avatar edit.
409        pub(super) fn edit_state(&self) -> ActionState {
410            self.edit_state.get()
411        }
412
413        /// Set the state of the avatar edit.
414        fn set_edit_state(&self, state: ActionState) {
415            if self.edit_state() == state {
416                return;
417            }
418
419            self.edit_state.set(state);
420        }
421
422        /// Whether the edit button is sensitive.
423        fn edit_sensitive(&self) -> bool {
424            self.edit_sensitive.get()
425        }
426
427        /// Set whether the edit button is sensitive.
428        fn set_edit_sensitive(&self, sensitive: bool) {
429            if self.edit_sensitive() == sensitive {
430                return;
431            }
432
433            self.edit_sensitive.set(sensitive);
434        }
435
436        /// The state of the avatar removal.
437        pub(super) fn remove_state(&self) -> ActionState {
438            self.remove_state.get()
439        }
440
441        /// Set the state of the avatar removal.
442        fn set_remove_state(&self, state: ActionState) {
443            if self.remove_state() == state {
444                return;
445            }
446
447            self.remove_state.set(state);
448        }
449
450        /// Whether the remove button is sensitive.
451        fn remove_sensitive(&self) -> bool {
452            self.remove_sensitive.get()
453        }
454
455        /// Set whether the remove button is sensitive.
456        fn set_remove_sensitive(&self, sensitive: bool) {
457            if self.remove_sensitive() == sensitive {
458                return;
459            }
460
461            self.remove_sensitive.set(sensitive);
462        }
463    }
464}
465
466glib::wrapper! {
467    /// An `Avatar` that can be edited.
468    pub struct EditableAvatar(ObjectSubclass<imp::EditableAvatar>)
469        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
470}
471
472impl EditableAvatar {
473    pub fn new() -> Self {
474        glib::Object::new()
475    }
476
477    /// Reset the state of the avatar.
478    pub(crate) fn reset(&self) {
479        self.imp().set_state(EditableAvatarState::Default);
480    }
481
482    /// Show that an edit is in progress.
483    pub(crate) fn edit_in_progress(&self) {
484        self.imp().set_state(EditableAvatarState::EditInProgress);
485    }
486
487    /// Show that a removal is in progress.
488    pub(crate) fn removal_in_progress(&self) {
489        self.imp().set_state(EditableAvatarState::RemovalInProgress);
490    }
491
492    /// Show that the current ongoing action was successful.
493    ///
494    /// This is has no effect if no action is ongoing.
495    pub(crate) fn success(&self) {
496        let imp = self.imp();
497        if imp.edit_state() == ActionState::Loading {
498            imp.set_state(EditableAvatarState::EditSuccessful);
499        } else if imp.remove_state() == ActionState::Loading {
500            // The remove button is hidden as soon as the avatar is gone so we
501            // don't need a state when it succeeds.
502            imp.set_state(EditableAvatarState::Default);
503        }
504    }
505
506    /// Choose a new avatar.
507    pub(super) async fn choose_avatar(&self) {
508        let filters = gio::ListStore::new::<gtk::FileFilter>();
509
510        let image_filter = gtk::FileFilter::new();
511        image_filter.set_name(Some(&gettext("Images")));
512        image_filter.add_mime_type("image/*");
513        filters.append(&image_filter);
514
515        let dialog = gtk::FileDialog::builder()
516            .title(gettext("Choose Avatar"))
517            .modal(true)
518            .accept_label(gettext("Choose"))
519            .filters(&filters)
520            .build();
521
522        let file = match dialog
523            .open_future(self.root().and_downcast_ref::<gtk::Window>())
524            .await
525        {
526            Ok(file) => file,
527            Err(error) => {
528                if error.matches(gtk::DialogError::Dismissed) {
529                    debug!("File dialog dismissed by user");
530                } else {
531                    error!("Could not open avatar file: {error:?}");
532                    toast!(self, gettext("Could not open avatar file"));
533                }
534                return;
535            }
536        };
537
538        if let Some(content_type) = file
539            .query_info_future(
540                gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
541                gio::FileQueryInfoFlags::NONE,
542                glib::Priority::LOW,
543            )
544            .await
545            .ok()
546            .and_then(|info| info.content_type())
547        {
548            if gio::content_type_is_a(&content_type, "image/*") {
549                self.imp().set_temp_paintable_from_file(file.clone()).await;
550                self.emit_by_name::<()>("edit-avatar", &[&file]);
551            } else {
552                error!("Expected an image, got {content_type}");
553                toast!(self, gettext("The chosen file is not an image"));
554            }
555        } else {
556            error!("Could not get the content type of the file");
557            toast!(
558                self,
559                gettext("Could not determine the type of the chosen file")
560            );
561        }
562    }
563
564    /// Connect to the signal emitted when a new avatar is selected.
565    pub fn connect_edit_avatar<F: Fn(&Self, gio::File) + 'static>(
566        &self,
567        f: F,
568    ) -> glib::SignalHandlerId {
569        self.connect_closure(
570            "edit-avatar",
571            true,
572            closure_local!(|obj: Self, file: gio::File| {
573                f(&obj, file);
574            }),
575        )
576    }
577
578    /// Connect to the signal emitted when the avatar is removed.
579    pub fn connect_remove_avatar<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
580        self.connect_closure(
581            "remove-avatar",
582            true,
583            closure_local!(|obj: Self| {
584                f(&obj);
585            }),
586        )
587    }
588}