Skip to main content

mimick/library/
people_view.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3
4use gtk::prelude::*;
5use libadwaita::prelude::*;
6
7use crate::api_client::{Person, PersonUpdate};
8
9use super::LibraryWindowUi;
10use super::context_menu::show_alert_dialog;
11use super::dialogs::{TextPrompt, popover_menu, prompt_text};
12use super::explore_view::spawn_person_thumbnail;
13use super::state::LibrarySource;
14
15const AVATAR_SIZE: i32 = 140;
16
17pub(super) struct PeopleView {
18    pub root: gtk::Box,
19    search: gtk::SearchEntry,
20    show_hidden: gtk::ToggleButton,
21    grid: gtk::FlowBox,
22    empty: libadwaita::StatusPage,
23    pub people: RefCell<Vec<Person>>,
24}
25
26pub(super) fn build_people_view() -> PeopleView {
27    let (toolbar, search, show_hidden) = build_toolbar();
28    let grid = gtk::FlowBox::builder()
29        .selection_mode(gtk::SelectionMode::None)
30        .homogeneous(true)
31        .min_children_per_line(2)
32        .max_children_per_line(30)
33        .column_spacing(4)
34        .row_spacing(12)
35        .valign(gtk::Align::Start)
36        .margin_top(12)
37        .margin_bottom(12)
38        .margin_start(8)
39        .margin_end(8)
40        .build();
41    let empty = libadwaita::StatusPage::builder()
42        .icon_name("avatar-default-symbolic")
43        .vexpand(true)
44        .visible(false)
45        .build();
46    let root = gtk::Box::builder()
47        .orientation(gtk::Orientation::Vertical)
48        .build();
49    root.append(&toolbar);
50    root.append(&scrolled(&grid, &empty));
51    PeopleView {
52        root,
53        search,
54        show_hidden,
55        grid,
56        empty,
57        people: RefCell::default(),
58    }
59}
60
61fn build_toolbar() -> (gtk::Box, gtk::SearchEntry, gtk::ToggleButton) {
62    let search = gtk::SearchEntry::builder()
63        .placeholder_text("Search people")
64        .hexpand(true)
65        .build();
66    let show_hidden = gtk::ToggleButton::builder()
67        .icon_name("view-reveal-symbolic")
68        .tooltip_text("Show hidden people")
69        .build();
70    let toolbar = gtk::Box::builder()
71        .orientation(gtk::Orientation::Horizontal)
72        .spacing(8)
73        .margin_top(12)
74        .margin_start(12)
75        .margin_end(12)
76        .build();
77    toolbar.append(&search);
78    toolbar.append(&show_hidden);
79    (toolbar, search, show_hidden)
80}
81
82fn scrolled(grid: &gtk::FlowBox, empty: &libadwaita::StatusPage) -> gtk::ScrolledWindow {
83    let content = gtk::Box::builder()
84        .orientation(gtk::Orientation::Vertical)
85        .build();
86    content.append(grid);
87    content.append(empty);
88    gtk::ScrolledWindow::builder()
89        .hscrollbar_policy(gtk::PolicyType::Never)
90        .vexpand(true)
91        .child(&content)
92        .build()
93}
94
95pub(super) fn connect_people_view(ui: Rc<LibraryWindowUi>) {
96    let ui_search = ui.clone();
97    ui.people
98        .search
99        .connect_search_changed(move |_| render(&ui_search));
100    let ui_hidden = ui.clone();
101    ui.people.show_hidden.connect_toggled(move |button| {
102        button.set_icon_name(if button.is_active() {
103            "view-conceal-symbolic"
104        } else {
105            "view-reveal-symbolic"
106        });
107        button.set_tooltip_text(Some(if button.is_active() {
108            "Hide hidden people"
109        } else {
110            "Show hidden people"
111        }));
112        render(&ui_hidden);
113    });
114}
115
116pub(super) fn show(ui: &Rc<LibraryWindowUi>) {
117    ui.content_stack.set_visible_child_name("people");
118    reload(ui.clone());
119}
120
121pub(super) fn reload(ui: Rc<LibraryWindowUi>) {
122    glib::MainContext::default().spawn_local(async move {
123        match ui.ctx.api_client.fetch_people(true).await {
124            Ok(people) => {
125                *ui.people.people.borrow_mut() = people;
126                render(&ui);
127            }
128            Err(err) => show_alert_dialog(
129                &ui,
130                "Could Not Load People",
131                &format!("{err}\n\nYour API key needs the 'person.read' permission."),
132            ),
133        }
134    });
135}
136
137fn render(ui: &Rc<LibraryWindowUi>) {
138    let view = &ui.people;
139    while let Some(child) = view.grid.first_child() {
140        view.grid.remove(&child);
141    }
142    let query = view.search.text().to_lowercase();
143    let people = view.people.borrow();
144    let shown = visible_people(&people, &query, view.show_hidden.is_active());
145    for person in &shown {
146        view.grid.append(&person_tile(ui, person));
147    }
148    let (title, description) = if people.is_empty() {
149        (
150            "No People",
151            "People appear here once Immich has recognized faces in your photos.",
152        )
153    } else {
154        ("No Matches", "No one matches your search.")
155    };
156    view.empty.set_title(title);
157    view.empty.set_description(Some(description));
158    view.empty.set_visible(shown.is_empty());
159    view.grid.set_visible(!shown.is_empty());
160}
161
162fn visible_people<'a>(people: &'a [Person], query: &str, show_hidden: bool) -> Vec<&'a Person> {
163    let mut shown: Vec<&Person> = people
164        .iter()
165        .filter(|person| show_hidden || !person.is_hidden)
166        .filter(|person| query.is_empty() || person.name.to_lowercase().contains(query))
167        .collect();
168    shown.sort_by_key(|person| !person.is_favorite);
169    shown
170}
171
172fn tile_avatar(ui: &LibraryWindowUi, person: &Person) -> gtk::Overlay {
173    let overlay = gtk::Overlay::builder()
174        .child(&avatar(ui, person, AVATAR_SIZE))
175        .halign(gtk::Align::Center)
176        .build();
177    if person.is_favorite {
178        let badge = gtk::Image::builder()
179            .icon_name("mimick-heart-filled-symbolic")
180            .halign(gtk::Align::End)
181            .valign(gtk::Align::End)
182            .tooltip_text("Favorite")
183            .css_classes(["mimick-person-favorite"])
184            .build();
185        overlay.add_overlay(&badge);
186    }
187    overlay
188}
189
190pub(super) fn display_name(person: &Person) -> &str {
191    if person.name.is_empty() {
192        "Unnamed"
193    } else {
194        &person.name
195    }
196}
197
198pub(super) fn avatar(ui: &LibraryWindowUi, person: &Person, size: i32) -> libadwaita::Avatar {
199    let avatar = libadwaita::Avatar::new(size, Some(&person.name), true);
200    let avatar_for_image = avatar.clone();
201    spawn_person_thumbnail(ui.ctx.clone(), person.id.clone(), move |texture| {
202        avatar_for_image.set_custom_image(Some(texture))
203    });
204    avatar
205}
206
207fn person_tile(ui: &Rc<LibraryWindowUi>, person: &Person) -> gtk::Overlay {
208    let label = gtk::Label::builder()
209        .label(display_name(person))
210        .ellipsize(gtk::pango::EllipsizeMode::End)
211        .max_width_chars(14)
212        .build();
213    if person.name.is_empty() {
214        label.add_css_class("dim-label");
215    }
216    let inner = gtk::Box::builder()
217        .orientation(gtk::Orientation::Vertical)
218        .spacing(6)
219        .halign(gtk::Align::Center)
220        .build();
221    inner.append(&tile_avatar(ui, person));
222    inner.append(&label);
223    let open = gtk::Button::builder()
224        .child(&inner)
225        .tooltip_text(format!("Show photos of {}", display_name(person)))
226        .css_classes(["flat"])
227        .build();
228    if person.is_hidden {
229        open.set_opacity(0.5);
230    }
231    let (ui_open, id, name) = (
232        ui.clone(),
233        person.id.clone(),
234        display_name(person).to_string(),
235    );
236    open.connect_clicked(move |_| {
237        super::navigation::navigate_source(
238            &ui_open,
239            LibrarySource::Person {
240                id: id.clone(),
241                name: name.clone(),
242            },
243        );
244    });
245    let menu = gtk::MenuButton::builder()
246        .icon_name("view-more-symbolic")
247        .tooltip_text("More")
248        .halign(gtk::Align::End)
249        .valign(gtk::Align::Start)
250        .css_classes(["circular", "osd"])
251        .build();
252    popover_menu(&menu, person_menu_items(ui, person));
253    let tile = gtk::Overlay::builder().child(&open).build();
254    tile.add_overlay(&menu);
255    tile
256}
257
258pub(super) fn person_menu_items(
259    ui: &Rc<LibraryWindowUi>,
260    person: &Person,
261) -> Vec<(String, Box<dyn Fn()>)> {
262    let rename_label = if person.name.is_empty() {
263        "Add Name…"
264    } else {
265        "Rename…"
266    };
267    let hide_label = if person.is_hidden { "Unhide" } else { "Hide" };
268    let favorite_label = if person.is_favorite {
269        "Remove from Favorites"
270    } else {
271        "Add to Favorites"
272    };
273    let (ui_favorite, p_favorite) = (ui.clone(), person.clone());
274    let (ui_rename, p_rename) = (ui.clone(), person.clone());
275    let (ui_birthday, p_birthday) = (ui.clone(), person.clone());
276    let (ui_hide, p_hide) = (ui.clone(), person.clone());
277    let (ui_merge, p_merge) = (ui.clone(), person.clone());
278    vec![
279        (
280            rename_label.to_string(),
281            Box::new(move || rename(&ui_rename, &p_rename)),
282        ),
283        (
284            favorite_label.to_string(),
285            Box::new(move || set_favorite(&ui_favorite, &p_favorite, !p_favorite.is_favorite)),
286        ),
287        (
288            "Set Birthday…".to_string(),
289            Box::new(move || edit_birthday(&ui_birthday, &p_birthday)),
290        ),
291        (
292            hide_label.to_string(),
293            Box::new(move || {
294                let update = PersonUpdate {
295                    is_hidden: Some(!p_hide.is_hidden),
296                    ..PersonUpdate::default()
297                };
298                save(&ui_hide, &p_hide.id, update);
299            }),
300        ),
301        (
302            "Merge People…".to_string(),
303            Box::new(move || super::person_merge_dialog::present(&ui_merge, &p_merge)),
304        ),
305    ]
306}
307
308fn save(ui: &Rc<LibraryWindowUi>, person_id: &str, update: PersonUpdate) {
309    let (ui, person_id) = (ui.clone(), person_id.to_string());
310    glib::MainContext::default().spawn_local(async move {
311        if let Err(err) = ui.ctx.api_client.update_person(&person_id, &update).await {
312            show_alert_dialog(&ui, "Could Not Update Person", &err);
313            return;
314        }
315        if let Some(person) = ui
316            .people
317            .people
318            .borrow_mut()
319            .iter_mut()
320            .find(|person| person.id == person_id)
321        {
322            apply_update(person, &update);
323        }
324        render(&ui);
325        super::source_header::refresh(&ui);
326    });
327}
328
329pub(super) fn set_favorite(ui: &Rc<LibraryWindowUi>, person: &Person, favorite: bool) {
330    let update = PersonUpdate {
331        is_favorite: Some(favorite),
332        ..PersonUpdate::default()
333    };
334    save(ui, &person.id, update);
335}
336
337fn apply_update(person: &mut Person, update: &PersonUpdate) {
338    if let Some(name) = &update.name {
339        person.name = name.clone();
340    }
341    if let Some(hidden) = update.is_hidden {
342        person.is_hidden = hidden;
343    }
344    if let Some(favorite) = update.is_favorite {
345        person.is_favorite = favorite;
346    }
347    if let Some(birth_date) = &update.birth_date {
348        person.birth_date = birth_date.clone();
349    }
350}
351
352fn rename(ui: &Rc<LibraryWindowUi>, person: &Person) {
353    let ui_save = ui.clone();
354    let person_id = person.id.clone();
355    prompt_text(
356        ui,
357        TextPrompt {
358            heading: if person.name.is_empty() {
359                "Add Name"
360            } else {
361                "Rename Person"
362            },
363            body: "This name is used in search and on the People page.",
364            initial: &person.name,
365            placeholder: "Name",
366            accept: "Save",
367        },
368        move |name| {
369            let update = PersonUpdate {
370                name: Some(name),
371                ..PersonUpdate::default()
372            };
373            save(&ui_save, &person_id, update);
374        },
375    );
376}
377
378fn edit_birthday(ui: &Rc<LibraryWindowUi>, person: &Person) {
379    let calendar = gtk::Calendar::builder().halign(gtk::Align::Center).build();
380    if let Some((year, month, day)) = person.birth_date.as_deref().and_then(parse_date) {
381        calendar.set_year(year);
382        calendar.set_month(month - 1);
383        calendar.set_day(day);
384    }
385    let dialog = libadwaita::AlertDialog::builder()
386        .heading("Set Birthday")
387        .body(format!(
388            "Immich shows {}'s age on photos.",
389            display_name(person)
390        ))
391        .extra_child(&calendar)
392        .build();
393    dialog.add_response("cancel", "Cancel");
394    if person.birth_date.is_some() {
395        dialog.add_response("clear", "Remove");
396        dialog.set_response_appearance("clear", libadwaita::ResponseAppearance::Destructive);
397    }
398    dialog.add_response("save", "Save");
399    dialog.set_response_appearance("save", libadwaita::ResponseAppearance::Suggested);
400    dialog.set_default_response(Some("save"));
401    dialog.set_close_response("cancel");
402    let (ui_save, person_id) = (ui.clone(), person.id.clone());
403    dialog.connect_response(None, move |_, response| {
404        let birth_date = match response {
405            "save" => Some(format!(
406                "{:04}-{:02}-{:02}",
407                calendar.year(),
408                calendar.month() + 1,
409                calendar.day()
410            )),
411            "clear" => None,
412            _ => return,
413        };
414        let update = PersonUpdate {
415            birth_date: Some(birth_date),
416            ..PersonUpdate::default()
417        };
418        save(&ui_save, &person_id, update);
419    });
420    dialog.present(Some(&ui.window));
421}
422
423fn parse_date(date: &str) -> Option<(i32, i32, i32)> {
424    let mut parts = date.get(..10)?.split('-');
425    let year = parts.next()?.parse().ok()?;
426    let month = parts.next()?.parse().ok()?;
427    let day = parts.next()?.parse().ok()?;
428    Some((year, month, day))
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    fn person(id: &str, name: &str, hidden: bool) -> Person {
436        Person {
437            id: id.into(),
438            name: name.into(),
439            is_hidden: hidden,
440            birth_date: None,
441            is_favorite: false,
442        }
443    }
444
445    #[test]
446    fn visible_people_filters_hidden_and_search() {
447        let people = vec![
448            person("1", "Ana", false),
449            person("2", "Ben", true),
450            person("3", "", false),
451        ];
452        let ids = |shown: Vec<&Person>| shown.iter().map(|p| p.id.clone()).collect::<Vec<_>>();
453        assert_eq!(ids(visible_people(&people, "", false)), ["1", "3"]);
454        assert_eq!(ids(visible_people(&people, "", true)), ["1", "2", "3"]);
455        assert_eq!(ids(visible_people(&people, "be", true)), ["2"]);
456    }
457
458    #[test]
459    fn favorites_come_first_in_their_original_order() {
460        let mut people = vec![
461            person("1", "Ana", false),
462            person("2", "Ben", false),
463            person("3", "Cy", false),
464        ];
465        people[1].is_favorite = true;
466        people[2].is_favorite = true;
467        let ids: Vec<&str> = visible_people(&people, "", false)
468            .iter()
469            .map(|p| p.id.as_str())
470            .collect();
471        assert_eq!(ids, ["2", "3", "1"]);
472    }
473
474    #[test]
475    fn apply_update_changes_only_set_fields() {
476        let mut ana = person("1", "Ana", false);
477        apply_update(
478            &mut ana,
479            &PersonUpdate {
480                is_hidden: Some(true),
481                birth_date: Some(Some("1990-05-04".into())),
482                ..PersonUpdate::default()
483            },
484        );
485        assert_eq!(ana.name, "Ana");
486        assert!(ana.is_hidden);
487        assert_eq!(ana.birth_date.as_deref(), Some("1990-05-04"));
488    }
489
490    #[test]
491    fn parse_date_reads_iso_dates() {
492        assert_eq!(parse_date("1990-05-04"), Some((1990, 5, 4)));
493        assert_eq!(parse_date("1990-05-04T00:00:00.000Z"), Some((1990, 5, 4)));
494        assert_eq!(parse_date("bad"), None);
495    }
496}