Skip to main content

mimick/library/
source_header.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3
4use gtk::prelude::*;
5
6use crate::api_client::{LibraryAlbum, MetadataSearchFilters, SharedLinkTarget};
7
8use super::LibraryWindowUi;
9use super::asset_tags::color_dot;
10use super::dialogs::popover_menu;
11use super::explore_view::spawn_person_thumbnail;
12use super::header_format::{
13    album_details, capitalize, count_label, count_noun, owns_album, person_details,
14};
15use super::state::{LibrarySource, Location};
16use super::tree_page::{leaf_name, parent_path};
17
18const AVATAR_SIZE: i32 = 64;
19
20pub(super) struct SourceHeader {
21    pub root: gtk::Box,
22    pub extra: gtk::Box,
23    pub status: gtk::Stack,
24    leading: gtk::Box,
25    title: gtk::Label,
26    facts: gtk::Label,
27    description: gtk::Label,
28    actions: gtk::Box,
29    status_error: gtk::Label,
30    count: RefCell<Option<String>>,
31    details: RefCell<Vec<String>>,
32}
33
34enum Leading {
35    Icon(&'static str),
36    Person { id: String, name: String },
37    TagColor(Option<String>),
38}
39
40struct HeaderTop {
41    root: gtk::Box,
42    leading: gtk::Box,
43    title: gtk::Label,
44    facts: gtk::Label,
45    actions: gtk::Box,
46}
47
48pub(super) fn build_source_header() -> SourceHeader {
49    let top = build_header_top();
50    let description = gtk::Label::builder()
51        .xalign(0.0)
52        .wrap(true)
53        .wrap_mode(gtk::pango::WrapMode::WordChar)
54        .visible(false)
55        .build();
56    let extra = gtk::Box::builder()
57        .orientation(gtk::Orientation::Vertical)
58        .spacing(6)
59        .build();
60    let root = gtk::Box::builder()
61        .orientation(gtk::Orientation::Vertical)
62        .spacing(10)
63        .margin_top(16)
64        .margin_bottom(12)
65        .margin_start(12)
66        .margin_end(12)
67        .build();
68    root.append(&top.root);
69    root.append(&description);
70    root.append(&extra);
71    let (status, status_error) = build_status_stack();
72    SourceHeader {
73        root,
74        extra,
75        status,
76        leading: top.leading,
77        title: top.title,
78        facts: top.facts,
79        description,
80        actions: top.actions,
81        status_error,
82        count: RefCell::default(),
83        details: RefCell::default(),
84    }
85}
86
87fn build_header_top() -> HeaderTop {
88    let leading = gtk::Box::builder().valign(gtk::Align::Center).build();
89    let title = gtk::Label::builder()
90        .xalign(0.0)
91        .ellipsize(gtk::pango::EllipsizeMode::End)
92        .css_classes(["title-1"])
93        .build();
94    let facts = gtk::Label::builder()
95        .xalign(0.0)
96        .wrap(true)
97        .wrap_mode(gtk::pango::WrapMode::WordChar)
98        .css_classes(["dim-label"])
99        .visible(false)
100        .build();
101    let text = gtk::Box::builder()
102        .orientation(gtk::Orientation::Vertical)
103        .spacing(2)
104        .hexpand(true)
105        .valign(gtk::Align::Center)
106        .build();
107    text.append(&title);
108    text.append(&facts);
109    let actions = gtk::Box::builder()
110        .spacing(6)
111        .valign(gtk::Align::Center)
112        .build();
113    let root = gtk::Box::builder().spacing(12).build();
114    root.append(&leading);
115    root.append(&text);
116    root.append(&actions);
117    HeaderTop {
118        root,
119        leading,
120        title,
121        facts,
122        actions,
123    }
124}
125
126fn build_status_stack() -> (gtk::Stack, gtk::Label) {
127    let error_view = super::build_status_view(
128        "dialog-warning-symbolic",
129        "Library data unavailable",
130        "Could not load library assets",
131    );
132    let status_error = error_view
133        .last_child()
134        .and_downcast::<gtk::Label>()
135        .expect("status-view subtitle label");
136    let status = gtk::Stack::builder()
137        .vexpand(true)
138        .vhomogeneous(false)
139        .build();
140    status.add_named(&gtk::Box::new(gtk::Orientation::Vertical, 0), Some("none"));
141    status.add_named(&super::build_loading_view(), Some("loading"));
142    status.add_named(
143        &super::build_status_view(
144            "image-x-generic-symbolic",
145            "Nothing Here",
146            "There are no photos or videos to show.",
147        ),
148        Some("empty"),
149    );
150    status.add_named(&error_view, Some("error"));
151    (status, status_error)
152}
153
154pub(super) fn set_status(ui: &LibraryWindowUi, status: &str, error: Option<&str>) {
155    if let Some(error) = error {
156        ui.header.status_error.set_label(error);
157    }
158    ui.header.status.set_visible_child_name(status);
159    ui.header.status.set_visible(status != "none");
160    ui.grid.canvas.set_visible(status == "none");
161}
162
163pub(super) fn refresh(ui: &Rc<LibraryWindowUi>) {
164    if let Location::Source(source) = super::navigation::current(ui) {
165        show(ui, &source);
166    }
167}
168
169pub(super) fn set_loaded_count(ui: &LibraryWindowUi, count: usize) {
170    *ui.header.count.borrow_mut() = Some(count_label(count as u64));
171    render_facts(&ui.header);
172}
173
174pub(super) fn show(ui: &Rc<LibraryWindowUi>, source: &LibrarySource) {
175    let header = &ui.header;
176    *header.count.borrow_mut() = None;
177    header.details.borrow_mut().clear();
178    render_facts(header);
179    set_description(header, None);
180    clear_actions(header);
181    let (leading, title) = basics(ui, source);
182    set_leading(ui, leading);
183    set_title(ui, &title);
184    enrich(ui, source);
185}
186
187fn basics(ui: &LibraryWindowUi, source: &LibrarySource) -> (Leading, String) {
188    let icon = |name: &'static str, title: &str| (Leading::Icon(name), title.to_string());
189    match source {
190        LibrarySource::AllAssets | LibrarySource::Timeline | LibrarySource::Unified => {
191            if let Some(stats) = ui.ctx.library_state.lock().status.stats.clone() {
192                *ui.header.details.borrow_mut() = vec![
193                    count_noun(stats.images, "photo", "photos"),
194                    count_noun(stats.videos, "video", "videos"),
195                ];
196            }
197            icon("image-x-generic-symbolic", "Photos")
198        }
199        LibrarySource::LocalAll => icon("mimick-computer-symbolic", "On This Computer"),
200        LibrarySource::Explore => icon("view-grid-symbolic", "Explore"),
201        LibrarySource::Favorites => icon("mimick-heart-filled-symbolic", "Favorites"),
202        LibrarySource::Archive => icon("mimick-archive-symbolic", "Archive"),
203        LibrarySource::Trash => icon("user-trash-symbolic", "Trash"),
204        LibrarySource::Album { name, .. }
205        | LibrarySource::AlbumLocal { name, .. }
206        | LibrarySource::AlbumUnified { name, .. } => icon("folder-pictures-symbolic", name),
207        LibrarySource::Person { id, name } => (
208            Leading::Person {
209                id: id.clone(),
210                name: name.clone(),
211            },
212            name.clone(),
213        ),
214        LibrarySource::Tag { name, .. } => (Leading::TagColor(None), leaf_name(name).to_string()),
215        LibrarySource::Folder { path } => icon("folder-symbolic", leaf_name(path)),
216        LibrarySource::Place { city } => icon("mark-location-symbolic", city),
217        LibrarySource::Thing { query } => icon("system-search-symbolic", &capitalize(query)),
218        LibrarySource::SmartSearch { query }
219        | LibrarySource::MetadataSearch { query }
220        | LibrarySource::OcrSearch { query }
221        | LibrarySource::LocalSearch { query }
222        | LibrarySource::UnifiedSearch { query } => icon("system-search-symbolic", query),
223        LibrarySource::AdvancedSearch { .. } => icon("system-search-symbolic", "Search Results"),
224    }
225}
226
227fn enrich(ui: &Rc<LibraryWindowUi>, source: &LibrarySource) {
228    match source {
229        LibrarySource::Favorites => count_matching(
230            ui,
231            MetadataSearchFilters {
232                is_favorite: Some(true),
233                ..MetadataSearchFilters::default()
234            },
235        ),
236        LibrarySource::Archive => count_matching(
237            ui,
238            MetadataSearchFilters {
239                visibility: Some("archive".to_string()),
240                ..MetadataSearchFilters::default()
241            },
242        ),
243        LibrarySource::Place { city } => count_matching(
244            ui,
245            MetadataSearchFilters {
246                city: Some(city.clone()),
247                ..MetadataSearchFilters::default()
248            },
249        ),
250        LibrarySource::MetadataSearch { query } => count_matching(
251            ui,
252            MetadataSearchFilters {
253                original_file_name: Some(query.clone()),
254                ..MetadataSearchFilters::default()
255            },
256        ),
257        LibrarySource::AdvancedSearch { filters } => count_matching(ui, (**filters).clone()),
258        LibrarySource::Thing { .. } => set_details(&ui.header, vec!["Things".to_string()]),
259        LibrarySource::SmartSearch { .. } => {
260            set_details(&ui.header, vec!["Smart search".to_string()])
261        }
262        LibrarySource::Folder { path } => {
263            set_details(&ui.header, vec![path.clone()]);
264        }
265        LibrarySource::Album { id, .. }
266        | LibrarySource::AlbumLocal { id, .. }
267        | LibrarySource::AlbumUnified { id, .. } => enrich_album(ui, id.clone()),
268        LibrarySource::Person { id, .. } => enrich_person(ui, id.clone()),
269        LibrarySource::Tag { id, name } => enrich_tag(ui, id.clone(), name.clone()),
270        _ => {}
271    }
272}
273
274fn generation(ui: &LibraryWindowUi) -> u64 {
275    ui.ctx.library_state.lock().generation
276}
277
278fn spawn_for_current<F, Fut>(ui: &Rc<LibraryWindowUi>, task: F)
279where
280    F: FnOnce(Rc<LibraryWindowUi>) -> Fut + 'static,
281    Fut: Future<Output = Option<Box<dyn FnOnce(&Rc<LibraryWindowUi>)>>> + 'static,
282{
283    let ui = ui.clone();
284    let started = generation(&ui);
285    glib::MainContext::default().spawn_local(async move {
286        let apply = task(ui.clone()).await;
287        if let Some(apply) = apply
288            && generation(&ui) == started
289        {
290            apply(&ui);
291        }
292    });
293}
294
295fn count_matching(ui: &Rc<LibraryWindowUi>, filters: MetadataSearchFilters) {
296    spawn_for_current(ui, move |ui| async move {
297        let total = ui.ctx.api_client.search_statistics(&filters).await.ok()?;
298        Some(Box::new(move |ui: &Rc<LibraryWindowUi>| {
299            *ui.header.count.borrow_mut() = Some(count_label(total));
300            render_facts(&ui.header);
301        }) as Box<dyn FnOnce(&Rc<LibraryWindowUi>)>)
302    });
303}
304
305fn enrich_album(ui: &Rc<LibraryWindowUi>, album_id: String) {
306    spawn_for_current(ui, move |ui| async move {
307        let album = ui.ctx.api_client.fetch_album_detail(&album_id).await.ok()?;
308        let me = super::ensure_current_user_id(&ui.ctx)
309            .await
310            .unwrap_or_default();
311        Some(
312            Box::new(move |ui: &Rc<LibraryWindowUi>| show_album(ui, album, &me))
313                as Box<dyn FnOnce(&Rc<LibraryWindowUi>)>,
314        )
315    });
316}
317
318fn show_album(ui: &Rc<LibraryWindowUi>, album: LibraryAlbum, me: &str) {
319    let header = &ui.header;
320    set_title(ui, &album.album_name);
321    *header.count.borrow_mut() = Some(count_label(album.asset_count.into()));
322    set_details(header, album_details(&album, me));
323    set_description(header, Some(&album.description));
324    if owns_album(&album, me) {
325        let menu = gtk::MenuButton::builder()
326            .icon_name("view-more-symbolic")
327            .tooltip_text("More")
328            .valign(gtk::Align::Center)
329            .css_classes(["flat"])
330            .build();
331        popover_menu(&menu, super::album_edit::album_menu_items(ui, &album));
332        header.actions.append(&menu);
333        let (ui_share, share_album) = (ui.clone(), album.clone());
334        add_action(header, "emblem-shared-symbolic", "Share album", move || {
335            super::album_share_dialog::present(ui_share.clone(), share_album.clone())
336        });
337    }
338    let (ui_link, album_id) = (ui.clone(), album.id.clone());
339    add_action(
340        header,
341        "insert-link-symbolic",
342        "Create share link",
343        move || {
344            super::shared_link_dialog::present(
345                ui_link.clone(),
346                super::shared_link_dialog::LinkDialogMode::Create(SharedLinkTarget::Album(
347                    album_id.clone(),
348                )),
349                Rc::new(|| {}),
350            )
351        },
352    );
353}
354
355fn enrich_person(ui: &Rc<LibraryWindowUi>, person_id: String) {
356    spawn_for_current(ui, move |ui| async move {
357        let person = ui.ctx.api_client.fetch_person(&person_id).await.ok()?;
358        let count = ui.ctx.api_client.person_asset_count(&person_id).await.ok();
359        Some(Box::new(move |ui: &Rc<LibraryWindowUi>| {
360            let header = &ui.header;
361            let name = if person.name.is_empty() {
362                "Unnamed".to_string()
363            } else {
364                person.name.clone()
365            };
366            set_title(ui, &name);
367            *header.count.borrow_mut() = count.map(count_label);
368            let today = chrono::Local::now().date_naive();
369            let mut details = person_details(person.birth_date.as_deref(), today);
370            if person.is_hidden {
371                details.push("Hidden".to_string());
372            }
373            set_details(header, details);
374            let menu = gtk::MenuButton::builder()
375                .icon_name("view-more-symbolic")
376                .tooltip_text("More")
377                .valign(gtk::Align::Center)
378                .css_classes(["flat"])
379                .build();
380            add_favorite_toggle(ui, &person);
381            popover_menu(&menu, super::people_view::person_menu_items(ui, &person));
382            header.actions.append(&menu);
383        }) as Box<dyn FnOnce(&Rc<LibraryWindowUi>)>)
384    });
385}
386
387fn add_favorite_toggle(ui: &Rc<LibraryWindowUi>, person: &crate::api_client::Person) {
388    let favorite = person.is_favorite;
389    let tooltip = if favorite {
390        "Remove from favorites"
391    } else {
392        "Add to favorites"
393    };
394    let (ui_toggle, person) = (ui.clone(), person.clone());
395    add_action(
396        &ui.header,
397        super::favorites::heart_icon(favorite),
398        tooltip,
399        move || super::people_view::set_favorite(&ui_toggle, &person, !favorite),
400    );
401}
402
403fn enrich_tag(ui: &Rc<LibraryWindowUi>, tag_id: String, value: String) {
404    let mut details = Vec::new();
405    if let Some(parent) = parent_path(&value) {
406        details.push(format!("In {}", parent.replace('/', " / ")));
407    }
408    set_details(&ui.header, details);
409    count_matching(
410        ui,
411        MetadataSearchFilters {
412            tag_ids: Some(vec![tag_id.clone()]),
413            ..MetadataSearchFilters::default()
414        },
415    );
416    spawn_for_current(ui, move |ui| async move {
417        let tags = ui.ctx.api_client.fetch_tags().await.ok()?;
418        let tag = tags.into_iter().find(|tag| tag.id == tag_id)?;
419        Some(Box::new(move |ui: &Rc<LibraryWindowUi>| {
420            let header = &ui.header;
421            set_title(ui, &tag.name);
422            set_leading(ui, Leading::TagColor(tag.color.clone()));
423            let menu = gtk::MenuButton::builder()
424                .icon_name("view-more-symbolic")
425                .tooltip_text("More")
426                .valign(gtk::Align::Center)
427                .css_classes(["flat"])
428                .build();
429            popover_menu(&menu, super::tags_view::tag_menu_items(ui, &tag));
430            header.actions.append(&menu);
431        }) as Box<dyn FnOnce(&Rc<LibraryWindowUi>)>)
432    });
433}
434
435fn set_title(ui: &LibraryWindowUi, title: &str) {
436    ui.header.title.set_label(title);
437    ui.header.title.set_tooltip_text(Some(title));
438    super::navigation::set_view_title(ui, title);
439}
440
441fn set_leading(ui: &LibraryWindowUi, leading: Leading) {
442    let slot = &ui.header.leading;
443    while let Some(child) = slot.first_child() {
444        slot.remove(&child);
445    }
446    let widget: gtk::Widget = match leading {
447        Leading::Icon(name) => gtk::Image::builder()
448            .icon_name(name)
449            .pixel_size(32)
450            .css_classes(["dim-label"])
451            .build()
452            .upcast(),
453        Leading::Person { id, name } => {
454            let avatar = libadwaita::Avatar::new(AVATAR_SIZE, Some(&name), true);
455            let avatar_for_image = avatar.clone();
456            spawn_person_thumbnail(ui.ctx.clone(), id, move |texture| {
457                avatar_for_image.set_custom_image(Some(texture))
458            });
459            avatar.upcast()
460        }
461        Leading::TagColor(color) => color_dot(color.as_deref(), 20).upcast(),
462    };
463    slot.append(&widget);
464}
465
466fn clear_actions(header: &SourceHeader) {
467    while let Some(child) = header.actions.first_child() {
468        header.actions.remove(&child);
469    }
470}
471
472fn add_action(header: &SourceHeader, icon: &str, tooltip: &str, on_click: impl Fn() + 'static) {
473    let button = gtk::Button::builder()
474        .icon_name(icon)
475        .tooltip_text(tooltip)
476        .valign(gtk::Align::Center)
477        .css_classes(["flat"])
478        .build();
479    button.connect_clicked(move |_| on_click());
480    header.actions.append(&button);
481}
482
483fn set_details(header: &SourceHeader, details: Vec<String>) {
484    *header.details.borrow_mut() = details;
485    render_facts(header);
486}
487
488fn set_description(header: &SourceHeader, description: Option<&str>) {
489    let text = description.map(str::trim).unwrap_or_default();
490    header.description.set_label(text);
491    header.description.set_visible(!text.is_empty());
492}
493
494fn render_facts(header: &SourceHeader) {
495    let parts: Vec<String> = header
496        .count
497        .borrow()
498        .iter()
499        .cloned()
500        .chain(header.details.borrow().iter().cloned())
501        .filter(|part| !part.is_empty())
502        .collect();
503    let text = parts.join(" ยท ");
504    header.facts.set_label(&text);
505    header.facts.set_visible(!text.is_empty());
506}