1use std::cell::RefCell;
8use std::rc::Rc;
9
10use gtk::prelude::*;
11use libadwaita::prelude::*;
12
13use crate::api_client::Tag;
14
15use super::LibraryWindowUi;
16use super::context_menu::show_alert_dialog;
17
18const PICKER_LIMIT: usize = 8;
20
21struct TagContext {
23 ui: Rc<LibraryWindowUi>,
24 on_search: Rc<dyn Fn(Tag)>,
25}
26
27#[derive(Clone)]
30pub(super) struct TagSection {
31 pub root: libadwaita::PreferencesGroup,
32 chips: gtk::FlowBox,
33 empty_label: gtk::Label,
34 add_btn: gtk::MenuButton,
35 asset_id: Rc<RefCell<String>>,
36 tags: Rc<RefCell<Vec<Tag>>>,
37 context: Rc<RefCell<Option<TagContext>>>,
38}
39
40impl TagSection {
41 pub(super) fn new() -> Self {
42 let add_btn = gtk::MenuButton::builder()
43 .icon_name("list-add-symbolic")
44 .tooltip_text("Add tag")
45 .valign(gtk::Align::Center)
46 .css_classes(["flat", "circular"])
47 .build();
48 let root = libadwaita::PreferencesGroup::builder()
49 .title("Tags")
50 .header_suffix(&add_btn)
51 .visible(false)
52 .build();
53 let chips = gtk::FlowBox::builder()
54 .selection_mode(gtk::SelectionMode::None)
55 .column_spacing(6)
56 .row_spacing(6)
57 .max_children_per_line(8)
58 .homogeneous(false)
59 .build();
60 let empty_label = gtk::Label::builder()
61 .label("No tags yet")
62 .xalign(0.0)
63 .css_classes(["dim-label"])
64 .build();
65 root.add(&chips);
66 root.add(&empty_label);
67 let section = Self {
68 root,
69 chips,
70 empty_label,
71 add_btn,
72 asset_id: Rc::default(),
73 tags: Rc::default(),
74 context: Rc::default(),
75 };
76 section.add_btn.set_popover(Some(§ion.build_picker()));
77 section
78 }
79
80 pub(super) fn bind(&self, ui: Rc<LibraryWindowUi>, on_search: Rc<dyn Fn(Tag)>) {
82 *self.context.borrow_mut() = Some(TagContext { ui, on_search });
83 }
84
85 pub(super) fn clear(&self) {
87 self.root.set_visible(false);
88 self.tags.borrow_mut().clear();
89 self.rebuild_chips();
90 }
91
92 pub(super) fn show(&self, asset_id: &str, mut tags: Vec<Tag>) {
93 tags.sort_by_key(|tag| tag.value.to_lowercase());
94 *self.asset_id.borrow_mut() = asset_id.to_string();
95 *self.tags.borrow_mut() = tags;
96 self.rebuild_chips();
97 self.root.set_visible(true);
98 }
99
100 fn ui(&self) -> Option<Rc<LibraryWindowUi>> {
101 self.context.borrow().as_ref().map(|c| c.ui.clone())
102 }
103
104 fn rebuild_chips(&self) {
105 self.chips.remove_all();
106 let tags = self.tags.borrow();
107 for tag in tags.iter() {
108 let slot = gtk::FlowBoxChild::builder()
109 .child(&self.chip(tag))
110 .halign(gtk::Align::Start)
111 .focusable(false)
112 .css_classes(["mimick-tag-slot"])
113 .build();
114 self.chips.insert(&slot, -1);
115 }
116 self.chips.set_visible(!tags.is_empty());
117 self.empty_label.set_visible(tags.is_empty());
118 }
119
120 fn chip(&self, tag: &Tag) -> gtk::Box {
122 let content = gtk::Box::builder()
123 .orientation(gtk::Orientation::Horizontal)
124 .spacing(6)
125 .build();
126 content.append(&color_dot(tag.color.as_deref(), 10));
127 content.append(
128 >k::Label::builder()
129 .use_markup(true)
130 .label(tag_markup(&tag.value))
131 .build(),
132 );
133 let name_btn = gtk::Button::builder()
134 .child(&content)
135 .tooltip_text(format!("Show photos tagged “{}”", tag.value))
136 .css_classes(["flat", "mimick-tag-name"])
137 .build();
138 let section = self.clone();
139 let clicked = tag.clone();
140 name_btn.connect_clicked(move |_| section.search(clicked.clone()));
141 let remove_btn = gtk::Button::builder()
142 .icon_name("window-close-symbolic")
143 .tooltip_text(format!("Remove “{}”", tag.value))
144 .css_classes(["flat", "mimick-tag-remove"])
145 .build();
146 let section = self.clone();
147 let removed = tag.clone();
148 remove_btn.connect_clicked(move |_| section.remove_tag(removed.clone()));
149 let chip = gtk::Box::builder()
150 .orientation(gtk::Orientation::Horizontal)
151 .halign(gtk::Align::Start)
153 .css_classes(["mimick-tag-chip"])
154 .build();
155 chip.append(&name_btn);
156 chip.append(&remove_btn);
157 chip
158 }
159
160 fn search(&self, tag: Tag) {
161 let on_search = self.context.borrow().as_ref().map(|c| c.on_search.clone());
162 if let Some(on_search) = on_search {
163 on_search(tag);
164 }
165 }
166
167 fn remove_tag(&self, tag: Tag) {
168 let Some(ui) = self.ui() else {
169 return;
170 };
171 let section = self.clone();
172 let asset_id = self.asset_id.borrow().clone();
173 glib::MainContext::default().spawn_local(async move {
174 match ui.ctx.api_client.untag_asset(&tag.id, &asset_id).await {
175 Ok(()) if *section.asset_id.borrow() == asset_id => {
176 section.tags.borrow_mut().retain(|t| t.id != tag.id);
177 section.rebuild_chips();
178 }
179 Ok(()) => {}
180 Err(err) => show_alert_dialog(&ui, "Could Not Remove Tag", &err),
181 }
182 });
183 }
184
185 fn add_tag(&self, value: String, existing: Option<Tag>) {
187 let Some(ui) = self.ui() else {
188 return;
189 };
190 let section = self.clone();
191 let asset_id = self.asset_id.borrow().clone();
192 glib::MainContext::default().spawn_local(async move {
193 let api = &ui.ctx.api_client;
194 let result = match existing {
195 Some(tag) => Ok(tag),
196 None => api.create_tag(&value).await,
197 };
198 let result = match result {
199 Ok(tag) => api.tag_asset(&tag.id, &asset_id).await.map(|()| tag),
200 Err(err) => Err(err),
201 };
202 match result {
203 Ok(tag) if *section.asset_id.borrow() == asset_id => {
204 let mut tags = section.tags.borrow().clone();
205 tags.retain(|t| t.id != tag.id);
206 tags.push(tag);
207 section.show(&asset_id, tags);
208 }
209 Ok(_) => {}
210 Err(err) => show_alert_dialog(&ui, "Could Not Add Tag", &err),
211 }
212 });
213 }
214
215 fn build_picker(&self) -> gtk::Popover {
217 let entry = gtk::SearchEntry::builder()
218 .placeholder_text("Find or create a tag")
219 .build();
220 let list = gtk::ListBox::builder()
221 .selection_mode(gtk::SelectionMode::None)
222 .css_classes(["boxed-list"])
223 .build();
224 let content = gtk::Box::builder()
225 .orientation(gtk::Orientation::Vertical)
226 .spacing(6)
227 .width_request(240)
228 .build();
229 content.append(&entry);
230 content.append(&list);
231 let popover = gtk::Popover::builder().child(&content).build();
232 let all_tags: Rc<RefCell<Vec<Tag>>> = Rc::default();
233
234 let picker = Picker {
235 section: self.clone(),
236 popover: popover.clone(),
237 entry: entry.clone(),
238 list,
239 all_tags,
240 };
241 let on_show = picker.clone();
242 popover.connect_show(move |_| on_show.open());
243 let on_change = picker.clone();
244 entry.connect_search_changed(move |_| on_change.refill());
245 entry.connect_activate(move |_| picker.choose_typed());
246 popover
247 }
248}
249
250#[derive(Clone)]
252struct Picker {
253 section: TagSection,
254 popover: gtk::Popover,
255 entry: gtk::SearchEntry,
256 list: gtk::ListBox,
257 all_tags: Rc<RefCell<Vec<Tag>>>,
258}
259
260impl Picker {
261 fn open(&self) {
262 self.entry.set_text("");
263 self.entry.grab_focus();
264 self.refill();
265 let Some(ui) = self.section.ui() else {
266 return;
267 };
268 let picker = self.clone();
269 glib::MainContext::default().spawn_local(async move {
270 match ui.ctx.api_client.fetch_tags().await {
271 Ok(tags) => {
272 *picker.all_tags.borrow_mut() = tags;
273 picker.refill();
274 }
275 Err(err) => log::warn!("Could not load tags: {}", err),
276 }
277 });
278 }
279
280 fn refill(&self) {
281 self.list.remove_all();
282 let query = self.entry.text().trim().to_string();
283 let applied = self.section.tags.borrow().clone();
284 let all_tags = self.all_tags.borrow().clone();
285 for tag in picker_matches(&all_tags, &applied, &query) {
286 let label = tag.value.clone();
287 self.append_row(&label, move |picker| {
288 picker.choose(tag.value.clone(), Some(tag.clone()))
289 });
290 }
291 if !query.is_empty()
292 && !all_tags
293 .iter()
294 .any(|t| t.value.eq_ignore_ascii_case(&query))
295 {
296 self.append_row(&format!("Create “{query}”"), move |picker| {
297 picker.choose(query.clone(), None)
298 });
299 }
300 }
301
302 fn append_row(&self, title: &str, on_activate: impl Fn(&Picker) + 'static) {
303 let row = libadwaita::ActionRow::builder()
304 .title(title)
305 .activatable(true)
306 .build();
307 let picker = self.clone();
308 row.connect_activated(move |_| on_activate(&picker));
309 self.list.append(&row);
310 }
311
312 fn choose_typed(&self) {
314 let query = self.entry.text().trim().to_string();
315 if query.is_empty() {
316 return;
317 }
318 let existing = self
319 .all_tags
320 .borrow()
321 .iter()
322 .find(|t| t.value.eq_ignore_ascii_case(&query))
323 .cloned();
324 self.choose(query, existing);
325 }
326
327 fn choose(&self, value: String, existing: Option<Tag>) {
328 self.popover.popdown();
329 self.section.add_tag(value, existing);
330 }
331}
332
333fn tag_markup(value: &str) -> String {
335 let (parent, leaf) = split_tag_path(value);
336 let leaf = format!("<b>{}</b>", glib::markup_escape_text(leaf));
337 match parent {
338 Some(parent) => format!(
339 "<span alpha=\"70%\">{}/</span>{}",
340 glib::markup_escape_text(parent),
341 leaf
342 ),
343 None => leaf,
344 }
345}
346
347fn split_tag_path(value: &str) -> (Option<&str>, &str) {
349 match value.rsplit_once('/') {
350 Some((parent, leaf)) if !parent.is_empty() => (Some(parent), leaf),
351 _ => (None, value),
352 }
353}
354
355fn tag_color(color: Option<&str>) -> Option<gdk4::RGBA> {
357 color.and_then(|c| gdk4::RGBA::parse(c).ok())
358}
359
360pub(super) fn color_dot(color: Option<&str>, size: i32) -> gtk::DrawingArea {
362 let rgba = tag_color(color)
363 .unwrap_or_else(|| libadwaita::StyleManager::default().accent_color().to_rgba());
364 let dot = gtk::DrawingArea::builder()
365 .content_width(size)
366 .content_height(size)
367 .valign(gtk::Align::Center)
368 .build();
369 dot.set_draw_func(move |_, cr, width, height| {
370 let radius = f64::from(width.min(height)) / 2.0;
371 cr.arc(radius, radius, radius, 0.0, std::f64::consts::TAU);
372 cr.set_source_rgba(
373 rgba.red().into(),
374 rgba.green().into(),
375 rgba.blue().into(),
376 rgba.alpha().into(),
377 );
378 let _ = cr.fill();
379 });
380 dot
381}
382
383fn picker_matches(all_tags: &[Tag], applied: &[Tag], query: &str) -> Vec<Tag> {
385 let query = query.to_lowercase();
386 all_tags
387 .iter()
388 .filter(|tag| !applied.iter().any(|a| a.id == tag.id))
389 .filter(|tag| tag.value.to_lowercase().contains(&query))
390 .take(PICKER_LIMIT)
391 .cloned()
392 .collect()
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn tag(id: &str, value: &str) -> Tag {
400 Tag {
401 id: id.into(),
402 name: value.rsplit('/').next().unwrap_or(value).into(),
403 value: value.into(),
404 color: None,
405 }
406 }
407
408 #[test]
409 fn picker_hides_applied_tags_and_filters_case_insensitively() {
410 let all = [
411 tag("1", "Trips/2024"),
412 tag("2", "Family"),
413 tag("3", "trips"),
414 ];
415 let applied = [tag("3", "trips")];
416 let matches = picker_matches(&all, &applied, "TRIP");
417 assert_eq!(matches, vec![tag("1", "Trips/2024")]);
418 }
419
420 #[test]
421 fn split_tag_path_separates_parent_and_leaf() {
422 assert_eq!(split_tag_path("Trips/2024"), (Some("Trips"), "2024"));
423 assert_eq!(split_tag_path("A/B/C"), (Some("A/B"), "C"));
424 assert_eq!(split_tag_path("Family"), (None, "Family"));
425 assert_eq!(split_tag_path("/odd"), (None, "/odd"));
426 }
427
428 #[test]
429 fn tag_markup_escapes_and_bolds_the_leaf() {
430 assert_eq!(tag_markup("Family"), "<b>Family</b>");
431 assert_eq!(
432 tag_markup("R&D/Q1"),
433 "<span alpha=\"70%\">R&D/</span><b>Q1</b>"
434 );
435 }
436
437 #[test]
438 fn tag_color_parses_hex_and_ignores_invalid() {
439 let red = tag_color(Some("#ff0000")).unwrap();
440 assert_eq!(red.red(), 1.0);
441 assert!(tag_color(Some("not a color")).is_none());
442 assert!(tag_color(None).is_none());
443 }
444
445 #[test]
446 fn picker_lists_at_most_the_limit() {
447 let all: Vec<Tag> = (0..20)
448 .map(|i| tag(&i.to_string(), &format!("t{i}")))
449 .collect();
450 assert_eq!(picker_matches(&all, &[], "").len(), PICKER_LIMIT);
451 }
452}