Skip to main content

mimick/library/
asset_tags.rs

1//! Tags section of the lightbox details pane.
2//!
3//! Each tag is a chip: clicking its name searches for that tag, × removes it from
4//! the asset. "Add Tag" opens a picker of existing tags that can also create a new
5//! one (Immich creates any missing parents for nested `Parent/Child` tags).
6
7use 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
18/// Most tags listed in the picker at once; typing narrows the list.
19const PICKER_LIMIT: usize = 8;
20
21/// Window and search handler, set once by the lightbox through `bind`.
22struct TagContext {
23    ui: Rc<LibraryWindowUi>,
24    on_search: Rc<dyn Fn(Tag)>,
25}
26
27/// "Tags" group (matching the EXIF groups) with an add button and wrapping tag pills.
28/// Cheap to clone.
29#[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(&section.build_picker()));
77        section
78    }
79
80    /// Connect the section to the library window; `on_search` runs when a tag is clicked.
81    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    /// Hide until the next asset's tags arrive (local-only assets never show it).
86    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    /// One pill: color dot and label (click to search), plus a remove button.
121    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            &gtk::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            // The FlowBox hands spare row width to its children; keep the pill tight.
152            .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    /// Tag the current asset, creating the tag first when `existing` is `None`.
186    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    /// Popover with a search entry over the server's tags, refreshed each time it opens.
216    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/// State behind the "Add Tag" popover.
251#[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    /// Enter applies an exact (case-insensitive) match, otherwise creates the typed tag.
313    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
333/// Tag label with the parent path dimmed and the leaf bold: "Trips/<b>2024</b>".
334fn 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
347/// `("Trips", "2024")` for `Trips/2024`; no parent for a top-level tag.
348fn 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
355/// Parse an Immich tag color (`#rrggbb`); tags without one use the theme accent.
356fn tag_color(color: Option<&str>) -> Option<gdk4::RGBA> {
357    color.and_then(|c| gdk4::RGBA::parse(c).ok())
358}
359
360/// Small filled circle in the tag's color.
361pub(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
383/// Server tags matching `query` (case-insensitive substring) that aren't applied yet.
384fn 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&amp;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}