Skip to main content

fractal/components/avatar/
editable.rs

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