1use std::cell::{Cell, RefCell};
8use std::rc::Rc;
9use std::sync::Arc;
10
11use gtk::prelude::*;
12
13use crate::api_client::{LibraryAlbum, ThumbnailSize};
14use crate::app_context::AppContext;
15
16pub type AlbumClick = Rc<dyn Fn(&str, String)>;
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum AlbumsSort {
21 #[default]
22 Newest,
23 Name,
24 MostAssets,
25}
26
27pub struct AlbumsViewParts {
29 pub root: gtk::ScrolledWindow,
30 pub populated: Rc<Cell<bool>>,
31 pub create_button: gtk::Button,
32 recent_grid: gtk::FlowBox,
33 owned_grid: gtk::FlowBox,
34 shared_grid: gtk::FlowBox,
35 recent_section: gtk::Box,
36 owned_section: gtk::Box,
37 shared_section: gtk::Box,
38 cached_albums: Rc<RefCell<Vec<LibraryAlbum>>>,
39 cached_click: Rc<RefCell<Option<AlbumClick>>>,
40 pub search_query: Rc<RefCell<String>>,
41 pub filter_entry: gtk::SearchEntry,
42 pub sort_mode: Rc<Cell<AlbumsSort>>,
43 cached_ctx: Rc<RefCell<Option<Arc<AppContext>>>>,
44}
45
46pub fn build_albums_view() -> AlbumsViewParts {
48 let outer = gtk::Box::builder()
49 .orientation(gtk::Orientation::Vertical)
50 .spacing(24)
51 .margin_top(16)
52 .margin_bottom(16)
53 .margin_start(16)
54 .margin_end(16)
55 .build();
56
57 let header_row = gtk::Box::builder()
58 .orientation(gtk::Orientation::Vertical)
59 .spacing(8)
60 .build();
61 let title = gtk::Label::builder()
62 .label("Albums")
63 .xalign(0.0)
64 .hexpand(true)
65 .css_classes(vec!["title-1".to_string()])
66 .build();
67 let create_button = gtk::Button::builder()
68 .label("Create album")
69 .css_classes(vec!["suggested-action".to_string()])
70 .build();
71 let filter_entry = gtk::SearchEntry::builder()
72 .placeholder_text("Filter albums")
73 .hexpand(true)
74 .max_width_chars(20)
75 .build();
76 let filter_row = gtk::Box::builder()
77 .orientation(gtk::Orientation::Horizontal)
78 .spacing(8)
79 .build();
80 header_row.append(&title);
81 filter_row.append(&filter_entry);
82 filter_row.append(&create_button);
83 header_row.append(&filter_row);
84 outer.append(&header_row);
85
86 let (recent_section, recent_grid) = build_section("Recent");
87 let (owned_section, owned_grid) = build_section("Your albums");
88 let (shared_section, shared_grid) = build_section("Shared with you");
89 outer.append(&recent_section);
90 outer.append(&owned_section);
91 outer.append(&shared_section);
92
93 let root = gtk::ScrolledWindow::builder()
94 .child(&outer)
95 .hscrollbar_policy(gtk::PolicyType::Never)
96 .vexpand(true)
97 .build();
98
99 AlbumsViewParts {
100 root,
101 populated: Rc::new(Cell::new(false)),
102 create_button,
103 recent_grid,
104 owned_grid,
105 shared_grid,
106 recent_section,
107 owned_section,
108 shared_section,
109 cached_albums: Rc::new(RefCell::new(Vec::new())),
110 cached_click: Rc::new(RefCell::new(None)),
111 search_query: Rc::new(RefCell::new(String::new())),
112 filter_entry,
113 sort_mode: Rc::new(Cell::new(AlbumsSort::default())),
114 cached_ctx: Rc::new(RefCell::new(None)),
115 }
116}
117
118pub fn populate_albums(
120 parts: &AlbumsViewParts,
121 ctx: Arc<AppContext>,
122 albums: Vec<LibraryAlbum>,
123 on_click: AlbumClick,
124) {
125 *parts.cached_albums.borrow_mut() = albums;
126 *parts.cached_click.borrow_mut() = Some(on_click);
127 *parts.cached_ctx.borrow_mut() = Some(ctx);
128 render_albums(parts);
129}
130
131pub fn set_search_filter(parts: &AlbumsViewParts, query: &str) {
133 *parts.search_query.borrow_mut() = query.to_string();
134 render_albums(parts);
135}
136
137pub fn set_sort_mode(parts: &AlbumsViewParts, mode: AlbumsSort) {
139 parts.sort_mode.set(mode);
140 render_albums(parts);
141}
142
143pub(crate) fn project_albums<'a>(
148 albums: &'a [LibraryAlbum],
149 current_user: &str,
150 query: &str,
151 sort: AlbumsSort,
152) -> (
153 Vec<&'a LibraryAlbum>,
154 Vec<&'a LibraryAlbum>,
155 Vec<&'a LibraryAlbum>,
156) {
157 let query = query.to_ascii_lowercase();
158 let filtered: Vec<&LibraryAlbum> = albums
159 .iter()
160 .filter(|a| query.is_empty() || a.album_name.to_ascii_lowercase().contains(&query))
161 .collect();
162
163 let mut sorted: Vec<&LibraryAlbum> = filtered.clone();
164 match sort {
165 AlbumsSort::Newest => sorted.sort_by(|a, b| b.created_at.cmp(&a.created_at)),
166 AlbumsSort::Name => sorted.sort_by(|a, b| {
167 a.album_name
168 .to_ascii_lowercase()
169 .cmp(&b.album_name.to_ascii_lowercase())
170 }),
171 AlbumsSort::MostAssets => sorted.sort_by_key(|a| std::cmp::Reverse(a.asset_count)),
172 }
173
174 let recent: Vec<&LibraryAlbum> = if matches!(sort, AlbumsSort::Newest) {
177 sorted.iter().copied().take(8).collect()
178 } else {
179 let mut by_date: Vec<&LibraryAlbum> = filtered.clone();
180 by_date.sort_by(|a, b| b.created_at.cmp(&a.created_at));
181 by_date.into_iter().take(8).collect()
182 };
183
184 let owned: Vec<&LibraryAlbum> = sorted
185 .iter()
186 .copied()
187 .filter(|a| !current_user.is_empty() && a.owner_id() == current_user)
188 .collect();
189 let shared: Vec<&LibraryAlbum> = sorted
190 .iter()
191 .copied()
192 .filter(|a| !current_user.is_empty() && a.owner_id() != current_user)
193 .collect();
194
195 (recent, owned, shared)
196}
197
198fn render_albums(parts: &AlbumsViewParts) {
199 let ctx_opt = parts.cached_ctx.borrow().clone();
200 let on_click_opt = parts.cached_click.borrow().clone();
201 let (Some(ctx), Some(on_click)) = (ctx_opt, on_click_opt) else {
202 return;
203 };
204
205 clear(&parts.recent_grid);
206 clear(&parts.owned_grid);
207 clear(&parts.shared_grid);
208
209 let current_user = ctx.current_user_id.lock().clone().unwrap_or_default();
210 let query = parts.search_query.borrow().clone();
211 let sort = parts.sort_mode.get();
212
213 let albums = parts.cached_albums.borrow();
214 let (recent, owned, shared) = project_albums(&albums, ¤t_user, &query, sort);
215
216 for album in &recent {
217 parts
218 .recent_grid
219 .insert(&album_tile(ctx.clone(), album, on_click.clone()), -1);
220 }
221 for album in &owned {
222 parts
223 .owned_grid
224 .insert(&album_tile(ctx.clone(), album, on_click.clone()), -1);
225 }
226 for album in &shared {
227 parts
228 .shared_grid
229 .insert(&album_tile(ctx.clone(), album, on_click.clone()), -1);
230 }
231
232 parts.recent_section.set_visible(!recent.is_empty());
233 parts.owned_section.set_visible(!owned.is_empty());
234 parts.shared_section.set_visible(!shared.is_empty());
235 parts.populated.set(true);
236}
237
238fn build_section(title: &str) -> (gtk::Box, gtk::FlowBox) {
240 let section = gtk::Box::builder()
241 .orientation(gtk::Orientation::Vertical)
242 .spacing(8)
243 .build();
244 let label = gtk::Label::builder()
245 .label(title)
246 .xalign(0.0)
247 .css_classes(vec!["title-3".to_string()])
248 .build();
249 let grid = gtk::FlowBox::builder()
250 .selection_mode(gtk::SelectionMode::None)
251 .max_children_per_line(20)
252 .min_children_per_line(2)
253 .row_spacing(12)
254 .column_spacing(12)
255 .homogeneous(true)
256 .halign(gtk::Align::Start)
257 .build();
258 section.append(&label);
259 section.append(&grid);
260 (section, grid)
261}
262
263fn album_tile(ctx: Arc<AppContext>, album: &LibraryAlbum, on_click: AlbumClick) -> gtk::Button {
265 let tile_box = gtk::Box::builder()
266 .orientation(gtk::Orientation::Vertical)
267 .spacing(4)
268 .build();
269
270 let thumb = gtk::Overlay::builder()
274 .overflow(gtk::Overflow::Hidden)
275 .css_classes(vec!["mimick-explore-tile".to_string()])
276 .build();
277 let spacer = gtk::Box::builder()
278 .css_classes(vec!["mimick-explore-spacer".to_string()])
279 .build();
280 let picture = gtk::Picture::builder()
281 .can_shrink(true)
282 .content_fit(gtk::ContentFit::Cover)
283 .build();
284 thumb.set_child(Some(&spacer));
285 thumb.add_overlay(&picture);
286
287 let meta_row = gtk::Box::builder()
288 .orientation(gtk::Orientation::Horizontal)
289 .spacing(8)
290 .build();
291 let title_label = gtk::Label::builder()
292 .label(&album.album_name)
293 .xalign(0.0)
294 .hexpand(true)
295 .ellipsize(gtk::pango::EllipsizeMode::End)
296 .width_chars(1)
297 .max_width_chars(1)
298 .css_classes(vec!["caption-heading".to_string()])
299 .build();
300 let count_label = gtk::Label::builder()
301 .label(format!(
302 "{} item{}",
303 album.asset_count,
304 if album.asset_count == 1 { "" } else { "s" }
305 ))
306 .xalign(1.0)
307 .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
308 .build();
309 meta_row.append(&title_label);
310 meta_row.append(&count_label);
311 tile_box.append(&thumb);
312 tile_box.append(&meta_row);
313
314 let button = gtk::Button::builder()
315 .child(&tile_box)
316 .css_classes(vec!["flat".to_string()])
317 .build();
318
319 if let Some(thumb_id) = album.thumbnail_asset_id.clone() {
320 spawn_thumbnail(ctx, thumb_id, picture);
321 }
322
323 let id = album.id.clone();
324 let name = album.album_name.clone();
325 button.connect_clicked(move |_| on_click(&id, name.clone()));
326 button
327}
328
329fn spawn_thumbnail(ctx: Arc<AppContext>, asset_id: String, picture: gtk::Picture) {
331 if let Some(texture) = ctx
332 .thumbnail_cache
333 .get_cached(&asset_id, ThumbnailSize::Thumbnail)
334 {
335 picture.set_paintable(Some(&texture));
336 return;
337 }
338 glib::timeout_add_local_once(std::time::Duration::from_millis(120), move || {
339 glib::MainContext::default().spawn_local(async move {
340 if let Ok(texture) = ctx
341 .thumbnail_cache
342 .load_thumbnail(&asset_id, ThumbnailSize::Thumbnail)
343 .await
344 {
345 picture.set_paintable(Some(&texture));
346 }
347 });
348 });
349}
350
351fn clear(flow: >k::FlowBox) {
353 while let Some(child) = flow.first_child() {
354 flow.remove(&child);
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 use crate::api_client::{AlbumUser, AlbumUserInfo};
363
364 fn album(id: &str, name: &str, owner: &str, count: u32, created: &str) -> LibraryAlbum {
365 LibraryAlbum {
366 id: id.into(),
367 album_name: name.into(),
368 asset_count: count,
369 thumbnail_asset_id: None,
370 created_at: created.into(),
371 updated_at: created.into(),
372 description: String::new(),
373 album_users: vec![AlbumUser {
374 user: AlbumUserInfo { id: owner.into() },
375 role: "owner".into(),
376 }],
377 }
378 }
379
380 fn fixture() -> Vec<LibraryAlbum> {
381 vec![
382 album("a1", "Beach Trip", "me", 200, "2024-06-01T00:00:00Z"),
383 album("a2", "Family", "friend", 50, "2025-01-10T00:00:00Z"),
384 album("a3", "Archive 2020", "me", 1000, "2020-12-31T00:00:00Z"),
385 album("a4", "beach-volleyball", "me", 12, "2023-09-15T00:00:00Z"),
386 ]
387 }
388
389 #[test]
390 fn project_filters_by_case_insensitive_substring() {
391 let items = fixture();
392 let (_, owned, _) = project_albums(&items, "me", "beach", AlbumsSort::Name);
393 let names: Vec<&str> = owned.iter().map(|a| a.album_name.as_str()).collect();
394 assert_eq!(names, vec!["Beach Trip", "beach-volleyball"]);
395 }
396
397 #[test]
398 fn project_sort_newest_orders_by_created_desc() {
399 let items = fixture();
400 let (_, owned, _) = project_albums(&items, "me", "", AlbumsSort::Newest);
401 let ids: Vec<&str> = owned.iter().map(|a| a.id.as_str()).collect();
402 assert_eq!(ids, vec!["a1", "a4", "a3"]);
403 }
404
405 #[test]
406 fn project_sort_name_is_case_insensitive() {
407 let items = fixture();
408 let (_, owned, _) = project_albums(&items, "me", "", AlbumsSort::Name);
409 let names: Vec<&str> = owned.iter().map(|a| a.album_name.as_str()).collect();
410 assert_eq!(
412 names,
413 vec!["Archive 2020", "Beach Trip", "beach-volleyball"]
414 );
415 }
416
417 #[test]
418 fn project_sort_most_assets_descends() {
419 let items = fixture();
420 let (_, owned, _) = project_albums(&items, "me", "", AlbumsSort::MostAssets);
421 let counts: Vec<u32> = owned.iter().map(|a| a.asset_count).collect();
422 assert_eq!(counts, vec![1000, 200, 12]);
423 }
424
425 #[test]
426 fn project_buckets_by_owner() {
427 let items = fixture();
428 let (_, owned, shared) = project_albums(&items, "me", "", AlbumsSort::Newest);
429 assert_eq!(owned.len(), 3);
430 assert_eq!(shared.len(), 1);
431 assert_eq!(shared[0].id, "a2");
432 }
433
434 #[test]
435 fn project_empty_current_user_excludes_from_both_buckets() {
436 let items = fixture();
439 let (recent, owned, shared) = project_albums(&items, "", "", AlbumsSort::Newest);
440 assert!(owned.is_empty());
441 assert!(shared.is_empty());
442 assert_eq!(recent.len(), 4);
444 }
445
446 #[test]
447 fn project_recent_always_by_date_even_under_alternate_sort() {
448 let items = fixture();
449 let (recent, _, _) = project_albums(&items, "me", "", AlbumsSort::MostAssets);
450 let ids: Vec<&str> = recent.iter().map(|a| a.id.as_str()).collect();
451 assert_eq!(ids[0], "a2");
454 }
455
456 #[test]
457 fn project_recent_caps_at_eight() {
458 let mut items = Vec::new();
459 for i in 0..20 {
460 items.push(album(
461 &format!("id{i}"),
462 &format!("Album {i}"),
463 "me",
464 i,
465 &format!("2024-01-{:02}T00:00:00Z", i + 1),
466 ));
467 }
468 let (recent, _, _) = project_albums(&items, "me", "", AlbumsSort::Newest);
469 assert_eq!(recent.len(), 8);
470 }
471}