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