1use std::cell::{Cell, RefCell};
8use std::rc::Rc;
9use std::sync::Arc;
10
11use gdk4::Texture;
12use glib::Bytes;
13use gtk::prelude::*;
14
15use crate::api_client::{ExploreSection, Person, PlaceItem, ThumbnailSize};
16use crate::app_context::AppContext;
17
18type ExploreClick = Rc<dyn Fn(&str, String, String)>;
19type PersonClick = Rc<dyn Fn(String, String)>;
20
21const INITIAL_TILE_COUNT: usize = 16;
22const RECENTS_EXPANDED_COUNT: usize = 30;
23
24pub struct ExploreViewParts {
26 pub root: gtk::ScrolledWindow,
27 pub populated: Rc<Cell<bool>>,
28 people_row: gtk::Box,
29 recents_grid: gtk::FlowBox,
30 places_grid: gtk::FlowBox,
31 things_grid: gtk::FlowBox,
32 people_section: gtk::Box,
33 recents_section: gtk::Box,
34 places_section: gtk::Box,
35 things_section: gtk::Box,
36 people_spinner: gtk::Spinner,
37 recents_spinner: gtk::Spinner,
38 places_spinner: gtk::Spinner,
39 things_spinner: gtk::Spinner,
40 pub people_filter_button: gtk::MenuButton,
41 cached_people: Rc<RefCell<Vec<Person>>>,
42 cached_people_click: Rc<RefCell<Option<PersonClick>>>,
43 cached_places: Rc<RefCell<Vec<PlaceItem>>>,
44 cached_places_click: Rc<RefCell<Option<ExploreClick>>>,
45 pub search_query: Rc<RefCell<String>>,
46 pub filter_entry: gtk::SearchEntry,
47 cached_ctx: Rc<RefCell<Option<Arc<AppContext>>>>,
48}
49
50pub fn build_explore_view() -> ExploreViewParts {
52 let outer = gtk::Box::builder()
53 .orientation(gtk::Orientation::Vertical)
54 .spacing(24)
55 .margin_top(16)
56 .margin_bottom(16)
57 .margin_start(16)
58 .margin_end(16)
59 .build();
60
61 let (people_section, people_row, people_spinner, people_filter_button) = build_people_section();
62 let (recents_section, recents_grid, recents_spinner) = build_tile_section("Recently Added");
63 let (places_section, places_grid, places_spinner) = build_tile_section("Places");
64 let (things_section, things_grid, things_spinner) = build_tile_section("Things");
65
66 let filter_entry = gtk::SearchEntry::builder()
67 .placeholder_text("Filter people and places")
68 .hexpand(true)
69 .max_width_chars(20)
70 .build();
71 outer.append(&filter_entry);
72
73 outer.append(&people_section);
74 outer.append(&places_section);
75 outer.append(&recents_section);
76 outer.append(&things_section);
77
78 let root = gtk::ScrolledWindow::builder()
79 .child(&outer)
80 .hscrollbar_policy(gtk::PolicyType::Never)
81 .vexpand(true)
82 .hexpand(true)
83 .build();
84
85 ExploreViewParts {
86 root,
87 populated: Rc::new(Cell::new(false)),
88 people_row,
89 recents_grid,
90 places_grid,
91 things_grid,
92 people_section,
93 recents_section,
94 places_section,
95 things_section,
96 people_spinner,
97 recents_spinner,
98 places_spinner,
99 things_spinner,
100 people_filter_button,
101 cached_people: Rc::new(RefCell::new(Vec::new())),
102 cached_people_click: Rc::new(RefCell::new(None)),
103 cached_places: Rc::new(RefCell::new(Vec::new())),
104 cached_places_click: Rc::new(RefCell::new(None)),
105 search_query: Rc::new(RefCell::new(String::new())),
106 filter_entry,
107 cached_ctx: Rc::new(RefCell::new(None)),
108 }
109}
110
111pub fn show_loading(parts: &ExploreViewParts) {
115 for (section, spinner) in [
116 (&parts.people_section, &parts.people_spinner),
117 (&parts.recents_section, &parts.recents_spinner),
118 (&parts.places_section, &parts.places_spinner),
119 (&parts.things_section, &parts.things_spinner),
120 ] {
121 section.set_visible(true);
122 spinner.set_visible(true);
123 spinner.start();
124 }
125}
126
127fn stop_spinner(spinner: >k::Spinner) {
128 spinner.stop();
129 spinner.set_visible(false);
130}
131
132fn build_people_section() -> (gtk::Box, gtk::Box, gtk::Spinner, gtk::MenuButton) {
134 let section = gtk::Box::builder()
135 .orientation(gtk::Orientation::Vertical)
136 .spacing(8)
137 .visible(false)
138 .build();
139
140 let header = gtk::Box::builder()
141 .orientation(gtk::Orientation::Horizontal)
142 .spacing(8)
143 .build();
144 let title = heading("People");
145 title.set_hexpand(true);
146 header.append(&title);
147 let spinner = gtk::Spinner::builder()
148 .visible(false)
149 .valign(gtk::Align::Center)
150 .build();
151 header.append(&spinner);
152 let filter_button = gtk::MenuButton::builder()
153 .icon_name("view-more-symbolic")
154 .tooltip_text("Filter people")
155 .css_classes(["flat"])
156 .valign(gtk::Align::Center)
157 .build();
158 header.append(&filter_button);
159 section.append(&header);
160
161 let row = gtk::Box::builder()
162 .orientation(gtk::Orientation::Horizontal)
163 .spacing(12)
164 .build();
165 let scroller = gtk::ScrolledWindow::builder()
166 .child(&row)
167 .vscrollbar_policy(gtk::PolicyType::Never)
168 .hscrollbar_policy(gtk::PolicyType::Automatic)
169 .height_request(140)
170 .build();
171 section.append(&scroller);
172 (section, row, spinner, filter_button)
173}
174
175fn build_tile_section(title: &str) -> (gtk::Box, gtk::FlowBox, gtk::Spinner) {
177 let section = gtk::Box::builder()
178 .orientation(gtk::Orientation::Vertical)
179 .spacing(8)
180 .visible(false)
181 .build();
182 let header = gtk::Box::builder()
183 .orientation(gtk::Orientation::Horizontal)
184 .spacing(8)
185 .build();
186 let title_label = heading(title);
187 title_label.set_hexpand(true);
188 header.append(&title_label);
189 let spinner = gtk::Spinner::builder()
190 .visible(false)
191 .valign(gtk::Align::Center)
192 .build();
193 header.append(&spinner);
194 section.append(&header);
195 let grid = gtk::FlowBox::builder()
196 .selection_mode(gtk::SelectionMode::None)
197 .row_spacing(8)
198 .column_spacing(8)
199 .min_children_per_line(2)
200 .max_children_per_line(20)
201 .homogeneous(true)
202 .halign(gtk::Align::Start)
203 .build();
204 section.append(&grid);
205 (section, grid, spinner)
206}
207
208fn heading(text: &str) -> gtk::Label {
210 gtk::Label::builder()
211 .label(text)
212 .xalign(0.0)
213 .css_classes(vec!["title-2".to_string()])
214 .build()
215}
216
217pub fn populate_people<F>(
223 parts: &ExploreViewParts,
224 ctx: Arc<AppContext>,
225 people: Vec<Person>,
226 on_click: F,
227) where
228 F: Fn(String, String) + 'static,
229{
230 stop_spinner(&parts.people_spinner);
231 *parts.cached_people.borrow_mut() = people;
232 *parts.cached_people_click.borrow_mut() = Some(Rc::new(on_click));
233 *parts.cached_ctx.borrow_mut() = Some(ctx.clone());
234 render_people(parts, ctx);
235}
236
237pub fn set_people_search(parts: &ExploreViewParts, query: &str) {
241 *parts.search_query.borrow_mut() = query.to_string();
242 let Some(ctx) = parts.cached_ctx.borrow().clone() else {
243 return;
244 };
245 render_people(parts, ctx.clone());
246 render_places_filtered(parts, ctx);
247}
248
249fn render_places_filtered(parts: &ExploreViewParts, ctx: Arc<AppContext>) {
251 while let Some(child) = parts.places_grid.first_child() {
252 parts.places_grid.remove(&child);
253 }
254 let places = parts.cached_places.borrow();
255 let query = parts.search_query.borrow().to_ascii_lowercase();
256 let filtered: Vec<&PlaceItem> = if query.is_empty() {
257 places.iter().collect()
258 } else {
259 places
260 .iter()
261 .filter(|p| p.city.to_ascii_lowercase().contains(&query))
262 .collect()
263 };
264 parts.places_section.set_visible(!filtered.is_empty());
265 let on_click = match parts.cached_places_click.borrow().clone() {
266 Some(cb) => cb,
267 None => return,
268 };
269 for place in filtered.into_iter().take(INITIAL_TILE_COUNT) {
270 let tile = explore_tile(
271 ctx.clone(),
272 "place",
273 &place.city,
274 &place.asset_id,
275 on_click.clone(),
276 );
277 parts.places_grid.append(&tile);
278 }
279}
280
281fn render_people(parts: &ExploreViewParts, ctx: Arc<AppContext>) {
282 while let Some(child) = parts.people_row.first_child() {
283 parts.people_row.remove(&child);
284 }
285 let (show_unnamed, show_hidden) = {
286 let cfg = ctx.config.read();
287 (cfg.data.show_unnamed_faces, cfg.data.show_hidden_faces)
288 };
289 let cached = parts.cached_people.borrow();
290 let query = parts.search_query.borrow().to_ascii_lowercase();
291 let filtered: Vec<&Person> = cached
292 .iter()
293 .filter(|p| show_hidden || !p.is_hidden)
294 .filter(|p| show_unnamed || !p.name.is_empty())
295 .filter(|p| query.is_empty() || p.name.to_ascii_lowercase().contains(&query))
296 .collect();
297 parts.people_section.set_visible(!filtered.is_empty());
298 let on_click = parts.cached_people_click.borrow().clone();
299 let Some(on_click) = on_click else {
300 return;
301 };
302 for person in filtered.into_iter().take(40) {
303 let tile = person_tile(ctx.clone(), person, on_click.clone());
304 parts.people_row.append(&tile);
305 }
306}
307
308pub fn wire_people_filter<F>(parts: &ExploreViewParts, ctx: Arc<AppContext>, on_change: F)
312where
313 F: Fn() + 'static,
314{
315 let popover = gtk::Popover::builder().build();
316 let body = gtk::Box::builder()
317 .orientation(gtk::Orientation::Vertical)
318 .spacing(6)
319 .margin_top(8)
320 .margin_bottom(8)
321 .margin_start(8)
322 .margin_end(8)
323 .build();
324
325 let (show_unnamed, show_hidden) = {
326 let cfg = ctx.config.read();
327 (cfg.data.show_unnamed_faces, cfg.data.show_hidden_faces)
328 };
329
330 let unnamed_check = gtk::CheckButton::builder()
331 .label("Show unnamed")
332 .active(show_unnamed)
333 .build();
334 let hidden_check = gtk::CheckButton::builder()
335 .label("Show hidden")
336 .active(show_hidden)
337 .build();
338 body.append(&unnamed_check);
339 body.append(&hidden_check);
340 popover.set_child(Some(&body));
341 parts.people_filter_button.set_popover(Some(&popover));
342
343 let on_change = Rc::new(on_change);
344
345 let ctx_a = ctx.clone();
346 let parts_a = clone_parts_handles(parts);
347 let on_change_a = on_change.clone();
348 unnamed_check.connect_toggled(move |btn| {
349 {
350 let mut cfg = ctx_a.config.write();
351 cfg.data.show_unnamed_faces = btn.is_active();
352 if !cfg.save() {
353 log::error!("Failed to save config after toggling show_unnamed_faces");
354 }
355 }
356 render_people(&parts_a, ctx_a.clone());
357 on_change_a();
358 });
359
360 let ctx_b = ctx.clone();
361 let parts_b = clone_parts_handles(parts);
362 let on_change_b = on_change.clone();
363 hidden_check.connect_toggled(move |btn| {
364 let active = btn.is_active();
365 let hidden_count = parts_b
366 .cached_people
367 .borrow()
368 .iter()
369 .filter(|p| p.is_hidden)
370 .count();
371 log::debug!(
372 "show_hidden_faces toggled to {} ({} hidden people in cache)",
373 active,
374 hidden_count
375 );
376 {
377 let mut cfg = ctx_b.config.write();
378 cfg.data.show_hidden_faces = active;
379 if !cfg.save() {
380 log::error!("Failed to save config after toggling show_hidden_faces");
381 }
382 }
383 render_people(&parts_b, ctx_b.clone());
384 on_change_b();
385 });
386}
387
388fn clone_parts_handles(parts: &ExploreViewParts) -> ExploreViewParts {
391 ExploreViewParts {
392 root: parts.root.clone(),
393 populated: parts.populated.clone(),
394 people_row: parts.people_row.clone(),
395 recents_grid: parts.recents_grid.clone(),
396 places_grid: parts.places_grid.clone(),
397 things_grid: parts.things_grid.clone(),
398 people_section: parts.people_section.clone(),
399 recents_section: parts.recents_section.clone(),
400 places_section: parts.places_section.clone(),
401 things_section: parts.things_section.clone(),
402 people_spinner: parts.people_spinner.clone(),
403 recents_spinner: parts.recents_spinner.clone(),
404 places_spinner: parts.places_spinner.clone(),
405 things_spinner: parts.things_spinner.clone(),
406 people_filter_button: parts.people_filter_button.clone(),
407 cached_people: parts.cached_people.clone(),
408 cached_people_click: parts.cached_people_click.clone(),
409 cached_places: parts.cached_places.clone(),
410 cached_places_click: parts.cached_places_click.clone(),
411 search_query: parts.search_query.clone(),
412 filter_entry: parts.filter_entry.clone(),
413 cached_ctx: parts.cached_ctx.clone(),
414 }
415}
416
417pub fn populate_places<F>(
422 parts: &ExploreViewParts,
423 ctx: Arc<AppContext>,
424 places: Vec<PlaceItem>,
425 on_click: F,
426) where
427 F: Fn(&str, String, String) + 'static,
428{
429 stop_spinner(&parts.places_spinner);
430 *parts.cached_places.borrow_mut() = places;
431 *parts.cached_places_click.borrow_mut() = Some(Rc::new(on_click));
432 render_places(parts, ctx, false);
433}
434
435pub fn has_cached_places(parts: &ExploreViewParts) -> bool {
437 !parts.cached_places.borrow().is_empty()
438}
439
440pub fn render_cached_places(parts: &ExploreViewParts, ctx: Arc<AppContext>) {
442 stop_spinner(&parts.places_spinner);
443 render_places(parts, ctx, false);
444}
445
446fn render_places(parts: &ExploreViewParts, ctx: Arc<AppContext>, expanded: bool) {
447 while let Some(child) = parts.places_grid.first_child() {
448 parts.places_grid.remove(&child);
449 }
450 let places = parts.cached_places.borrow();
451 parts.places_section.set_visible(!places.is_empty());
452 let on_click = match parts.cached_places_click.borrow().clone() {
453 Some(cb) => cb,
454 None => return,
455 };
456 let limit = if expanded {
457 places.len()
458 } else {
459 INITIAL_TILE_COUNT
460 };
461 for place in places.iter().take(limit) {
462 let tile = explore_tile(
463 ctx.clone(),
464 "place",
465 &place.city,
466 &place.asset_id,
467 on_click.clone(),
468 );
469 parts.places_grid.append(&tile);
470 }
471 if !expanded && places.len() > INITIAL_TILE_COUNT {
472 let remaining = places.len() - INITIAL_TILE_COUNT;
473 drop(places);
474 append_see_more_button(&parts.places_grid, remaining, {
475 let parts = clone_parts_handles(parts);
476 let ctx = ctx.clone();
477 move || render_places(&parts, ctx.clone(), true)
478 });
479 } else if expanded && places.len() > INITIAL_TILE_COUNT {
480 drop(places);
481 append_show_less_button(&parts.places_grid, {
482 let parts = clone_parts_handles(parts);
483 let ctx = ctx.clone();
484 move || render_places(&parts, ctx.clone(), false)
485 });
486 }
487}
488
489pub fn populate_explore<F>(
496 parts: &ExploreViewParts,
497 ctx: Arc<AppContext>,
498 sections: Vec<ExploreSection>,
499 on_click: F,
500) where
501 F: Fn(&str, String, String) + 'static,
502{
503 stop_spinner(&parts.things_spinner);
504 stop_spinner(&parts.recents_spinner);
505 while let Some(child) = parts.things_grid.first_child() {
506 parts.things_grid.remove(&child);
507 }
508 while let Some(child) = parts.recents_grid.first_child() {
509 parts.recents_grid.remove(&child);
510 }
511 let mut had_things = false;
512 let mut had_recents = false;
513
514 log::debug!(
515 "Explore sections: [{}]",
516 sections
517 .iter()
518 .map(|s| format!("{}({})", s.field_name, s.items.len()))
519 .collect::<Vec<_>>()
520 .join(", ")
521 );
522
523 let on_click: ExploreClick = Rc::new(on_click);
524 for section in sections {
525 if section.field_name.contains("city") {
526 continue;
527 }
528
529 if section.field_name == "createdAt" || section.field_name == "updatedAt" {
531 had_recents = true;
532 let mut items: Vec<_> = section.items.into_iter().collect();
533 items.sort_by(|a, b| b.value.cmp(&a.value));
534 render_recents_tiles(parts, &ctx, &items, &on_click, false);
535 if items.len() > INITIAL_TILE_COUNT {
536 let remaining = items.len().min(RECENTS_EXPANDED_COUNT) - INITIAL_TILE_COUNT;
537 let parts_clone = clone_parts_handles(parts);
538 let ctx_clone = ctx.clone();
539 let on_click_clone = on_click.clone();
540 append_see_more_button(&parts.recents_grid, remaining, move || {
541 render_recents_tiles(&parts_clone, &ctx_clone, &items, &on_click_clone, true);
542 });
543 }
544 continue;
545 }
546
547 if !section.field_name.starts_with("smartInfo") {
549 log::debug!("Skipping unknown section '{}'", section.field_name);
550 continue;
551 }
552
553 had_things = true;
554 for item in section.items.into_iter().take(24) {
555 let tile = explore_tile(
556 ctx.clone(),
557 "thing",
558 &item.value,
559 &item.data.id,
560 on_click.clone(),
561 );
562 parts.things_grid.append(&tile);
563 }
564 }
565
566 parts.things_section.set_visible(had_things);
567 parts.recents_section.set_visible(had_recents);
568}
569
570fn render_recents_tiles(
572 parts: &ExploreViewParts,
573 ctx: &Arc<AppContext>,
574 items: &[crate::api_client::ExploreItem],
575 on_click: &ExploreClick,
576 expanded: bool,
577) {
578 while let Some(child) = parts.recents_grid.first_child() {
579 parts.recents_grid.remove(&child);
580 }
581 let limit = if expanded {
582 RECENTS_EXPANDED_COUNT
583 } else {
584 INITIAL_TILE_COUNT
585 };
586 for item in items.iter().take(limit) {
587 let label = format_relative_date(&item.value);
588 let tile = explore_tile(
589 ctx.clone(),
590 "recent",
591 &label,
592 &item.data.id,
593 on_click.clone(),
594 );
595 parts.recents_grid.append(&tile);
596 }
597}
598
599fn append_action_button<F: Fn() + 'static>(
600 grid: >k::FlowBox,
601 icon_name: &str,
602 label_text: &str,
603 on_click: F,
604) {
605 let icon = gtk::Image::builder()
606 .icon_name(icon_name)
607 .pixel_size(24)
608 .halign(gtk::Align::Center)
609 .build();
610 let label = gtk::Label::builder()
611 .label(label_text)
612 .css_classes(["caption-heading"])
613 .halign(gtk::Align::Center)
614 .build();
615 let spacer = gtk::Box::builder()
617 .css_classes(["mimick-explore-spacer"])
618 .build();
619 let content = gtk::Box::builder()
620 .orientation(gtk::Orientation::Vertical)
621 .spacing(6)
622 .halign(gtk::Align::Center)
623 .valign(gtk::Align::Center)
624 .build();
625 content.append(&icon);
626 content.append(&label);
627 let overlay = gtk::Overlay::builder()
630 .overflow(gtk::Overflow::Hidden)
631 .css_classes(["mimick-see-more-tile"])
632 .build();
633 overlay.set_child(Some(&spacer));
634 overlay.add_overlay(&content);
635
636 let btn = gtk::Button::builder()
637 .child(&overlay)
638 .css_classes(["flat"])
639 .build();
640 let grid_ref = grid.clone();
641 btn.connect_clicked(move |button| {
642 if let Some(parent) = button.parent() {
643 grid_ref.remove(&parent);
644 }
645 on_click();
646 });
647 grid.append(&btn);
648}
649
650fn append_see_more_button<F: Fn() + 'static>(grid: >k::FlowBox, remaining: usize, on_expand: F) {
655 append_action_button(
656 grid,
657 "view-more-symbolic",
658 &format!("See {remaining} more"),
659 on_expand,
660 );
661}
662
663fn append_show_less_button<F: Fn() + 'static>(grid: >k::FlowBox, on_collapse: F) {
665 append_action_button(grid, "go-up-symbolic", "Show Less", on_collapse);
666}
667fn parse_iso_date(iso: &str) -> Option<(i64, u32, u32, u32, u32, u32)> {
668 let parsed = iso
669 .replace('T', " ")
670 .replace('Z', "")
671 .chars()
672 .take(19)
673 .collect::<String>();
674
675 let parts: Vec<&str> = parsed.split(&['-', ' ', ':'][..]).collect();
676 if parts.len() < 6 {
677 return None;
678 }
679 match (
680 parts[0].parse::<i64>(),
681 parts[1].parse::<u32>(),
682 parts[2].parse::<u32>(),
683 parts[3].parse::<u32>(),
684 parts[4].parse::<u32>(),
685 parts[5].parse::<u32>(),
686 ) {
687 (Ok(y), Ok(mo), Ok(d), Ok(h), Ok(mi), Ok(s)) => Some((y, mo, d, h, mi, s)),
688 _ => None,
689 }
690}
691
692fn compute_epoch_seconds(year: i64, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> i64 {
693 let days_in_month = [0u32, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
694 let mut total_days: i64 = 0;
695 for y in 1970..year {
696 total_days += if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) {
697 366
698 } else {
699 365
700 };
701 }
702 let is_leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
703 for m in 1..month {
704 total_days += days_in_month[m as usize] as i64;
705 if m == 2 && is_leap {
706 total_days += 1;
707 }
708 }
709 total_days += (day - 1) as i64;
710 total_days * 86400 + hour as i64 * 3600 + min as i64 * 60 + sec as i64
711}
712
713fn format_relative_date(iso: &str) -> String {
715 use std::time::{SystemTime, UNIX_EPOCH};
716
717 let Some((year, month, day, hour, min, sec)) = parse_iso_date(iso) else {
718 return iso.chars().take(10).collect();
719 };
720
721 let ts = compute_epoch_seconds(year, month, day, hour, min, sec);
722 let now_secs = SystemTime::now()
723 .duration_since(UNIX_EPOCH)
724 .map(|d| d.as_secs() as i64)
725 .unwrap_or(0);
726 let diff = now_secs - ts;
727
728 if diff < 60 {
729 "Just now".to_string()
730 } else if diff < 3600 {
731 let m = diff / 60;
732 format!("{m} min ago")
733 } else if diff < 86400 {
734 let h = diff / 3600;
735 format!("{h}h ago")
736 } else if diff < 86400 * 7 {
737 let d = diff / 86400;
738 format!("{d}d ago")
739 } else {
740 format!("{year:04}-{month:02}-{day:02}")
741 }
742}
743
744fn person_tile(
746 ctx: Arc<AppContext>,
747 person: &Person,
748 on_click: Rc<dyn Fn(String, String)>,
749) -> gtk::Button {
750 let avatar = gtk::Picture::builder()
751 .width_request(96)
752 .height_request(96)
753 .can_shrink(true)
754 .content_fit(gtk::ContentFit::Cover)
755 .css_classes(vec!["mimick-person-avatar".to_string()])
756 .build();
757 let label = gtk::Label::builder()
758 .label(if person.name.is_empty() {
759 "Unnamed"
760 } else {
761 &person.name
762 })
763 .ellipsize(gtk::pango::EllipsizeMode::End)
764 .max_width_chars(12)
765 .build();
766 let inner = gtk::Box::builder()
767 .orientation(gtk::Orientation::Vertical)
768 .spacing(6)
769 .halign(gtk::Align::Center)
770 .build();
771 inner.append(&avatar);
772 inner.append(&label);
773
774 let button = gtk::Button::builder()
775 .child(&inner)
776 .css_classes(vec!["flat".to_string()])
777 .build();
778
779 let id = person.id.clone();
780 let name = if person.name.is_empty() {
781 "Unnamed".to_string()
782 } else {
783 person.name.clone()
784 };
785 button.connect_clicked(move |_| on_click(id.clone(), name.clone()));
786
787 spawn_person_thumbnail(ctx, person.id.clone(), avatar);
788 button
789}
790
791fn explore_tile(
793 ctx: Arc<AppContext>,
794 kind: &'static str,
795 value: &str,
796 asset_id: &str,
797 on_click: ExploreClick,
798) -> gtk::Button {
799 let thumb = gtk::Overlay::builder()
803 .overflow(gtk::Overflow::Hidden)
804 .css_classes(vec!["mimick-explore-tile".to_string()])
805 .build();
806 let spacer = gtk::Box::builder()
807 .css_classes(vec!["mimick-explore-spacer".to_string()])
808 .build();
809 let picture = gtk::Picture::builder()
810 .can_shrink(true)
811 .content_fit(gtk::ContentFit::Cover)
812 .build();
813 thumb.set_child(Some(&spacer));
814 thumb.add_overlay(&picture);
815
816 let label = gtk::Label::builder()
817 .label(value)
818 .xalign(0.0)
819 .width_chars(1)
820 .max_width_chars(1)
821 .ellipsize(gtk::pango::EllipsizeMode::End)
822 .css_classes(vec!["caption-heading".to_string()])
823 .build();
824 let inner = gtk::Box::builder()
825 .orientation(gtk::Orientation::Vertical)
826 .spacing(4)
827 .build();
828 inner.append(&thumb);
829 inner.append(&label);
830
831 let button = gtk::Button::builder()
832 .child(&inner)
833 .css_classes(vec!["flat".to_string()])
834 .build();
835
836 let value_owned = value.to_string();
837 let asset_id_owned = asset_id.to_string();
838 button.connect_clicked(move |_| on_click(kind, value_owned.clone(), asset_id_owned.clone()));
839
840 spawn_asset_thumbnail(ctx, asset_id.to_string(), picture);
841 button
842}
843
844fn spawn_asset_thumbnail(ctx: Arc<AppContext>, asset_id: String, picture: gtk::Picture) {
846 if let Some(texture) = ctx
847 .thumbnail_cache
848 .get_cached(&asset_id, ThumbnailSize::Thumbnail)
849 {
850 picture.set_paintable(Some(&texture));
851 return;
852 }
853 glib::timeout_add_local_once(std::time::Duration::from_millis(120), move || {
854 glib::MainContext::default().spawn_local(async move {
855 if let Ok(texture) = ctx
856 .thumbnail_cache
857 .load_thumbnail(&asset_id, ThumbnailSize::Thumbnail)
858 .await
859 {
860 picture.set_paintable(Some(&texture));
861 }
862 });
863 });
864}
865
866fn spawn_person_thumbnail(ctx: Arc<AppContext>, person_id: String, picture: gtk::Picture) {
868 glib::timeout_add_local_once(std::time::Duration::from_millis(120), move || {
869 glib::MainContext::default().spawn_local(async move {
870 let bytes = match ctx.api_client.fetch_person_thumbnail(&person_id).await {
871 Ok(b) => b,
872 Err(_) => return,
873 };
874 let texture = tokio::task::spawn_blocking(move || -> Option<Texture> {
875 Texture::from_bytes(&Bytes::from(&bytes[..])).ok()
876 })
877 .await
878 .ok()
879 .flatten();
880 if let Some(texture) = texture {
881 picture.set_paintable(Some(&texture));
882 }
883 });
884 });
885}