fractal/session/view/content/room_details/addresses_subpage/
completion_popover.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gtk::{gdk, gio, glib, glib::clone, pango, CompositeTemplate};
3use tracing::error;
4
5use crate::utils::BoundObjectWeakRef;
6
7mod imp {
8    use std::cell::RefCell;
9
10    use glib::subclass::InitializingObject;
11
12    use super::*;
13
14    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
15    #[template(
16        resource = "/org/gnome/Fractal/ui/session/view/content/room_details/addresses_subpage/completion_popover.ui"
17    )]
18    #[properties(wrapper_type = super::CompletionPopover)]
19    pub struct CompletionPopover {
20        #[template_child]
21        list: TemplateChild<gtk::ListBox>,
22        /// The parent entry to autocomplete.
23        #[property(get, set = Self::set_entry, explicit_notify, nullable)]
24        entry: BoundObjectWeakRef<gtk::Editable>,
25        /// The key controller added to the parent entry.
26        entry_controller: RefCell<Option<gtk::EventControllerKey>>,
27        entry_binding: RefCell<Option<glib::Binding>>,
28        /// The list model to use for completion.
29        ///
30        /// Only supports `GtkStringObject` items.
31        #[property(get, set = Self::set_model, explicit_notify, nullable)]
32        model: RefCell<Option<gio::ListModel>>,
33        /// The string filter.
34        #[property(get)]
35        filter: gtk::StringFilter,
36        /// The filtered list model.
37        #[property(get)]
38        filtered_list: gtk::FilterListModel,
39    }
40
41    #[glib::object_subclass]
42    impl ObjectSubclass for CompletionPopover {
43        const NAME: &'static str = "RoomDetailsAddressesSubpageCompletionPopover";
44        type Type = super::CompletionPopover;
45        type ParentType = gtk::Popover;
46
47        fn class_init(klass: &mut Self::Class) {
48            Self::bind_template(klass);
49            Self::bind_template_callbacks(klass);
50        }
51
52        fn instance_init(obj: &InitializingObject<Self>) {
53            obj.init_template();
54        }
55    }
56
57    #[glib::derived_properties]
58    impl ObjectImpl for CompletionPopover {
59        fn constructed(&self) {
60            self.parent_constructed();
61
62            self.filter
63                .set_expression(Some(gtk::StringObject::this_expression("string")));
64            self.filtered_list.set_filter(Some(&self.filter));
65
66            self.filtered_list.connect_items_changed(clone!(
67                #[weak(rename_to = imp)]
68                self,
69                move |_, _, _, _| {
70                    imp.update_completion();
71                }
72            ));
73
74            self.list.bind_model(Some(&self.filtered_list), |item| {
75                let Some(item) = item.downcast_ref::<gtk::StringObject>() else {
76                    error!("Completion has item that is not a GtkStringObject");
77                    return adw::Bin::new().upcast();
78                };
79
80                let label = gtk::Label::builder()
81                    .label(item.string())
82                    .ellipsize(pango::EllipsizeMode::End)
83                    .halign(gtk::Align::Start)
84                    .build();
85
86                gtk::ListBoxRow::builder().child(&label).build().upcast()
87            });
88        }
89
90        fn dispose(&self) {
91            if let Some(entry) = self.entry.obj() {
92                if let Some(controller) = self.entry_controller.take() {
93                    entry.remove_controller(&controller);
94                }
95            }
96
97            if let Some(binding) = self.entry_binding.take() {
98                binding.unbind();
99            }
100        }
101    }
102
103    impl WidgetImpl for CompletionPopover {}
104    impl PopoverImpl for CompletionPopover {}
105
106    #[gtk::template_callbacks]
107    impl CompletionPopover {
108        /// Set the parent entry to autocomplete.
109        fn set_entry(&self, entry: Option<&gtk::Editable>) {
110            let prev_entry = self.entry.obj();
111
112            if prev_entry.as_ref() == entry {
113                return;
114            }
115            let obj = self.obj();
116
117            if let Some(entry) = prev_entry {
118                if let Some(controller) = self.entry_controller.take() {
119                    entry.remove_controller(&controller);
120                }
121
122                obj.unparent();
123            }
124            if let Some(binding) = self.entry_binding.take() {
125                binding.unbind();
126            }
127            self.entry.disconnect_signals();
128
129            if let Some(entry) = entry {
130                let key_events = gtk::EventControllerKey::new();
131                key_events.connect_key_pressed(clone!(
132                    #[weak(rename_to = imp)]
133                    self,
134                    #[upgrade_or]
135                    glib::Propagation::Proceed,
136                    move |_, key, _, modifier| imp.key_pressed(key, modifier)
137                ));
138
139                entry.add_controller(key_events.clone());
140                self.entry_controller.replace(Some(key_events));
141
142                let search_binding = entry
143                    .bind_property("text", &self.filter, "search")
144                    .sync_create()
145                    .build();
146                self.entry_binding.replace(Some(search_binding));
147
148                let changed_handler = entry.connect_changed(clone!(
149                    #[weak(rename_to = imp)]
150                    self,
151                    move |_| {
152                        imp.update_completion();
153                    }
154                ));
155
156                let state_flags_handler = entry.connect_state_flags_changed(clone!(
157                    #[weak(rename_to = imp)]
158                    self,
159                    move |_, _| {
160                        imp.update_completion();
161                    }
162                ));
163
164                obj.set_parent(entry);
165                self.entry
166                    .set(entry, vec![changed_handler, state_flags_handler]);
167            }
168
169            obj.notify_entry();
170        }
171
172        /// Set the list model to use for completion.
173        fn set_model(&self, model: Option<gio::ListModel>) {
174            if *self.model.borrow() == model {
175                return;
176            }
177
178            self.filtered_list.set_model(model.as_ref());
179
180            self.model.replace(model);
181            self.obj().notify_model();
182        }
183
184        /// Update completion.
185        fn update_completion(&self) {
186            let Some(entry) = self.entry.obj() else {
187                return;
188            };
189            let obj = self.obj();
190
191            let n_items = self.filtered_list.n_items();
192
193            // Always hide the popover if it's empty.
194            if n_items == 0 {
195                if obj.is_visible() {
196                    obj.popdown();
197                }
198
199                return;
200            }
201
202            // Always hide the popover if it has a single item that is exactly the text of
203            // the entry.
204            if n_items == 1 {
205                if let Some(item) = self
206                    .filtered_list
207                    .item(0)
208                    .and_downcast::<gtk::StringObject>()
209                {
210                    if item.string() == entry.text() {
211                        if obj.is_visible() {
212                            obj.popdown();
213                        }
214
215                        return;
216                    }
217                }
218            }
219
220            // Only show the popover if the entry is focused.
221            let entry_has_focus = entry.state_flags().contains(gtk::StateFlags::FOCUS_WITHIN);
222            if entry_has_focus {
223                if !obj.is_visible() {
224                    obj.popup();
225                }
226            } else if obj.is_visible() {
227                obj.popdown();
228            }
229        }
230
231        /// The index of the selected row.
232        fn selected_row_index(&self) -> Option<usize> {
233            let selected_row = self.list.selected_row()?;
234            let n_rows = i32::try_from(self.filtered_list.n_items()).unwrap_or(i32::MAX);
235
236            for idx in 0..n_rows {
237                let Some(row) = self.list.row_at_index(idx) else {
238                    break;
239                };
240
241                if row == selected_row {
242                    return Some(idx.try_into().unwrap_or_default());
243                }
244            }
245
246            None
247        }
248
249        /// Select the row at the given index.
250        fn select_row_at_index(&self, idx: Option<usize>) {
251            if self.selected_row_index() == idx
252                || idx >= Some(self.filtered_list.n_items() as usize)
253            {
254                return;
255            }
256
257            let row =
258                idx.and_then(|idx| self.list.row_at_index(idx.try_into().unwrap_or(i32::MAX)));
259            self.list.select_row(row.as_ref());
260        }
261
262        /// The text of the selected row, if any.
263        fn selected_text(&self) -> Option<glib::GString> {
264            Some(
265                self.list
266                    .selected_row()?
267                    .child()?
268                    .downcast_ref::<gtk::Label>()?
269                    .label(),
270            )
271        }
272
273        /// Activate the selected row.
274        ///
275        /// Returns `true` if the row was activated.
276        pub(super) fn activate_selected_row(&self) -> bool {
277            if !self.obj().is_visible() {
278                return false;
279            }
280            let Some(entry) = self.entry.obj() else {
281                return false;
282            };
283
284            let Some(selected_text) = self.selected_text() else {
285                return false;
286            };
287
288            if selected_text == entry.text() {
289                // Activating the row would have no effect.
290                return false;
291            }
292
293            let Some(row) = self.list.selected_row() else {
294                return false;
295            };
296
297            row.activate();
298            true
299        }
300
301        /// Handle a key being pressed in the entry.
302        fn key_pressed(&self, key: gdk::Key, modifier: gdk::ModifierType) -> glib::Propagation {
303            if !modifier.is_empty() {
304                return glib::Propagation::Proceed;
305            }
306
307            let obj = self.obj();
308
309            if obj.is_visible() {
310                if matches!(key, gdk::Key::Tab) {
311                    self.update_completion();
312                    return glib::Propagation::Stop;
313                }
314
315                return glib::Propagation::Proceed;
316            }
317
318            if matches!(
319                key,
320                gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::ISO_Enter
321            ) {
322                // Activate completion.
323                self.activate_selected_row();
324                return glib::Propagation::Stop;
325            } else if matches!(key, gdk::Key::Up | gdk::Key::KP_Up) {
326                // Move up, if possible.
327                let idx = self.selected_row_index().unwrap_or_default();
328                if idx > 0 {
329                    self.select_row_at_index(Some(idx - 1));
330                }
331                return glib::Propagation::Stop;
332            } else if matches!(key, gdk::Key::Down | gdk::Key::KP_Down) {
333                // Move down, if possible.
334                let new_idx = if let Some(idx) = self.selected_row_index() {
335                    idx + 1
336                } else {
337                    0
338                };
339                let max = self.filtered_list.n_items() as usize;
340
341                if new_idx < max {
342                    self.select_row_at_index(Some(new_idx));
343                }
344                return glib::Propagation::Stop;
345            } else if matches!(key, gdk::Key::Escape) {
346                // Close.
347                obj.popdown();
348                return glib::Propagation::Stop;
349            }
350
351            glib::Propagation::Proceed
352        }
353
354        /// Handle a row being activated.
355        #[template_callback]
356        fn row_activated(&self, row: &gtk::ListBoxRow) {
357            let Some(label) = row.child().and_downcast::<gtk::Label>() else {
358                return;
359            };
360            let Some(entry) = self.entry.obj() else {
361                return;
362            };
363
364            entry.set_text(&label.label());
365
366            self.obj().popdown();
367            entry.grab_focus();
368        }
369    }
370}
371
372glib::wrapper! {
373    /// A popover to auto-complete strings for a `gtk::Editable`.
374    pub struct CompletionPopover(ObjectSubclass<imp::CompletionPopover>)
375        @extends gtk::Widget, gtk::Popover, @implements gtk::Accessible;
376}
377
378impl CompletionPopover {
379    pub fn new() -> Self {
380        glib::Object::new()
381    }
382
383    /// Activate the selected row.
384    ///
385    /// Returns `true` if the row was activated.
386    pub(crate) fn activate_selected_row(&self) -> bool {
387        self.imp().activate_selected_row()
388    }
389}
390
391impl Default for CompletionPopover {
392    fn default() -> Self {
393        Self::new()
394    }
395}