Skip to main content

fractal/utils/
fixed_selection.rs

1use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*};
2
3use crate::utils::BoundObject;
4
5/// A function that returns `true` if two `GObject`s are considered equivalent.
6pub(crate) type EquivalentObjectFn = dyn Fn(&glib::Object, &glib::Object) -> bool;
7
8mod imp {
9    use std::{
10        cell::{Cell, RefCell},
11        fmt,
12        marker::PhantomData,
13    };
14
15    use super::*;
16
17    #[derive(glib::Properties)]
18    #[properties(wrapper_type = super::FixedSelection)]
19    pub struct FixedSelection {
20        /// The underlying model.
21        #[property(get, set = Self::set_model, explicit_notify, nullable)]
22        model: BoundObject<gio::ListModel>,
23        /// The function to use to test for equivalence of two items.
24        ///
25        /// It is used when checking if an object still present when the
26        /// underlying model changes. Which means that if there are two
27        /// equivalent objects at the same time in the underlying model, the
28        /// selected item might change unexpectedly between those two objects.
29        ///
30        /// If this is not set, the `Eq` implementation is used, meaning that
31        /// they must be the same object.
32        pub(super) item_equivalence_fn: RefCell<Option<Box<EquivalentObjectFn>>>,
33        /// The position of the selected item.
34        #[property(get, set = Self::set_selected, explicit_notify, default = gtk::INVALID_LIST_POSITION)]
35        selected: Cell<u32>,
36        /// The selected item.
37        #[property(get, set = Self::set_selected_item, explicit_notify, nullable)]
38        selected_item: RefCell<Option<glib::Object>>,
39        /// Whether the model is empty.
40        #[property(get = Self::is_empty)]
41        is_empty: PhantomData<bool>,
42    }
43
44    impl fmt::Debug for FixedSelection {
45        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46            f.debug_struct("FixedSelection")
47                .field("model", &self.model)
48                .field("selected", &self.selected)
49                .field("selected_item", &self.selected_item)
50                .finish_non_exhaustive()
51        }
52    }
53
54    impl Default for FixedSelection {
55        fn default() -> Self {
56            Self {
57                model: Default::default(),
58                item_equivalence_fn: Default::default(),
59                selected: Cell::new(gtk::INVALID_LIST_POSITION),
60                selected_item: Default::default(),
61                is_empty: Default::default(),
62            }
63        }
64    }
65
66    #[glib::object_subclass]
67    impl ObjectSubclass for FixedSelection {
68        const NAME: &'static str = "FixedSelection";
69        type Type = super::FixedSelection;
70        type Interfaces = (gio::ListModel, gtk::SelectionModel);
71    }
72
73    #[glib::derived_properties]
74    impl ObjectImpl for FixedSelection {}
75
76    impl ListModelImpl for FixedSelection {
77        fn item_type(&self) -> glib::Type {
78            glib::Object::static_type()
79        }
80
81        fn n_items(&self) -> u32 {
82            self.model.obj().map(|m| m.n_items()).unwrap_or_default()
83        }
84
85        fn item(&self, position: u32) -> Option<glib::Object> {
86            self.model.obj()?.item(position)
87        }
88    }
89
90    impl SelectionModelImpl for FixedSelection {
91        fn selection_in_range(&self, _position: u32, _n_items: u32) -> gtk::Bitset {
92            let bitset = gtk::Bitset::new_empty();
93            let selected = self.selected.get();
94
95            if selected != gtk::INVALID_LIST_POSITION {
96                bitset.add(selected);
97            }
98
99            bitset
100        }
101
102        fn is_selected(&self, position: u32) -> bool {
103            self.selected.get() == position
104        }
105    }
106
107    impl FixedSelection {
108        /// Set the underlying model.
109        fn set_model(&self, model: Option<gio::ListModel>) {
110            let prev_model = self.model.obj();
111
112            if prev_model == model {
113                return;
114            }
115
116            let prev_n_items = prev_model
117                .as_ref()
118                .map(ListModelExt::n_items)
119                .unwrap_or_default();
120            let n_items = model
121                .as_ref()
122                .map(ListModelExt::n_items)
123                .unwrap_or_default();
124
125            self.model.disconnect_signals();
126
127            let obj = self.obj();
128            let _guard = obj.freeze_notify();
129
130            if let Some(model) = model {
131                let items_changed_handler = model.connect_items_changed(clone!(
132                    #[weak(rename_to = imp)]
133                    self,
134                    move |m, p, r, a| {
135                        imp.items_changed_cb(m, p, r, a);
136                    }
137                ));
138
139                self.model.set(model, vec![items_changed_handler]);
140            }
141
142            if self.selected.get() != gtk::INVALID_LIST_POSITION {
143                self.selected.replace(gtk::INVALID_LIST_POSITION);
144                obj.notify_selected();
145            }
146            if self.selected_item.borrow().is_some() {
147                self.selected_item.replace(None);
148                obj.notify_selected_item();
149            }
150
151            if prev_n_items > 0 || n_items > 0 {
152                obj.items_changed(0, prev_n_items, n_items);
153            }
154            if (prev_n_items > 0 && n_items == 0) || (prev_n_items == 0 && n_items > 0) {
155                obj.notify_is_empty();
156            }
157
158            obj.notify_model();
159        }
160
161        /// Set the selected item by its position.
162        fn set_selected(&self, position: u32) {
163            let prev_selected = self.selected.get();
164            if prev_selected == position {
165                return;
166            }
167
168            let selected_item = self.model.obj().and_then(|m| m.item(position));
169
170            let selected = if selected_item.is_none() {
171                gtk::INVALID_LIST_POSITION
172            } else {
173                position
174            };
175
176            if prev_selected == selected {
177                return;
178            }
179            let obj = self.obj();
180
181            self.selected.replace(selected);
182            self.selected_item.replace(selected_item);
183
184            if prev_selected == gtk::INVALID_LIST_POSITION {
185                obj.selection_changed(selected, 1);
186            } else if selected == gtk::INVALID_LIST_POSITION {
187                obj.selection_changed(prev_selected, 1);
188            } else if selected < prev_selected {
189                obj.selection_changed(selected, prev_selected - selected + 1);
190            } else {
191                obj.selection_changed(prev_selected, selected - prev_selected + 1);
192            }
193
194            obj.notify_selected();
195            obj.notify_selected_item();
196        }
197
198        /// Set the selected item.
199        fn set_selected_item(&self, item: Option<glib::Object>) {
200            if *self.selected_item.borrow() == item {
201                return;
202            }
203            let obj = self.obj();
204
205            let prev_selected = self.selected.get();
206            let mut selected = gtk::INVALID_LIST_POSITION;
207
208            if item.is_some()
209                && let Some(model) = self.model.obj()
210            {
211                for i in 0..model.n_items() {
212                    let current_item = model.item(i);
213                    if current_item == item {
214                        selected = i;
215                        break;
216                    }
217                }
218            }
219
220            self.selected_item.replace(item);
221
222            if prev_selected != selected {
223                self.selected.replace(selected);
224
225                if prev_selected == gtk::INVALID_LIST_POSITION {
226                    obj.selection_changed(selected, 1);
227                } else if selected == gtk::INVALID_LIST_POSITION {
228                    obj.selection_changed(prev_selected, 1);
229                } else if selected < prev_selected {
230                    obj.selection_changed(selected, prev_selected - selected + 1);
231                } else {
232                    obj.selection_changed(prev_selected, selected - prev_selected + 1);
233                }
234                obj.notify_selected();
235            }
236
237            obj.notify_selected_item();
238        }
239
240        /// Whether the model is empty.
241        fn is_empty(&self) -> bool {
242            self.model.obj().is_none_or(|model| model.n_items() == 0)
243        }
244
245        /// Handle when items changed in the underlying model.
246        fn items_changed_cb(
247            &self,
248            model: &gio::ListModel,
249            position: u32,
250            removed: u32,
251            added: u32,
252        ) {
253            let obj = self.obj();
254            let _guard = obj.freeze_notify();
255
256            let selected = self.selected.get();
257            let selected_item = self.selected_item.borrow().clone();
258
259            if selected_item.is_none() || selected < position {
260                // unchanged
261            } else if selected != gtk::INVALID_LIST_POSITION && selected >= position + removed {
262                self.selected.set(selected + added - removed);
263                obj.notify_selected();
264            } else {
265                let mut found = false;
266                let item_equivalence_fn = self.item_equivalence_fn.borrow();
267
268                for i in position..(position + added) {
269                    let item = model.item(i);
270
271                    if item.as_ref().zip(selected_item.as_ref()).is_some_and(
272                        |(item, selected_item)| {
273                            if let Some(item_equivalence_fn) = &*item_equivalence_fn {
274                                item_equivalence_fn(item, selected_item)
275                            } else {
276                                item == selected_item
277                            }
278                        },
279                    ) {
280                        if selected != i {
281                            // The item moved.
282                            self.selected.set(i);
283                            obj.notify_selected();
284                        }
285
286                        if item != selected_item {
287                            // The item changed.
288                            self.selected_item.replace(item);
289                            obj.notify_selected_item();
290                        }
291
292                        found = true;
293                        break;
294                    }
295                }
296
297                if !found {
298                    // The item is no longer in the model.
299                    self.selected.set(gtk::INVALID_LIST_POSITION);
300                    obj.notify_selected();
301                }
302            }
303
304            obj.items_changed(position, removed, added);
305
306            let n_items = model.n_items();
307            if n_items == 0 || (removed == 0 && n_items == added) {
308                obj.notify_is_empty();
309            }
310        }
311    }
312}
313
314glib::wrapper! {
315    /// A `GtkSelectionModel` that keeps track of the selected item even if its
316    /// position changes or it is removed from the list.
317    pub struct FixedSelection(ObjectSubclass<imp::FixedSelection>)
318        @implements gio::ListModel, gtk::SelectionModel;
319}
320
321impl FixedSelection {
322    /// Construct a new `FixedSelection` with the given model.
323    pub fn new(model: Option<&impl IsA<gio::ListModel>>) -> Self {
324        glib::Object::builder().property("model", model).build()
325    }
326
327    /// Set the function to use to test for equivalence of two items.
328    ///
329    /// It is used when checking if an object still present when the underlying
330    /// model changes. Which means that if there are two equivalent objects at
331    /// the same time in the underlying model, the selected item might change
332    /// unexpectedly between those two objects.
333    ///
334    /// If this is not set, the `Eq` implementation is used, meaning that they
335    /// must be the same object.
336    pub(crate) fn set_item_equivalence_fn(
337        &self,
338        equivalence_fn: impl Fn(&glib::Object, &glib::Object) -> bool + 'static,
339    ) {
340        self.imp()
341            .item_equivalence_fn
342            .replace(Some(Box::new(equivalence_fn)));
343    }
344}
345
346impl Default for FixedSelection {
347    fn default() -> Self {
348        Self::new(None::<&gio::ListModel>)
349    }
350}