fractal/components/avatar/
editable.rs1use 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#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
26#[repr(u32)]
27#[enum_type(name = "EditableAvatarState")]
28pub enum EditableAvatarState {
29 #[default]
31 Default = 0,
32 EditInProgress = 1,
34 EditSuccessful = 2,
36 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 #[property(get, set = Self::set_data, explicit_notify)]
66 data: BoundObject<AvatarData>,
67 #[property(get)]
69 image: BoundObjectWeakRef<AvatarImage>,
70 #[property(get, set = Self::set_editable, explicit_notify)]
72 editable: Cell<bool>,
73 #[property(get, set = Self::set_inhibit_remove, explicit_notify)]
75 inhibit_remove: Cell<bool>,
76 #[property(get, set = Self::set_state, explicit_notify, builder(EditableAvatarState::default()))]
78 state: Cell<EditableAvatarState>,
79 edit_state: Cell<ActionState>,
81 edit_sensitive: Cell<bool>,
83 remove_state: Cell<ActionState>,
85 remove_sensitive: Cell<bool>,
87 #[property(get)]
89 temp_paintable: RefCell<Option<gdk::Paintable>>,
90 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub(super) fn edit_state(&self) -> ActionState {
405 self.edit_state.get()
406 }
407
408 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 fn edit_sensitive(&self) -> bool {
419 self.edit_sensitive.get()
420 }
421
422 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 pub(super) fn remove_state(&self) -> ActionState {
433 self.remove_state.get()
434 }
435
436 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 fn remove_sensitive(&self) -> bool {
447 self.remove_sensitive.get()
448 }
449
450 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 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 pub(crate) fn reset(&self) {
475 self.imp().set_state(EditableAvatarState::Default);
476 }
477
478 pub(crate) fn edit_in_progress(&self) {
480 self.imp().set_state(EditableAvatarState::EditInProgress);
481 }
482
483 pub(crate) fn removal_in_progress(&self) {
485 self.imp().set_state(EditableAvatarState::RemovalInProgress);
486 }
487
488 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 imp.set_state(EditableAvatarState::Default);
499 }
500 }
501
502 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 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 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}