fractal/components/pill/
search_entry.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gtk::{
3    glib,
4    glib::{clone, closure_local},
5    CompositeTemplate,
6};
7
8use crate::components::{AvatarImageSafetySetting, Pill, PillSource};
9
10mod imp {
11    use std::{cell::RefCell, collections::HashMap, marker::PhantomData, sync::LazyLock};
12
13    use glib::subclass::{InitializingObject, Signal};
14
15    use super::*;
16
17    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
18    #[template(resource = "/org/gnome/Fractal/ui/components/pill/search_entry.ui")]
19    #[properties(wrapper_type = super::PillSearchEntry)]
20    pub struct PillSearchEntry {
21        #[template_child]
22        text_view: TemplateChild<gtk::TextView>,
23        #[template_child]
24        text_buffer: TemplateChild<gtk::TextBuffer>,
25        /// The text of the entry.
26        #[property(get = Self::text)]
27        text: PhantomData<glib::GString>,
28        /// Whether the entry is editable.
29        #[property(get = Self::editable, set = Self::set_editable, explicit_notify)]
30        editable: PhantomData<bool>,
31        /// The pills in the text view.
32        ///
33        /// A map of pill identifier to anchor of the pill in the text view.
34        pills: RefCell<HashMap<String, gtk::TextChildAnchor>>,
35    }
36
37    #[glib::object_subclass]
38    impl ObjectSubclass for PillSearchEntry {
39        const NAME: &'static str = "PillSearchEntry";
40        type Type = super::PillSearchEntry;
41        type ParentType = adw::Bin;
42
43        fn class_init(klass: &mut Self::Class) {
44            Self::bind_template(klass);
45        }
46
47        fn instance_init(obj: &InitializingObject<Self>) {
48            obj.init_template();
49        }
50    }
51
52    #[glib::derived_properties]
53    impl ObjectImpl for PillSearchEntry {
54        fn signals() -> &'static [Signal] {
55            static SIGNALS: LazyLock<Vec<Signal>> = LazyLock::new(|| {
56                vec![Signal::builder("pill-removed")
57                    .param_types([PillSource::static_type()])
58                    .build()]
59            });
60            SIGNALS.as_ref()
61        }
62
63        fn constructed(&self) {
64            self.parent_constructed();
65            let obj = self.obj();
66
67            self.text_buffer.connect_delete_range(clone!(
68                #[weak]
69                obj,
70                move |_, start, end| {
71                    if start == end {
72                        // Nothing to do.
73                        return;
74                    }
75
76                    // If a pill was removed, emit the corresponding signal.
77                    let mut current = *start;
78                    loop {
79                        if let Some(source) = current
80                            .child_anchor()
81                            .and_then(|a| a.widgets().first().cloned())
82                            .and_downcast_ref::<Pill>()
83                            .and_then(Pill::source)
84                        {
85                            let removed = obj
86                                .imp()
87                                .pills
88                                .borrow_mut()
89                                .remove(&source.identifier())
90                                .is_some();
91
92                            if removed {
93                                obj.emit_by_name::<()>("pill-removed", &[&source]);
94                            }
95                        }
96
97                        current.forward_char();
98
99                        if &current == end {
100                            break;
101                        }
102                    }
103                }
104            ));
105
106            self.text_buffer
107                .connect_insert_text(|text_buffer, location, text| {
108                    let mut changed = false;
109
110                    // We do not allow adding chars before and between pills.
111                    loop {
112                        if location.child_anchor().is_some() {
113                            changed = true;
114                            if !location.forward_char() {
115                                break;
116                            }
117                        } else {
118                            break;
119                        }
120                    }
121
122                    if changed {
123                        text_buffer.place_cursor(location);
124                        text_buffer.stop_signal_emission_by_name("insert-text");
125                        text_buffer.insert(location, text);
126                    }
127                });
128
129            self.text_buffer.connect_text_notify(clone!(
130                #[weak]
131                obj,
132                move |_| {
133                    obj.notify_text();
134                }
135            ));
136        }
137    }
138
139    impl WidgetImpl for PillSearchEntry {
140        fn grab_focus(&self) -> bool {
141            self.text_view.grab_focus()
142        }
143    }
144
145    impl BinImpl for PillSearchEntry {}
146
147    impl PillSearchEntry {
148        /// The text of the entry.
149        fn text(&self) -> glib::GString {
150            let (start, end) = self.text_buffer.bounds();
151            self.text_buffer.text(&start, &end, false)
152        }
153
154        /// Whether the entry is editable.
155        fn editable(&self) -> bool {
156            self.text_view.is_editable()
157        }
158
159        /// Set whether the entry is editable.
160        fn set_editable(&self, editable: bool) {
161            if self.editable() == editable {
162                return;
163            }
164
165            self.text_view.set_editable(editable);
166            self.obj().notify_editable();
167        }
168
169        /// Add a pill for the given source to the entry.
170        pub(super) fn add_pill(&self, source: &PillSource) {
171            let identifier = source.identifier();
172
173            // If the pill already exists, do not insert it again.
174            if self.pills.borrow().contains_key(&identifier) {
175                return;
176            }
177
178            // We do not need to watch the safety setting as this entry should only be used
179            // with search results.
180            let pill = Pill::new(source, AvatarImageSafetySetting::None, None);
181            pill.set_margin_start(3);
182            pill.set_margin_end(3);
183
184            let (mut start_iter, mut end_iter) = self.text_buffer.bounds();
185
186            // We don't allow adding chars before and between pills
187            loop {
188                if start_iter.child_anchor().is_some() {
189                    start_iter.forward_char();
190                } else {
191                    break;
192                }
193            }
194
195            self.text_buffer.delete(&mut start_iter, &mut end_iter);
196            let anchor = self.text_buffer.create_child_anchor(&mut start_iter);
197            self.text_view.add_child_at_anchor(&pill, &anchor);
198            self.pills.borrow_mut().insert(identifier, anchor);
199
200            self.text_view.grab_focus();
201        }
202
203        /// Remove the pill with the given identifier.
204        pub(super) fn remove_pill(&self, identifier: &str) {
205            let Some(anchor) = self.pills.borrow_mut().remove(identifier) else {
206                return;
207            };
208
209            if anchor.is_deleted() {
210                // Nothing to do.
211                return;
212            }
213
214            let mut start_iter = self.text_buffer.iter_at_child_anchor(&anchor);
215            let mut end_iter = start_iter;
216            end_iter.forward_char();
217            self.text_buffer.delete(&mut start_iter, &mut end_iter);
218        }
219
220        /// Clear this entry.
221        pub(super) fn clear(&self) {
222            let (mut start, mut end) = self.text_buffer.bounds();
223            self.text_buffer.delete(&mut start, &mut end);
224        }
225    }
226}
227
228glib::wrapper! {
229    /// Search entry where selected results can be added as [`Pill`]s.
230    pub struct PillSearchEntry(ObjectSubclass<imp::PillSearchEntry>)
231        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
232}
233
234impl PillSearchEntry {
235    pub fn new() -> Self {
236        glib::Object::new()
237    }
238
239    /// Add a pill for the given source to the entry.
240    pub(crate) fn add_pill(&self, source: &impl IsA<PillSource>) {
241        self.imp().add_pill(source.upcast_ref());
242    }
243
244    /// Remove the pill with the given identifier.
245    pub(crate) fn remove_pill(&self, identifier: &str) {
246        self.imp().remove_pill(identifier);
247    }
248
249    /// Clear this entry.
250    pub(crate) fn clear(&self) {
251        self.imp().clear();
252    }
253
254    /// Connect to the signal emitted when a pill is removed from the entry.
255    ///
256    /// The second parameter is the source of the pill.
257    pub fn connect_pill_removed<F: Fn(&Self, PillSource) + 'static>(
258        &self,
259        f: F,
260    ) -> glib::SignalHandlerId {
261        self.connect_closure(
262            "pill-removed",
263            true,
264            closure_local!(|obj: Self, source: PillSource| {
265                f(&obj, source);
266            }),
267        )
268    }
269}