fractal/components/avatar/
editable.rs1use 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#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
29#[repr(u32)]
30#[enum_type(name = "EditableAvatarState")]
31pub enum EditableAvatarState {
32 #[default]
34 Default = 0,
35 EditInProgress = 1,
37 EditSuccessful = 2,
39 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 #[property(get, set = Self::set_data, explicit_notify)]
69 data: BoundObject<AvatarData>,
70 #[property(get)]
72 image: BoundObjectWeakRef<AvatarImage>,
73 #[property(get, set = Self::set_editable, explicit_notify)]
75 editable: Cell<bool>,
76 #[property(get, set = Self::set_inhibit_remove, explicit_notify)]
78 inhibit_remove: Cell<bool>,
79 #[property(get, set = Self::set_state, explicit_notify, builder(EditableAvatarState::default()))]
81 state: Cell<EditableAvatarState>,
82 edit_state: Cell<ActionState>,
84 edit_sensitive: Cell<bool>,
86 remove_state: Cell<ActionState>,
88 remove_sensitive: Cell<bool>,
90 #[property(get)]
92 temp_paintable: RefCell<Option<gdk::Paintable>>,
93 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub(super) fn edit_state(&self) -> ActionState {
410 self.edit_state.get()
411 }
412
413 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 fn edit_sensitive(&self) -> bool {
424 self.edit_sensitive.get()
425 }
426
427 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 pub(super) fn remove_state(&self) -> ActionState {
438 self.remove_state.get()
439 }
440
441 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 fn remove_sensitive(&self) -> bool {
452 self.remove_sensitive.get()
453 }
454
455 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 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 pub(crate) fn reset(&self) {
479 self.imp().set_state(EditableAvatarState::Default);
480 }
481
482 pub(crate) fn edit_in_progress(&self) {
484 self.imp().set_state(EditableAvatarState::EditInProgress);
485 }
486
487 pub(crate) fn removal_in_progress(&self) {
489 self.imp().set_state(EditableAvatarState::RemovalInProgress);
490 }
491
492 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 imp.set_state(EditableAvatarState::Default);
503 }
504 }
505
506 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 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 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}