fractal/components/pill/
search_entry.rs

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