Skip to main content

mimick/library/
albums_view.rs

1//! Albums landing page with Recent / Owned / Shared sections.
2//!
3//! Fetches album cover thumbnails and renders them as clickable tiles
4//! in a responsive flow layout. Selecting a tile navigates the main
5//! grid to that album's contents.
6
7use 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/// Sort modes available for the albums landing page.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum AlbumsSort {
21    #[default]
22    Newest,
23    Name,
24    MostAssets,
25}
26
27/// Contains references to individual grid widgets of the albums overview display.
28pub 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
46/// Construct the hierarchical panels and containers for the albums listing page.
47pub 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
118/// Populate the album list grids grouped by ownership status.
119pub 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
131/// Set the current search filter and re-render. Empty string clears the filter.
132pub fn set_search_filter(parts: &AlbumsViewParts, query: &str) {
133    *parts.search_query.borrow_mut() = query.to_string();
134    render_albums(parts);
135}
136
137/// Set the current sort mode and re-render.
138pub fn set_sort_mode(parts: &AlbumsViewParts, mode: AlbumsSort) {
139    parts.sort_mode.set(mode);
140    render_albums(parts);
141}
142
143/// Pure projection over the cached album list. Returns the (recent, owned,
144/// shared) buckets that drive the three Albums-view rows after applying the
145/// active search filter and sort mode. Extracted from `render_albums` so the
146/// ordering rules can be unit-tested without GTK.
147pub(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    // The "Recent" bucket always reflects creation order, even when the user
175    // picks a different sort for the main owned/shared rows.
176    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, &current_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
238/// Build a single category grid section with a text label and a flow box layout.
239fn 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
263/// Build a clickable tile representing an individual album complete with cover art.
264fn 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    // Fixed-height thumbnail container (same pattern as explore_tile):
271    // the Overlay sizes itself from the spacer child (100px) so portrait
272    // cover images cannot inflate the row height.
273    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
329/// Asynchronously load and set the thumbnail for an album cover art picture widget.
330fn 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
351/// Remove all child widgets from the specified flow box.
352fn clear(flow: &gtk::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        // "Archive 2020", "Beach Trip", "beach-volleyball" — lowercase-sorted.
411        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        // Without a known user id we can't safely classify owner vs shared,
437        // so both buckets stay empty to avoid mis-labeling.
438        let items = fixture();
439        let (recent, owned, shared) = project_albums(&items, "", "", AlbumsSort::Newest);
440        assert!(owned.is_empty());
441        assert!(shared.is_empty());
442        // Recent still populates regardless of user identity.
443        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        // Recent is creation-order, not asset-count, so the "Family" album
452        // (newest) leads even though it's not the largest.
453        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}