Skip to main content

fractal/utils/grouping_list_model/
mod.rs

1use std::{cmp::Ordering, fmt, ops::RangeInclusive};
2
3use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*};
4
5mod group;
6#[cfg(test)]
7mod tests;
8
9pub(crate) use self::group::GroupingListGroup;
10use crate::utils::BoundObject;
11
12/// A function to determine if an item should be grouped with another contiguous
13/// item.
14///
15/// This function MUST always return `true` when used with any two items in the
16/// same group.
17pub(crate) type GroupFn = dyn Fn(&glib::Object, &glib::Object) -> bool;
18
19mod imp {
20    use std::{
21        cell::{OnceCell, RefCell},
22        collections::{HashSet, VecDeque},
23    };
24
25    use super::*;
26
27    #[derive(Default, glib::Properties)]
28    #[properties(wrapper_type = super::GroupingListModel)]
29    pub struct GroupingListModel {
30        /// The underlying model.
31        #[property(get, set = Self::set_model, explicit_notify, nullable)]
32        model: BoundObject<gio::ListModel>,
33        /// The function to determine if adjacent items should be grouped.
34        pub(super) group_fn: OnceCell<Box<GroupFn>>,
35        /// The groups created by this list model.
36        items: RefCell<VecDeque<GroupingListItem>>,
37    }
38
39    impl fmt::Debug for GroupingListModel {
40        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41            f.debug_struct("GroupingListModel")
42                .field("model", &self.model)
43                .field("items", &self.items)
44                .finish_non_exhaustive()
45        }
46    }
47
48    #[glib::object_subclass]
49    impl ObjectSubclass for GroupingListModel {
50        const NAME: &'static str = "GroupingListModel";
51        type Type = super::GroupingListModel;
52        type Interfaces = (gio::ListModel,);
53    }
54
55    #[glib::derived_properties]
56    impl ObjectImpl for GroupingListModel {}
57
58    impl ListModelImpl for GroupingListModel {
59        fn item_type(&self) -> glib::Type {
60            glib::Object::static_type()
61        }
62
63        fn n_items(&self) -> u32 {
64            self.items.borrow().len() as u32
65        }
66
67        fn item(&self, position: u32) -> Option<glib::Object> {
68            let model = self.model.obj()?;
69            self.items
70                .borrow()
71                .get(position as usize)
72                .and_then(|item| match item {
73                    GroupingListItem::Singleton(position) => model.item(*position),
74                    GroupingListItem::Group(obj) => Some(obj.clone().upcast()),
75                })
76        }
77    }
78
79    impl GroupingListModel {
80        /// The function to determine if adjacent items should be grouped.
81        fn group_fn(&self) -> &GroupFn {
82            self.group_fn.get().expect("group Fn should be initialized")
83        }
84
85        /// Set the underlying model.
86        fn set_model(&self, model: Option<gio::ListModel>) {
87            let prev_model = self.model.obj();
88
89            if prev_model == model {
90                return;
91            }
92
93            self.model.disconnect_signals();
94
95            if let Some(model) = model {
96                let items_changed_handler = model.connect_items_changed(clone!(
97                    #[weak(rename_to = imp)]
98                    self,
99                    move |model, position, removed, added| {
100                        imp.items_changed(model, position, removed, added);
101                    }
102                ));
103
104                self.model.set(model.clone(), vec![items_changed_handler]);
105
106                let removed = prev_model.map(|model| model.n_items()).unwrap_or_default();
107                self.items_changed(&model, 0, removed, model.n_items());
108            } else {
109                let removed = self.n_items();
110                self.items.borrow_mut().clear();
111                self.obj().items_changed(0, removed, 0);
112            }
113
114            self.obj().notify_model();
115        }
116
117        /// Find the index of the list item containing the given position in the
118        /// underlying model.
119        fn model_position_to_index(&self, position: u32) -> Option<usize> {
120            for (index, item) in self.items.borrow().iter().enumerate() {
121                if item.contains(position) {
122                    return Some(index);
123                }
124
125                // Because items are sorted, we can return early when the position is in a gap
126                // between items. This should only happen during `items_changed`.
127                if item.end() > position {
128                    return None;
129                }
130            }
131
132            None
133        }
134
135        /// Handle when items have changed in the underlying model.
136        #[allow(clippy::too_many_lines)]
137        fn items_changed(&self, model: &gio::ListModel, position: u32, removed: u32, added: u32) {
138            if removed == 0 && added == 0 {
139                // Nothing to do.
140                return;
141            }
142
143            // Index of the list item that contains the item right before the changes in the
144            // model.
145            let index_before_changes = position
146                .checked_sub(1)
147                .and_then(|position| self.model_position_to_index(position));
148
149            let mut replaced_list_items = HashSet::new();
150
151            let mut list_items_removed =
152                self.items_removed(position, removed, index_before_changes);
153            let mut list_items_added = self.items_added(
154                model,
155                position,
156                added,
157                &mut replaced_list_items,
158                index_before_changes,
159            );
160
161            let position_after_changes = position + added;
162            let mut index_after_changes = self.model_position_to_index(position_after_changes);
163
164            // Check if the list item after the changes can be merged with the previous list
165            // item.
166            if let Some(index_after_changes) =
167                index_after_changes.as_mut().filter(|index| **index > 0)
168            {
169                let mut items = self.items.borrow_mut();
170
171                let previous_item_in_other_list_item = !items
172                    .get(*index_after_changes)
173                    .expect("list item index should be valid")
174                    .contains(position_after_changes - 1);
175
176                if previous_item_in_other_list_item {
177                    let item_after_changes = model
178                        .item(position_after_changes)
179                        .expect("item position should be valid");
180                    let previous_item = model
181                        .item(position_after_changes - 1)
182                        .expect("item position should be valid");
183
184                    if self.group_fn()(&item_after_changes, &previous_item) {
185                        // We can merge the items.
186                        *index_after_changes -= 1;
187
188                        let (removed_list_item, list_item_to_merge_into) = if index_before_changes
189                            .is_some_and(|index| index == *index_after_changes)
190                        {
191                            // Merge into the list item before changes.
192                            let list_item_after_changes = items
193                                .remove(*index_after_changes + 1)
194                                .expect("list item index should be valid");
195                            let list_item_before_changes = items
196                                .get_mut(*index_after_changes)
197                                .expect("list item index should be valid");
198                            (list_item_after_changes, list_item_before_changes)
199                        } else {
200                            // Merge into the list item after changes.
201                            let previous_list_item = items
202                                .remove(*index_after_changes)
203                                .expect("list item index should be valid");
204                            let list_item_after_changes = items
205                                .get_mut(*index_after_changes)
206                                .expect("list item index should be valid");
207                            (previous_list_item, list_item_after_changes)
208                        };
209
210                        let list_item_replacement = list_item_to_merge_into.add(
211                            removed_list_item.start(),
212                            removed_list_item.len(),
213                            model,
214                        );
215
216                        if let Some(replacement) = list_item_replacement {
217                            *list_item_to_merge_into = replacement;
218                            replaced_list_items.insert(*index_after_changes);
219                        }
220
221                        if let Some(added) = list_items_added.checked_sub(1) {
222                            list_items_added = added;
223                        } else {
224                            list_items_removed += 1;
225                        }
226                    }
227                }
228            }
229
230            let obj = self.obj();
231
232            if list_items_removed > 0 || list_items_added > 0 {
233                let index_at_changes = index_before_changes
234                    .map(|index| index + 1)
235                    .unwrap_or_default();
236
237                // Drop the batches of the new groups, we do not want to send signals about
238                // changed items later for them.
239                self.items
240                    .borrow()
241                    .range(index_at_changes..index_at_changes + list_items_added)
242                    .for_each(|list_item| match list_item {
243                        GroupingListItem::Singleton(_) => {}
244                        GroupingListItem::Group(group) => group.drop_batch(),
245                    });
246
247                obj.items_changed(
248                    index_at_changes as u32,
249                    list_items_removed as u32,
250                    list_items_added as u32,
251                );
252            }
253
254            // Change groups with a single item to singletons.
255            for index in index_before_changes.into_iter().chain(index_after_changes) {
256                let mut items = self.items.borrow_mut();
257                let item = items
258                    .get_mut(index)
259                    .expect("list item index should be valid");
260
261                if matches!(item, GroupingListItem::Group(_)) && item.len() == 1 {
262                    *item = GroupingListItem::Singleton(item.start());
263                    replaced_list_items.insert(index);
264                }
265            }
266
267            for index in replaced_list_items {
268                obj.items_changed(index as u32, 1, 1);
269            }
270
271            // Generate a list of groups before processing the batches, to avoid holding a
272            // ref while we send signals about changed items.
273            let groups = {
274                let items = self.items.borrow();
275
276                let first_possible_group_with_changes_index =
277                    index_before_changes.unwrap_or_default();
278                let after_last_possible_group_with_changes_index =
279                    (first_possible_group_with_changes_index + list_items_added + 2)
280                        .min(items.len());
281
282                items
283                    .range(
284                        first_possible_group_with_changes_index
285                            ..after_last_possible_group_with_changes_index,
286                    )
287                    .filter_map(|list_item| match list_item {
288                        GroupingListItem::Singleton(_) => None,
289                        GroupingListItem::Group(group) => group.has_batch().then(|| group.clone()),
290                    })
291                    .collect::<Vec<_>>()
292            };
293
294            for group in groups {
295                group.process_batch();
296            }
297        }
298
299        /// Handle when items were removed in the underlying model.
300        ///
301        /// Returns a `(position, removed)` tuple if items were removed in this
302        /// list.
303        fn items_removed(
304            &self,
305            position: u32,
306            removed: u32,
307            index_before_changes: Option<usize>,
308        ) -> usize {
309            if removed == 0 {
310                // Nothing to do.
311                return 0;
312            }
313
314            // Index of the list item that contains the item right after the changes in the
315            // model.
316            let index_after_changes = position
317                .checked_add(removed)
318                .and_then(|position| self.model_position_to_index(position));
319
320            let mut items = self.items.borrow_mut();
321
322            // Update the range of the list item before changes, if it's not the same as the
323            // list item after the changes and if it contains removed items.
324            if let Some(index_before_changes) = index_before_changes.filter(|index| {
325                index_after_changes.is_none_or(|index_after_changes| index_after_changes > *index)
326            }) {
327                items
328                    .get_mut(index_before_changes)
329                    .expect("list item index should be valid")
330                    .handle_removal(position, removed);
331            }
332
333            // Update the range of the list items after the changes.
334            if let Some(index_after_changes) = index_after_changes {
335                items
336                    .range_mut(index_after_changes..)
337                    .for_each(|list_item| list_item.handle_removal(position, removed));
338            }
339
340            // If items were removed, we should have at least one list item.
341            let max_index = items.len() - 1;
342
343            let removal_start = if let Some(index_before_changes) = index_before_changes {
344                // The list items removal starts at the list item after the one before the
345                // changes.
346                index_before_changes
347                    .checked_add(1)
348                    .filter(|index| *index <= max_index)
349            } else {
350                // There is no list item before, we are at the start of the list items.
351                Some(0)
352            };
353
354            let removal_end = if let Some(index_after_changes) = index_after_changes {
355                // The list items removal starts at the list item before the one after the
356                // changes.
357                index_after_changes.checked_sub(1)
358            } else {
359                // There is no list item after, we are at the end of the list items.
360                Some(max_index)
361            };
362
363            // Remove list items if needed.
364            let Some((removal_start, removal_end)) = removal_start
365                .zip(removal_end)
366                .filter(|(removal_start, removal_end)| removal_start <= removal_end)
367            else {
368                return 0;
369            };
370
371            let is_at_items_start = removal_start == 0;
372            let is_at_items_end = removal_end == items.len().saturating_sub(1);
373
374            // Try to optimize the removal by using the most appropriate `VecDeque` method.
375            if is_at_items_start && is_at_items_end {
376                // Remove all items.
377                items.clear();
378            } else if is_at_items_end {
379                // Remove the end of the items.
380                items.truncate(removal_start);
381            } else {
382                // We can only remove each item separately.
383                for i in (removal_start..=removal_end).rev() {
384                    items.remove(i);
385                }
386            }
387
388            removal_end - removal_start + 1
389        }
390
391        /// Handle when items were added to the underlying model.
392        ///
393        /// Returns the number of items that were added, if any.
394        fn items_added(
395            &self,
396            model: &gio::ListModel,
397            position: u32,
398            added: u32,
399            replaced_list_items: &mut HashSet<usize>,
400            index_before_changes: Option<usize>,
401        ) -> usize {
402            if added == 0 {
403                // Nothing to do.
404                return 0;
405            }
406
407            let mut list_items_added = 0;
408
409            let position_before = position.checked_sub(1);
410            // The previous item in the underlying model and the index of the list item that
411            // contains it.
412            let mut previous_item_and_index =
413                position_before.and_then(|position| model.item(position).zip(index_before_changes));
414
415            let group_fn = self.group_fn();
416            let mut items = self.items.borrow_mut();
417
418            for current_position in position..position + added {
419                let item = model
420                    .item(current_position)
421                    .expect("item position should be valid");
422
423                if let Some((previous_item, previous_index)) = &mut previous_item_and_index {
424                    let previous_list_item = items
425                        .get_mut(*previous_index)
426                        .expect("list item index should be valid");
427
428                    if group_fn(&item, previous_item) {
429                        // Add the position to the list item.
430                        let list_item_replacement =
431                            previous_list_item.add(current_position, 1, model);
432
433                        if let Some(replacement) = list_item_replacement {
434                            *previous_list_item = replacement;
435
436                            if current_position == position {
437                                // We will need to send a signal because we replaced a list item
438                                // that already existed.
439                                replaced_list_items.insert(*previous_index);
440                            }
441                        }
442
443                        // The previous item changed but the list item that contains it is the same.
444                        *previous_item = item;
445
446                        continue;
447                    } else if previous_list_item.contains(current_position) {
448                        // We need to split the group.
449                        let end_list_item = previous_list_item.split(current_position);
450
451                        items.insert(*previous_index + 1, end_list_item);
452                        list_items_added += 1;
453                    }
454                }
455
456                // The item is a singleton.
457                let index = previous_item_and_index
458                    .take()
459                    .map(|(_, index)| index + 1)
460                    .unwrap_or_default();
461                items.insert(index, GroupingListItem::Singleton(current_position));
462                list_items_added += 1;
463
464                previous_item_and_index = Some((item, index));
465            }
466
467            let (_, last_index_with_changes) =
468                previous_item_and_index.expect("there should have been at least one addition");
469            let index_after_changes = last_index_with_changes + 1;
470
471            // Update the ranges of the list items after the changes.
472            if index_after_changes < items.len() {
473                items
474                    .range_mut(index_after_changes..)
475                    .for_each(|list_item| list_item.handle_addition(position, added));
476            }
477
478            list_items_added
479        }
480    }
481}
482
483glib::wrapper! {
484    /// A list model that groups some items according to a function.
485    pub struct GroupingListModel(ObjectSubclass<imp::GroupingListModel>)
486        @implements gio::ListModel;
487}
488
489impl GroupingListModel {
490    /// Construct a new `GroupingListModel` with the given function to determine
491    /// if adjacent items should be grouped.
492    pub fn new<GroupFn>(group_fn: GroupFn) -> Self
493    where
494        GroupFn: Fn(&glib::Object, &glib::Object) -> bool + 'static,
495    {
496        let obj = glib::Object::new::<Self>();
497        // Ignore the error because we cannot `.expect()` when the value is a function.
498        let _ = obj.imp().group_fn.set(Box::new(group_fn));
499        obj
500    }
501}
502
503/// An item in the [`GroupingListModel`].
504#[derive(Debug, Clone)]
505enum GroupingListItem {
506    /// An item that is not in a group.
507    Singleton(u32),
508
509    /// A group of items.
510    Group(GroupingListGroup),
511}
512
513impl GroupingListItem {
514    /// Construct a list item with the given range for the given model.
515    fn with_range(range: RangeInclusive<u32>, model: &gio::ListModel) -> Self {
516        if range.start() == range.end() {
517            Self::Singleton(*range.start())
518        } else {
519            Self::Group(GroupingListGroup::new(model, range))
520        }
521    }
522
523    /// Whether this list item contains the given position.
524    fn contains(&self, position: u32) -> bool {
525        match self {
526            Self::Singleton(pos) => *pos == position,
527            Self::Group(group) => group.contains(position),
528        }
529    }
530
531    /// The position of the first item in this list item.
532    fn start(&self) -> u32 {
533        match self {
534            Self::Singleton(position) => *position,
535            Self::Group(group) => group.start(),
536        }
537    }
538
539    /// The position of the last item in this list item.
540    fn end(&self) -> u32 {
541        match self {
542            Self::Singleton(position) => *position,
543            Self::Group(group) => group.end(),
544        }
545    }
546
547    /// The length of the range of this list item.
548    fn len(&self) -> u32 {
549        match self {
550            Self::Singleton(_) => 1,
551            Self::Group(group) => {
552                let (start, end) = group.bounds();
553                end - start + 1
554            }
555        }
556    }
557
558    /// Handle the given removal of items that might affect this group.
559    ///
560    /// This function panics if there would be no items left in this group.
561    fn handle_removal(&mut self, position: u32, removed: u32) {
562        match self {
563            Self::Singleton(pos) => match position.cmp(pos) {
564                Ordering::Less => *pos -= removed,
565                Ordering::Equal => panic!("should not remove list item"),
566                Ordering::Greater => {}
567            },
568            Self::Group(group) => group.handle_removal(position, removed),
569        }
570    }
571
572    /// Handle the given addition of items that might affect this group.
573    fn handle_addition(&mut self, position: u32, added: u32) {
574        match self {
575            Self::Singleton(pos) => {
576                if position <= *pos {
577                    *pos += added;
578                }
579            }
580            Self::Group(group) => group.handle_addition(position, added),
581        }
582    }
583
584    /// Add items to this list item.
585    ///
586    /// Returns the newly created group if this item was a singleton.
587    ///
588    /// Panics if the added items are not contiguous to the current ones.
589    fn add(&self, position: u32, added: u32, model: &gio::ListModel) -> Option<Self> {
590        debug_assert!(
591            (position + added) >= self.start() || position <= self.end().saturating_add(1),
592            "items to add should be contiguous"
593        );
594
595        match self {
596            Self::Singleton(pos) => {
597                let start = position.min(*pos);
598                let end = start + added;
599                Some(Self::with_range(start..=end, model))
600            }
601            Self::Group(group) => {
602                group.add(position, added);
603                None
604            }
605        }
606    }
607
608    /// Split this group at the given position.
609    ///
610    /// `position` must be greater than the start and lower or equal to the end
611    /// of this group.
612    ///
613    /// Returns the new list item containing the second part of the group,
614    /// starting at `at`.
615    fn split(&self, position: u32) -> Self {
616        match self {
617            Self::Singleton(_) => panic!("singleton cannot be split"),
618            Self::Group(group) => {
619                let model = group.model().expect("model should be initialized");
620                let end = group.end();
621
622                group.handle_removal(position, end - position + 1);
623
624                Self::with_range(position..=end, &model)
625            }
626        }
627    }
628}