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: >k::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}