diff --git a/src/components/mod.rs b/src/components/mod.rs index b865679a..d205d91c 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -13,7 +13,7 @@ mod label_with_widgets; mod loading_row; mod location_viewer; mod media_content_viewer; -mod overlapping_box; +mod overlapping_avatars; mod pill; mod reaction_chooser; mod room_title; @@ -40,7 +40,7 @@ pub use self::{ loading_row::{LoadingRow, LoadingState}, location_viewer::LocationViewer, media_content_viewer::{ContentType, MediaContentViewer}, - overlapping_box::OverlappingBox, + overlapping_avatars::OverlappingAvatars, pill::Pill, reaction_chooser::ReactionChooser, room_title::RoomTitle, diff --git a/src/components/overlapping_avatars.rs b/src/components/overlapping_avatars.rs new file mode 100644 index 00000000..429739f7 --- /dev/null +++ b/src/components/overlapping_avatars.rs @@ -0,0 +1,323 @@ +use adw::prelude::*; +use gtk::{gdk, gio, glib, glib::clone, subclass::prelude::*}; + +use super::Avatar; +use crate::{session::model::AvatarData, utils::BoundObject}; + +/// Compute the overlap according to the child's size. +fn overlap(for_size: i32) -> i32 { + // Make the overlap a little less than half the size of the avatar. + (for_size as f64 / 2.5) as i32 +} + +pub type ExtractAvatarDataFn = dyn Fn(&glib::Object) -> AvatarData + 'static; + +mod imp { + use std::cell::{Cell, RefCell}; + + use super::*; + + #[derive(Default)] + pub struct OverlappingAvatars { + /// The child avatars. + pub avatars: RefCell>, + + /// The size of the avatars. + pub avatar_size: Cell, + + /// The maximum number of avatars to display. + /// + /// `0` means that all avatars are displayed. + pub max_avatars: Cell, + + /// The list model that is bound, if any. + pub bound_model: BoundObject, + + /// The method used to extract `AvatarData` from the items of the list + /// model, if any. + pub extract_avatar_data_fn: RefCell>>, + } + + #[glib::object_subclass] + impl ObjectSubclass for OverlappingAvatars { + const NAME: &'static str = "OverlappingAvatars"; + type Type = super::OverlappingAvatars; + type ParentType = gtk::Widget; + + fn class_init(klass: &mut Self::Class) { + klass.set_accessible_role(gtk::AccessibleRole::Img); + } + } + + impl ObjectImpl for OverlappingAvatars { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecInt::builder("avatar-size") + .explicit_notify() + .build(), + glib::ParamSpecUInt::builder("max-avatars") + .explicit_notify() + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + let obj = self.obj(); + + match pspec.name() { + "avatar-size" => obj.set_avatar_size(value.get().unwrap()), + "max-avatars" => obj.set_max_avatars(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let obj = self.obj(); + + match pspec.name() { + "avatar-size" => obj.avatar_size().to_value(), + "max-avatars" => obj.max_avatars().to_value(), + _ => unimplemented!(), + } + } + + fn dispose(&self) { + for avatar in self.avatars.take() { + avatar.unparent(); + } + + self.bound_model.disconnect_signals(); + } + } + + impl WidgetImpl for OverlappingAvatars { + fn measure(&self, orientation: gtk::Orientation, _for_size: i32) -> (i32, i32, i32, i32) { + let mut size = 0; + // child_size = avatar_size + cutout_borders + let child_size = self.avatar_size.get() + 2; + + if orientation == gtk::Orientation::Vertical { + if self.avatars.borrow().is_empty() { + return (0, 0, -1, 1); + } else { + return (child_size, child_size, -1, -1); + } + } + + let overlap = overlap(child_size); + + for avatar in self.avatars.borrow().iter() { + if !avatar.should_layout() { + continue; + } + + size += child_size - overlap; + } + + // The last child doesn't have an overlap. + if size > 0 { + size += overlap; + } + + (size, size, -1, -1) + } + + fn size_allocate(&self, _width: i32, _height: i32, _baseline: i32) { + let mut pos = 0; + // child_size = avatar_size + cutout_borders + let child_size = self.avatar_size.get() + 2; + let overlap = overlap(child_size); + + for avatar in self.avatars.borrow().iter() { + if !avatar.should_layout() { + continue; + } + + let x = pos; + pos += child_size - overlap; + + let allocation = gdk::Rectangle::new(x, 0, child_size, child_size); + + avatar.size_allocate(&allocation, -1); + } + } + } + + impl AccessibleImpl for OverlappingAvatars { + fn first_accessible_child(&self) -> Option { + // Hide the children in the a11y tree. + None + } + } +} + +glib::wrapper! { + /// A horizontal list of overlapping avatars. + pub struct OverlappingAvatars(ObjectSubclass) + @extends gtk::Widget, @implements gtk::Accessible; +} + +impl OverlappingAvatars { + /// Create an empty `OverlappingAvatars`. + pub fn new() -> Self { + glib::Object::new() + } + + /// The size of the avatars. + pub fn avatar_size(&self) -> i32 { + self.imp().avatar_size.get() + } + + /// Set the size of the avatars. + pub fn set_avatar_size(&self, size: i32) { + if self.avatar_size() == size { + return; + } + let imp = self.imp(); + + imp.avatar_size.set(size); + self.notify("avatar-size"); + + // Update the sizes of the avatars. + for avatar in imp + .avatars + .borrow() + .iter() + .filter_map(|bin| bin.child().and_downcast::()) + { + avatar.set_size(size); + } + self.queue_resize(); + } + + /// The maximum number of avatars to display. + /// + /// `0` means that all avatars are displayed. + pub fn max_avatars(&self) -> u32 { + self.imp().max_avatars.get() + } + + /// Set the maximum number of avatars to display. + pub fn set_max_avatars(&self, max_avatars: u32) { + let old_max_avatars = self.max_avatars(); + + if old_max_avatars == max_avatars { + return; + } + + let imp = self.imp(); + imp.max_avatars.set(max_avatars); + self.notify("max-avatars"); + + if max_avatars != 0 && imp.avatars.borrow().len() > max_avatars as usize { + // We have more children than we should, remove them. + let children = imp.avatars.borrow_mut().split_off(max_avatars as usize); + for widget in children { + widget.unparent() + } + } else if max_avatars == 0 || (old_max_avatars != 0 && max_avatars > old_max_avatars) { + let Some(model) = imp.bound_model.obj() else { + return; + }; + + let diff = model.n_items() - old_max_avatars; + if diff > 0 { + // We could have more children, create them. + self.handle_items_changed(&model, old_max_avatars, 0, diff); + } + } + + self.notify("max-avatars") + } + + /// Bind a `ListModel` to this list. + pub fn bind_model AvatarData + 'static>( + &self, + model: Option>, + extract_avatar_data_fn: P, + ) { + let imp = self.imp(); + + imp.bound_model.disconnect_signals(); + for avatar in imp.avatars.take() { + avatar.unparent(); + } + imp.extract_avatar_data_fn.take(); + + let Some(model) = model else { + return; + }; + + let signal_handler_id = model.connect_items_changed( + clone!(@weak self as obj => move |model, position, removed, added| { + obj.handle_items_changed(model, position, removed, added) + }), + ); + + imp.bound_model + .set(model.clone().upcast(), vec![signal_handler_id]); + + imp.extract_avatar_data_fn + .replace(Some(Box::new(extract_avatar_data_fn))); + + self.handle_items_changed(&model, 0, 0, model.n_items()) + } + + fn handle_items_changed( + &self, + model: &impl glib::IsA, + position: u32, + mut removed: u32, + added: u32, + ) { + let max_avatars = self.max_avatars(); + if max_avatars != 0 && position >= max_avatars { + // No changes here. + return; + } + + let imp = self.imp(); + let mut avatars = imp.avatars.borrow_mut(); + let avatar_size = self.avatar_size(); + let extract_avatar_data_fn_borrow = imp.extract_avatar_data_fn.borrow(); + let extract_avatar_data_fn = extract_avatar_data_fn_borrow.as_ref().unwrap(); + + while removed > 0 { + if position as usize >= avatars.len() { + break; + } + + let avatar = avatars.remove(position as usize); + avatar.unparent(); + removed -= 1; + } + + for i in position..(position + added) { + if max_avatars != 0 && i >= max_avatars { + break; + } + + let item = model.item(i).unwrap(); + let avatar_data = extract_avatar_data_fn(&item); + + let avatar = Avatar::new(); + avatar.set_data(Some(avatar_data)); + avatar.set_size(avatar_size); + + let cutout = adw::Bin::builder() + .child(&avatar) + .css_classes(["cutout"]) + .build(); + cutout.set_parent(self); + + avatars.insert(i as usize, cutout); + } + + self.queue_resize(); + } +} diff --git a/src/components/overlapping_box.rs b/src/components/overlapping_box.rs deleted file mode 100644 index 3a3126f7..00000000 --- a/src/components/overlapping_box.rs +++ /dev/null @@ -1,349 +0,0 @@ -use gtk::{gdk, gio, glib, glib::clone, prelude::*, subclass::prelude::*}; - -use crate::utils::BoundObjectWeakRef; - -pub type CreateWidgetFromObjectFn = dyn Fn(&glib::Object) -> gtk::Widget + 'static; - -mod imp { - use std::cell::{Cell, RefCell}; - - use super::*; - - pub struct OverlappingBox { - /// The child widgets. - pub widgets: RefCell>, - - /// The size of the widgets. - pub widgets_sizes: RefCell>, - - /// The maximum number of children to display. - /// - /// `0` means that all children are displayed. - pub max_children: Cell, - - /// The size by which the widgets overlap. - pub overlap: Cell, - - /// The orientation of the box. - pub orientation: Cell, - - /// The list model that is bound, if any. - pub bound_model: BoundObjectWeakRef, - - /// The method used to create widgets from the items of the list model, - /// if any. - pub create_widget_func: RefCell>>, - } - - impl Default for OverlappingBox { - fn default() -> Self { - Self { - widgets: Default::default(), - widgets_sizes: Default::default(), - max_children: Default::default(), - overlap: Default::default(), - orientation: gtk::Orientation::Horizontal.into(), - bound_model: Default::default(), - create_widget_func: Default::default(), - } - } - } - - #[glib::object_subclass] - impl ObjectSubclass for OverlappingBox { - const NAME: &'static str = "OverlappingBox"; - type Type = super::OverlappingBox; - type ParentType = gtk::Widget; - type Interfaces = (gtk::Buildable, gtk::Orientable); - } - - impl ObjectImpl for OverlappingBox { - fn properties() -> &'static [glib::ParamSpec] { - use once_cell::sync::Lazy; - static PROPERTIES: Lazy> = Lazy::new(|| { - vec![ - glib::ParamSpecUInt::builder("max-children") - .explicit_notify() - .build(), - glib::ParamSpecUInt::builder("overlap") - .explicit_notify() - .build(), - glib::ParamSpecOverride::for_interface::("orientation"), - ] - }); - - PROPERTIES.as_ref() - } - - fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { - let obj = self.obj(); - - match pspec.name() { - "max-children" => obj.set_max_children(value.get().unwrap()), - "overlap" => obj.set_overlap(value.get().unwrap()), - "orientation" => obj.set_orientation(value.get().unwrap()), - _ => unimplemented!(), - } - } - - fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { - let obj = self.obj(); - - match pspec.name() { - "max-children" => obj.max_children().to_value(), - "overlap" => obj.overlap().to_value(), - "orientation" => obj.orientation().to_value(), - _ => unimplemented!(), - } - } - - fn dispose(&self) { - for widget in self.widgets.borrow().iter() { - widget.unparent(); - } - - self.bound_model.disconnect_signals(); - } - } - - impl WidgetImpl for OverlappingBox { - fn measure(&self, orientation: gtk::Orientation, _for_size: i32) -> (i32, i32, i32, i32) { - let mut size = 0; - let overlap = self.overlap.get() as i32; - let self_orientation = self.obj().orientation(); - - for child in self.widgets.borrow().iter() { - if !child.should_layout() { - continue; - } - - let (_, child_size, ..) = child.measure(orientation, -1); - - if orientation == self_orientation { - size += child_size - overlap; - } else { - size = size.max(child_size); - } - } - - if orientation == self_orientation { - // The last child doesn't have an overlap. - if size > 0 { - size += overlap; - } - } - - (size, size, -1, -1) - } - - fn size_allocate(&self, width: i32, height: i32, _baseline: i32) { - let overlap = self.overlap.get() as i32; - let self_orientation = self.obj().orientation(); - let mut pos = 0; - - for child in self.widgets.borrow().iter() { - if !child.should_layout() { - continue; - } - - let (_, child_height, ..) = child.measure(gtk::Orientation::Vertical, -1); - let (_, child_width, ..) = child.measure(gtk::Orientation::Horizontal, -1); - - let (x, y) = if self_orientation == gtk::Orientation::Horizontal { - let x = pos; - pos += child_width - overlap; - - // Center the child on the opposite orientation. - let y = (height - child_height) / 2; - - (x, y) - } else { - let y = pos; - pos += child_height - overlap; - - // Center the child on the opposite orientation. - let x = (width - child_width) / 2; - (y, x) - }; - - let allocation = gdk::Rectangle::new(x, y, child_width, child_height); - - child.size_allocate(&allocation, -1); - } - } - } - - impl BuildableImpl for OverlappingBox {} - - impl OrientableImpl for OverlappingBox {} -} - -glib::wrapper! { - /// A box that has multiple widgets overlapping. - /// - /// Note that this works only with children with a fixed size. - pub struct OverlappingBox(ObjectSubclass) - @extends gtk::Widget, - @implements gtk::Accessible, gtk::Buildable, gtk::Orientable; -} - -impl OverlappingBox { - /// Create an empty `OverlappingBox`. - pub fn new() -> Self { - glib::Object::new() - } - - /// The maximum number of children to display. - /// - /// `0` means that all children are displayed. - pub fn max_children(&self) -> u32 { - self.imp().max_children.get() - } - - /// Set the maximum number of children to display. - pub fn set_max_children(&self, max_children: u32) { - let old_max_children = self.max_children(); - - if old_max_children == max_children { - return; - } - - let imp = self.imp(); - imp.max_children.set(max_children); - self.notify("max-children"); - - if max_children != 0 && self.children_nb() > max_children as usize { - // We have more children than we should, remove them. - let children = imp.widgets.borrow_mut().split_off(max_children as usize); - for widget in children { - widget.unparent() - } - } else if max_children == 0 || (old_max_children != 0 && max_children > old_max_children) { - let Some(model) = imp.bound_model.obj() else { - return; - }; - - let diff = model.n_items() - old_max_children; - if diff > 0 { - // We could have more children, create them. - self.handle_items_changed(&model, old_max_children, 0, diff); - } - } - } - - /// The size by which the widgets overlap. - pub fn overlap(&self) -> u32 { - self.imp().overlap.get() - } - - /// Set the size by which the widgets overlap. - pub fn set_overlap(&self, overlap: u32) { - if self.overlap() == overlap { - return; - } - - self.imp().overlap.set(overlap); - self.notify("overlap"); - self.queue_resize(); - } - - /// The orientation of the box. - pub fn orientation(&self) -> gtk::Orientation { - self.imp().orientation.get() - } - - /// Set the orientation of the box. - pub fn set_orientation(&self, orientation: gtk::Orientation) { - if self.orientation() == orientation { - return; - } - - self.imp().orientation.set(orientation); - self.notify("orientation"); - self.queue_resize(); - } - - /// The number of children in this box. - pub fn children_nb(&self) -> usize { - self.imp().widgets.borrow().len() - } - - /// Bind a `ListModel` to this box. - /// - /// The contents of the box are cleared and then filled with widgets that - /// represent items from the model. The box is updated whenever the model - /// changes. If the model is `None`, the box is left empty. - pub fn bind_model gtk::Widget + 'static>( - &self, - model: Option<&impl glib::IsA>, - create_widget_func: P, - ) { - let imp = self.imp(); - - imp.bound_model.disconnect_signals(); - for child in imp.widgets.take() { - child.unparent(); - } - imp.create_widget_func.take(); - - let Some(model) = model else { - return; - }; - - let signal_handler_id = model.connect_items_changed( - clone!(@weak self as obj => move |model, position, removed, added| { - obj.handle_items_changed(model, position, removed, added) - }), - ); - - imp.bound_model - .set(model.upcast_ref(), vec![signal_handler_id]); - - imp.create_widget_func - .replace(Some(Box::new(create_widget_func))); - - self.handle_items_changed(model, 0, 0, model.n_items()) - } - - fn handle_items_changed( - &self, - model: &impl glib::IsA, - position: u32, - mut removed: u32, - added: u32, - ) { - let max_children = self.max_children(); - if max_children != 0 && position >= max_children { - // No changes here. - return; - } - - let imp = self.imp(); - let mut widgets = imp.widgets.borrow_mut(); - let create_widget_func_option = imp.create_widget_func.borrow(); - let create_widget_func = create_widget_func_option.as_ref().unwrap(); - - while removed > 0 { - if position as usize >= widgets.len() { - break; - } - - let widget = widgets.remove(position as usize); - widget.unparent(); - removed -= 1; - } - - for i in position..(position + added) { - if max_children != 0 && i >= max_children { - break; - } - - let item = model.item(i).unwrap(); - let widget = create_widget_func(&item); - widget.set_parent(self); - widgets.insert(i as usize, widget) - } - - self.queue_resize(); - } -} diff --git a/src/session/view/content/room_history/read_receipts_list/mod.rs b/src/session/view/content/room_history/read_receipts_list/mod.rs index 5a86a389..21caa9ba 100644 --- a/src/session/view/content/room_history/read_receipts_list/mod.rs +++ b/src/session/view/content/room_history/read_receipts_list/mod.rs @@ -6,14 +6,14 @@ mod read_receipts_popover; use self::read_receipts_popover::ReadReceiptsPopover; use super::member_timestamp::MemberTimestamp; use crate::{ - components::{Avatar, OverlappingBox}, + components::OverlappingAvatars, i18n::{gettext_f, ngettext_f}, prelude::*, session::model::{Member, MemberList, UserReadReceipt}, utils::BoundObjectWeakRef, }; -// Keep in sync with the `max-children` property of the `overlapping_box` in the +// Keep in sync with the `max-avatars` property of the `avatar_list` in the // UI file. const MAX_RECEIPTS_SHOWN: u32 = 10; @@ -35,7 +35,7 @@ mod imp { #[template_child] pub label: TemplateChild, #[template_child] - pub overlapping_box: TemplateChild, + pub avatar_list: TemplateChild, /// The list of room members. pub members: RefCell>, /// The list of read receipts. @@ -51,7 +51,7 @@ mod imp { Self { toggle_button: Default::default(), label: Default::default(), - overlapping_box: Default::default(), + avatar_list: Default::default(), members: Default::default(), list: gio::ListStore::new::(), source: Default::default(), @@ -115,20 +115,13 @@ mod imp { self.parent_constructed(); let obj = self.obj(); - self.overlapping_box.bind_model( - Some(&self.list), - clone!(@weak obj => @default-return { Avatar::new().upcast() }, move |item| { - let avatar = Avatar::new(); - avatar.set_size(20); - - if let Some(member) = item.downcast_ref::().and_then(|r| r.member()) { - avatar.set_data(Some(member.avatar_data().clone())); - } - - let cutout = adw::Bin::builder().child(&avatar).css_classes(["cutout"]).build(); - cutout.upcast() - }), - ); + self.avatar_list + .bind_model(Some(self.list.clone()), |item| { + item.downcast_ref::() + .and_then(|m| m.member()) + .map(|m| m.avatar_data().clone()) + .unwrap() + }); self.list .connect_items_changed(clone!(@weak obj => move |_, _,_,_| { diff --git a/src/session/view/content/room_history/read_receipts_list/mod.ui b/src/session/view/content/room_history/read_receipts_list/mod.ui index b20ec599..4e3679d7 100644 --- a/src/session/view/content/room_history/read_receipts_list/mod.ui +++ b/src/session/view/content/room_history/read_receipts_list/mod.ui @@ -20,9 +20,9 @@ - - 8 - 10 + + 20 + 10 diff --git a/src/session/view/content/room_history/typing_row.rs b/src/session/view/content/room_history/typing_row.rs index 00ab2a5a..e49178ac 100644 --- a/src/session/view/content/room_history/typing_row.rs +++ b/src/session/view/content/room_history/typing_row.rs @@ -2,7 +2,7 @@ use adw::subclass::prelude::*; use gtk::{glib, glib::clone, prelude::*, CompositeTemplate}; use crate::{ - components::{Avatar, OverlappingBox}, + components::OverlappingAvatars, i18n::{gettext_f, ngettext_f}, prelude::*, session::model::{Member, TypingList}, @@ -19,7 +19,7 @@ mod imp { #[template(resource = "/org/gnome/Fractal/ui/session/view/content/room_history/typing_row.ui")] pub struct TypingRow { #[template_child] - pub avatar_box: TemplateChild, + pub avatar_list: TemplateChild, #[template_child] pub label: TemplateChild, /// The list of members that are currently typing. @@ -125,17 +125,8 @@ impl TypingRow { clone!(@weak self as obj => move |_, _| obj.notify("is-empty")), ); - imp.avatar_box.bind_model(Some(list), |item| { - let avatar_data = item.downcast_ref::().unwrap().avatar_data().clone(); - let avatar = Avatar::new(); - avatar.set_data(Some(avatar_data)); - avatar.set_size(30); - - let cutout = adw::Bin::builder() - .child(&avatar) - .css_classes(["cutout"]) - .build(); - cutout.upcast() + imp.avatar_list.bind_model(Some(list.clone()), |item| { + item.downcast_ref::().unwrap().avatar_data().clone() }); imp.bound_list.set( diff --git a/src/session/view/content/room_history/typing_row.ui b/src/session/view/content/room_history/typing_row.ui index 5369f7c7..9899b782 100644 --- a/src/session/view/content/room_history/typing_row.ui +++ b/src/session/view/content/room_history/typing_row.ui @@ -9,9 +9,10 @@ 6 - - 16 - 10 + + 30 + 10 + presentation