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