fractal/components/pill/
search_entry.rs1use 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 #[property(get = Self::text)]
27 text: PhantomData<glib::GString>,
28 #[property(get = Self::editable, set = Self::set_editable, explicit_notify)]
30 editable: PhantomData<bool>,
31 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 return;
74 }
75
76 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 ¤t == 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 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 fn text(&self) -> glib::GString {
150 let (start, end) = self.text_buffer.bounds();
151 self.text_buffer.text(&start, &end, false)
152 }
153
154 fn editable(&self) -> bool {
156 self.text_view.is_editable()
157 }
158
159 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 pub(super) fn add_pill(&self, source: &PillSource) {
171 let identifier = source.identifier();
172
173 if self.pills.borrow().contains_key(&identifier) {
175 return;
176 }
177
178 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 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 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 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 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 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 pub(crate) fn add_pill(&self, source: &impl IsA<PillSource>) {
241 self.imp().add_pill(source.upcast_ref());
242 }
243
244 pub(crate) fn remove_pill(&self, identifier: &str) {
246 self.imp().remove_pill(identifier);
247 }
248
249 pub(crate) fn clear(&self) {
251 self.imp().clear();
252 }
253
254 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}