components: Specialize OverlappingBox to only handle avatars

Rename it to OverlappingAvatars.

It's the only use case for it in the foreseeable future.
It deduplicates code and facilitates a11y.
This commit is contained in:
Kévin Commaille 2023-11-13 11:41:11 +01:00
parent 9c01de9682
commit d70e8d3f4e
No known key found for this signature in database
GPG key ID: 29A48C1F03620416
7 changed files with 347 additions and 388 deletions

View file

@ -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,

View file

@ -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<Vec<adw::Bin>>,
/// The size of the avatars.
pub avatar_size: Cell<i32>,
/// The maximum number of avatars to display.
///
/// `0` means that all avatars are displayed.
pub max_avatars: Cell<u32>,
/// The list model that is bound, if any.
pub bound_model: BoundObject<gio::ListModel>,
/// The method used to extract `AvatarData` from the items of the list
/// model, if any.
pub extract_avatar_data_fn: RefCell<Option<Box<ExtractAvatarDataFn>>>,
}
#[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<Vec<glib::ParamSpec>> = 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<gtk::Accessible> {
// Hide the children in the a11y tree.
None
}
}
}
glib::wrapper! {
/// A horizontal list of overlapping avatars.
pub struct OverlappingAvatars(ObjectSubclass<imp::OverlappingAvatars>)
@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>())
{
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<P: Fn(&glib::Object) -> AvatarData + 'static>(
&self,
model: Option<impl glib::IsA<gio::ListModel>>,
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<gio::ListModel>,
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();
}
}

View file

@ -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<Vec<gtk::Widget>>,
/// The size of the widgets.
pub widgets_sizes: RefCell<Vec<(i32, i32)>>,
/// The maximum number of children to display.
///
/// `0` means that all children are displayed.
pub max_children: Cell<u32>,
/// The size by which the widgets overlap.
pub overlap: Cell<u32>,
/// The orientation of the box.
pub orientation: Cell<gtk::Orientation>,
/// The list model that is bound, if any.
pub bound_model: BoundObjectWeakRef<gio::ListModel>,
/// The method used to create widgets from the items of the list model,
/// if any.
pub create_widget_func: RefCell<Option<Box<CreateWidgetFromObjectFn>>>,
}
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<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![
glib::ParamSpecUInt::builder("max-children")
.explicit_notify()
.build(),
glib::ParamSpecUInt::builder("overlap")
.explicit_notify()
.build(),
glib::ParamSpecOverride::for_interface::<gtk::Orientable>("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<imp::OverlappingBox>)
@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<P: Fn(&glib::Object) -> gtk::Widget + 'static>(
&self,
model: Option<&impl glib::IsA<gio::ListModel>>,
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<gio::ListModel>,
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();
}
}

View file

@ -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<gtk::Label>,
#[template_child]
pub overlapping_box: TemplateChild<OverlappingBox>,
pub avatar_list: TemplateChild<OverlappingAvatars>,
/// The list of room members.
pub members: RefCell<Option<MemberList>>,
/// 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::<MemberTimestamp>(),
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::<MemberTimestamp>().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::<MemberTimestamp>()
.and_then(|m| m.member())
.map(|m| m.avatar_data().clone())
.unwrap()
});
self.list
.connect_items_changed(clone!(@weak obj => move |_, _,_,_| {

View file

@ -20,9 +20,9 @@
</object>
</child>
<child>
<object class="OverlappingBox" id="overlapping_box">
<property name="overlap">8</property>
<property name="max-children">10</property>
<object class="OverlappingAvatars" id="avatar_list">
<property name="avatar-size">20</property>
<property name="max-avatars">10</property>
</object>
</child>
</object>

View file

@ -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<OverlappingBox>,
pub avatar_list: TemplateChild<OverlappingAvatars>,
#[template_child]
pub label: TemplateChild<gtk::Label>,
/// 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::<Member>().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::<Member>().unwrap().avatar_data().clone()
});
imp.bound_list.set(

View file

@ -9,9 +9,10 @@
<object class="GtkBox">
<property name="spacing">6</property>
<child>
<object class="OverlappingBox" id="avatar_box">
<property name="overlap">16</property>
<property name="max-children">10</property>
<object class="OverlappingAvatars" id="avatar_list">
<property name="avatar-size">30</property>
<property name="max-avatars">10</property>
<property name="accessible-role">presentation</property>
</object>
</child>
<child>