Skip to main content

mimick/library/
window_layout.rs

1//! Widget construction for the library window.
2//!
3//! Builds the header, controls, content stack, transfer and selection bars, and
4//! the responsive breakpoints. `build_library_window` in the parent module wires
5//! signal handlers onto the result.
6
7use std::cell::Cell;
8use std::rc::Rc;
9use std::sync::Arc;
10
11use glib::clone;
12use gtk::prelude::*;
13use libadwaita::prelude::*;
14
15use crate::app_context::AppContext;
16use crate::library::albums_view::{AlbumsViewParts, build_albums_view};
17use crate::library::explore_view::{ExploreViewParts, build_explore_view};
18use crate::library::masonry::{GridViewParts, build_grid_view};
19use crate::library::search_view::{SearchViewParts, build_search_view};
20use crate::library::sidebar::{SidebarParts, build_sidebar};
21
22use super::shared_links_view::{SharedLinksView, build_shared_links_view};
23use super::trash_view::{self, TrashControls};
24use super::{LibraryWindowUi, build_drop_overlay, build_loading_view, build_status_view};
25
26pub(super) struct HeaderParts {
27    header: libadwaita::HeaderBar,
28    sidebar_toggle: gtk::ToggleButton,
29    back_button: gtk::Button,
30    select_toggle: gtk::ToggleButton,
31}
32
33/// Source/timeline and sort/upload controls above the grid.
34pub(super) struct ControlsParts {
35    root: gtk::Box,
36    source_mode: gtk::DropDown,
37    source_revealer: gtk::Revealer,
38    timeline_toggle: gtk::ToggleButton,
39    sort_mode: gtk::DropDown,
40    upload_button: gtk::Button,
41}
42
43pub(super) struct TransferParts {
44    pub indicator: gtk::Box,
45    pub icon: gtk::Image,
46    pub progress: gtk::ProgressBar,
47}
48
49pub(super) struct AlbumLinkParts {
50    pub listbox: gtk::ListBox,
51    row: libadwaita::ActionRow,
52    link_button: gtk::Button,
53    sync_button: gtk::Button,
54}
55
56/// Bottom selection bar; `delete`, `download`, and `clear` are wired by `connect_bulk_actions`.
57pub(super) struct BulkParts {
58    bar: gtk::Revealer,
59    count_label: gtk::Label,
60    pub delete: gtk::Button,
61    pub download: gtk::Button,
62    pub clear: gtk::Button,
63    pub share_link: gtk::Button,
64    archive: gtk::Button,
65    trash: TrashControls,
66}
67
68/// Everything shown in the main content column, plus the sidebar.
69pub(super) struct ContentParts {
70    sidebar: SidebarParts,
71    grid: GridViewParts,
72    explore: ExploreViewParts,
73    albums: AlbumsViewParts,
74    shared_links: SharedLinksView,
75    people: super::people_view::PeopleView,
76    tags: super::tags_view::TagsView,
77    folders: super::folders_view::FoldersView,
78    sharing: super::sharing_view::SharingView,
79    header: super::source_header::SourceHeader,
80    bottom_nav: super::bottom_nav::BottomNav,
81    search_view: SearchViewParts,
82    controls: ControlsParts,
83    banner: gtk::Label,
84    stack: gtk::Stack,
85    error_label: gtk::Label,
86    transfer: TransferParts,
87    pub album_link: AlbumLinkParts,
88    pub bulk: BulkParts,
89}
90
91/// The whole library window before signal handlers are connected.
92pub(super) struct WindowParts {
93    pub window: libadwaita::ApplicationWindow,
94    nav: libadwaita::NavigationView,
95    header: HeaderParts,
96    pub content: ContentParts,
97    split: libadwaita::OverlaySplitView,
98    drop_overlay: gtk::Revealer,
99}
100
101impl WindowParts {
102    pub(super) fn build(app: &libadwaita::Application, ctx: &Arc<AppContext>) -> Self {
103        let window = libadwaita::ApplicationWindow::builder()
104            .application(app)
105            .title("Mimick Library")
106            .name("mimick-library-window")
107            .default_width(1480)
108            .default_height(780)
109            .width_request(360)
110            .height_request(480)
111            .build();
112        let header = build_header();
113        // Shared with the grid canvas so masonry layout knows when the window is narrow.
114        let narrow = Rc::new(Cell::new(false));
115        let content = ContentParts::build(ctx, header.select_toggle.clone(), narrow.clone());
116        let (content_with_drop, drop_overlay) = build_drop_overlay(content.column());
117        let split = build_split(
118            &content.sidebar.root,
119            &content_with_drop,
120            &header.sidebar_toggle,
121        );
122        let nav = build_nav(&window, &header.header, &split);
123        header.header.pack_end(&content.transfer.indicator);
124
125        add_narrow_breakpoint(&window, narrow, &content);
126        add_desktop_breakpoint(&window, &content, &split);
127        add_sidebar_shortcut(&window, &split);
128        Self {
129            window,
130            nav,
131            header,
132            content,
133            split,
134            drop_overlay,
135        }
136    }
137}
138
139impl ContentParts {
140    fn build(
141        ctx: &Arc<AppContext>,
142        select_toggle: gtk::ToggleButton,
143        narrow: Rc<Cell<bool>>,
144    ) -> Self {
145        let grid = build_grid_view(ctx.clone(), select_toggle, narrow);
146        let explore = build_explore_view();
147        let albums = build_albums_view();
148        let (stack, error_label) = build_content_stack(&grid, &explore, &albums);
149        let shared_links = build_shared_links_view();
150        stack.add_named(&shared_links.root, Some("shared-links"));
151        let people = super::people_view::build_people_view();
152        stack.add_named(&people.root, Some("people"));
153        let tags = super::tags_view::build_tags_view();
154        stack.add_named(&tags.page.root, Some("tags"));
155        let folders = super::folders_view::build_folders_view();
156        stack.add_named(&folders.page.root, Some("folders"));
157        let sharing = super::sharing_view::build_sharing_view();
158        stack.add_named(&sharing.root, Some("sharing"));
159        let header = super::source_header::build_source_header();
160        let album_link = build_album_link();
161        let bulk = build_bulk_bar();
162        header.extra.append(&album_link.listbox);
163        header.extra.append(&bulk.trash.bar);
164        grid.content.prepend(&header.status);
165        grid.content.prepend(&header.root);
166        Self {
167            sidebar: build_sidebar(),
168            grid,
169            explore,
170            albums,
171            shared_links,
172            people,
173            tags,
174            folders,
175            sharing,
176            header,
177            bottom_nav: super::bottom_nav::build_bottom_nav(),
178            search_view: build_search_view(),
179            controls: build_controls(),
180            banner: build_timeline_banner(),
181            stack,
182            error_label,
183            transfer: build_transfer_indicator(),
184            album_link,
185            bulk,
186        }
187    }
188
189    /// Main column, top to bottom.
190    fn column(&self) -> gtk::Box {
191        let column = gtk::Box::builder()
192            .orientation(gtk::Orientation::Vertical)
193            .build();
194        column.append(&self.controls.root);
195        column.append(&self.search_view.root);
196        column.append(&self.banner);
197        column.append(&self.stack);
198        column.append(&self.bottom_nav.root);
199        column.append(&self.bulk.bar);
200        column
201    }
202}
203
204impl LibraryWindowUi {
205    pub(super) fn from_parts(
206        ctx: Arc<AppContext>,
207        app: &libadwaita::Application,
208        parts: WindowParts,
209    ) -> Self {
210        let (header, content) = (parts.header, parts.content);
211        let controls = content.controls;
212        Self {
213            ctx,
214            app: app.clone(),
215            window: parts.window,
216            nav: parts.nav,
217            sidebar: content.sidebar,
218            grid: content.grid,
219            explore: content.explore,
220            albums: content.albums,
221            shared_links: content.shared_links,
222            people: content.people,
223            tags: content.tags,
224            folders: content.folders,
225            sharing: content.sharing,
226            header: content.header,
227            bottom_nav: content.bottom_nav,
228            features: Cell::default(),
229            content_stack: content.stack,
230            error_label: content.error_label,
231            transfer: content.transfer,
232            search_view: content.search_view,
233            sort_mode: controls.sort_mode,
234            source_mode: controls.source_mode,
235            source_revealer: controls.source_revealer,
236            upload_button: controls.upload_button,
237            timeline_toggle: controls.timeline_toggle,
238            timeline_banner: content.banner,
239            source_mode_suppressed: Cell::new(false),
240            sidebar_suppressed: Cell::new(false),
241            back_button: header.back_button,
242            select_toggle: header.select_toggle,
243            bulk_bar: content.bulk.bar,
244            bulk_archive: content.bulk.archive,
245            trash: content.bulk.trash,
246            bulk_count_label: content.bulk.count_label,
247            album_link_row: content.album_link.row,
248            album_link_button: content.album_link.link_button,
249            album_sync_button: content.album_link.sync_button,
250            last_seen_upload_batch: Cell::new(0),
251            split: parts.split,
252            drop_overlay: parts.drop_overlay,
253        }
254    }
255}
256
257fn pressable_button(icon: &str, tooltip: &str) -> gtk::Button {
258    gtk::Button::builder()
259        .icon_name(icon)
260        .tooltip_text(tooltip)
261        .css_classes(["mimick-pressable"])
262        .build()
263}
264
265fn build_header() -> HeaderParts {
266    let header = libadwaita::HeaderBar::builder()
267        .show_start_title_buttons(true)
268        .show_end_title_buttons(true)
269        .build();
270    let sidebar_toggle = gtk::ToggleButton::builder()
271        .icon_name("sidebar-show-symbolic")
272        .tooltip_text("Toggle sidebar (F9)")
273        .active(true)
274        .css_classes(["mimick-pressable"])
275        .build();
276    let back_button = pressable_button("go-previous-symbolic", "Back (Alt+Left)");
277    back_button.set_sensitive(false);
278    let menu = gtk::gio::Menu::new();
279    menu.append(Some("Refresh"), Some("win.refresh"));
280    menu.append(Some("Queue Inspector"), Some("win.queue"));
281    menu.append(Some("Settings"), Some("win.settings"));
282    menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
283    let menu_button = gtk::MenuButton::builder()
284        .icon_name("open-menu-symbolic")
285        .menu_model(&menu)
286        .tooltip_text("Menu")
287        .css_classes(["mimick-pressable"])
288        .build();
289    header.pack_start(&sidebar_toggle);
290    header.pack_start(&back_button);
291    header.pack_end(&menu_button);
292    let select_toggle = gtk::ToggleButton::builder()
293        .icon_name("checkbox-symbolic")
294        .tooltip_text("Select assets (Esc to exit)")
295        .build();
296    HeaderParts {
297        header,
298        sidebar_toggle,
299        back_button,
300        select_toggle,
301    }
302}
303
304fn hbox(children: &[&gtk::Widget]) -> gtk::Box {
305    let row = gtk::Box::builder()
306        .orientation(gtk::Orientation::Horizontal)
307        .spacing(8)
308        .build();
309    for child in children {
310        row.append(*child);
311    }
312    row
313}
314
315fn build_controls() -> ControlsParts {
316    let source_mode = gtk::DropDown::builder()
317        .model(&gtk::StringList::new(&["Remote", "Local", "Unified"]))
318        .selected(0)
319        .tooltip_text("Asset source")
320        .build();
321    let timeline_toggle = gtk::ToggleButton::builder()
322        .label("Timeline")
323        .tooltip_text("Timeline view (all assets only)")
324        .build();
325    // Source mode (Remote/Local/Unified) is meaningful only inside a linked
326    // album; `apply_timeline_ui_state` reveals it per source kind.
327    let source_revealer = gtk::Revealer::builder()
328        .transition_type(gtk::RevealerTransitionType::SlideRight)
329        .transition_duration(180)
330        .reveal_child(false)
331        .child(&source_mode)
332        .build();
333    let sort_mode = gtk::DropDown::builder()
334        .model(&gtk::StringList::new(&["Newest", "Filename", "File Type"]))
335        .selected(0)
336        .build();
337    let upload_button = pressable_button("document-send-symbolic", "Upload to library");
338    upload_button.add_css_class("suggested-action");
339
340    let spacer = gtk::Box::builder().hexpand(true).build();
341    let root = hbox(&[
342        timeline_toggle.upcast_ref(),
343        source_revealer.upcast_ref(),
344        sort_mode.upcast_ref(),
345        spacer.upcast_ref(),
346        upload_button.upcast_ref(),
347    ]);
348    root.set_margin_top(8);
349    root.set_margin_bottom(8);
350    root.set_margin_start(8);
351    root.set_margin_end(8);
352    ControlsParts {
353        root,
354        source_mode,
355        source_revealer,
356        timeline_toggle,
357        sort_mode,
358        upload_button,
359    }
360}
361
362fn build_timeline_banner() -> gtk::Label {
363    gtk::Label::builder()
364        .xalign(0.0)
365        .css_classes(vec!["mimick-timeline-banner".to_string()])
366        .visible(false)
367        .ellipsize(gtk::pango::EllipsizeMode::End)
368        .max_width_chars(20)
369        .margin_top(4)
370        .margin_bottom(4)
371        .margin_start(12)
372        .build()
373}
374
375/// Stack of loading/empty/error states and the grid, Explore, and Albums views.
376/// Also returns the error view's subtitle label, which shows the load error.
377fn build_content_stack(
378    grid: &GridViewParts,
379    explore: &ExploreViewParts,
380    albums: &AlbumsViewParts,
381) -> (gtk::Stack, gtk::Label) {
382    let stack = gtk::Stack::builder()
383        .vexpand(true)
384        .hexpand(true)
385        .transition_type(gtk::StackTransitionType::Crossfade)
386        .transition_duration(180)
387        .build();
388    let empty_view = build_status_view(
389        "image-x-generic-symbolic",
390        "Nothing to show",
391        "No assets match the current view",
392    );
393    let error_view = build_status_view(
394        "dialog-warning-symbolic",
395        "Library data unavailable",
396        "Could not load library assets",
397    );
398    let error_label = error_view
399        .last_child()
400        .and_downcast::<gtk::Label>()
401        .expect("status-view subtitle label");
402    stack.add_named(&build_loading_view(), Some("loading"));
403    stack.add_named(&empty_view, Some("empty"));
404    stack.add_named(&error_view, Some("error"));
405    stack.add_named(&grid.scrolled, Some("grid"));
406    stack.add_named(&explore.root, Some("explore"));
407    stack.add_named(&albums.root, Some("albums"));
408    (stack, error_label)
409}
410
411fn build_transfer_indicator() -> TransferParts {
412    let icon = gtk::Image::from_icon_name("mimick-upload-symbolic");
413    let progress = gtk::ProgressBar::builder()
414        .width_request(28)
415        .valign(gtk::Align::Center)
416        .css_classes(["mimick-transfer-mini"])
417        .build();
418    let indicator = gtk::Box::builder()
419        .spacing(4)
420        .valign(gtk::Align::Center)
421        .visible(false)
422        .build();
423    indicator.append(&icon);
424    indicator.append(&progress);
425    TransferParts {
426        indicator,
427        icon,
428        progress,
429    }
430}
431
432fn build_album_link() -> AlbumLinkParts {
433    let row = libadwaita::ActionRow::builder()
434        .title("No local folder linked")
435        .subtitle("Drop files in the linked folder to sync this album")
436        .title_lines(1)
437        .subtitle_lines(2)
438        .build();
439    let sync_button = gtk::Button::builder()
440        .label("Sync")
441        .valign(gtk::Align::Center)
442        .css_classes(vec!["suggested-action".to_string()])
443        .visible(false)
444        .build();
445    let link_button = gtk::Button::builder()
446        .label("Link")
447        .valign(gtk::Align::Center)
448        .build();
449    row.add_suffix(&sync_button);
450    row.add_suffix(&link_button);
451    let listbox = gtk::ListBox::builder()
452        .selection_mode(gtk::SelectionMode::None)
453        .css_classes(vec!["boxed-list".to_string()])
454        .visible(false)
455        .build();
456    listbox.append(&row);
457    AlbumLinkParts {
458        listbox,
459        row,
460        link_button,
461        sync_button,
462    }
463}
464
465/// Delete, download, clear, and share-link buttons for the selection bar.
466fn bulk_buttons() -> (gtk::Button, gtk::Button, gtk::Button, gtk::Button) {
467    let delete = gtk::Button::builder()
468        .icon_name("user-trash-symbolic")
469        .tooltip_text("Delete selected")
470        .css_classes(vec!["destructive-action".to_string()])
471        .build();
472    let download = gtk::Button::builder()
473        .icon_name("mimick-download-symbolic")
474        .tooltip_text("Download selected")
475        .build();
476    let clear = gtk::Button::builder()
477        .icon_name("edit-clear-symbolic")
478        .tooltip_text("Clear selection")
479        .css_classes(vec!["flat".to_string()])
480        .build();
481    let share_link = gtk::Button::builder()
482        .icon_name("emblem-shared-symbolic")
483        .tooltip_text("Create share link for selected")
484        .build();
485    (delete, download, clear, share_link)
486}
487
488fn build_bulk_bar() -> BulkParts {
489    let count_label = gtk::Label::builder()
490        .xalign(0.0)
491        .hexpand(true)
492        .ellipsize(gtk::pango::EllipsizeMode::End)
493        .build();
494    let (delete, download, clear, share_link) = bulk_buttons();
495    let archive = gtk::Button::builder()
496        .icon_name("mimick-archive-symbolic")
497        .tooltip_text("Archive selected")
498        .build();
499    let inner = gtk::Box::builder()
500        .orientation(gtk::Orientation::Horizontal)
501        .spacing(8)
502        .margin_top(8)
503        .margin_bottom(16)
504        .margin_start(12)
505        .margin_end(12)
506        .css_classes(vec!["toolbar".to_string()])
507        .build();
508    // Share, download, and Move to trash are swapped for Restore / Delete Permanently
509    // in the Trash view.
510    let trash = trash_view::build_trash_controls(vec![
511        delete.clone(),
512        download.clone(),
513        share_link.clone(),
514        archive.clone(),
515    ]);
516    inner.append(&count_label);
517    inner.append(&clear);
518    inner.append(&share_link);
519    inner.append(&archive);
520    inner.append(&download);
521    inner.append(&delete);
522    inner.append(&trash.restore_selected);
523    inner.append(&trash.delete_selected);
524    let bar = gtk::Revealer::builder()
525        .transition_type(gtk::RevealerTransitionType::SlideUp)
526        .reveal_child(false)
527        .child(&inner)
528        .build();
529    BulkParts {
530        bar,
531        count_label,
532        delete,
533        download,
534        clear,
535        share_link,
536        archive,
537        trash,
538    }
539}
540
541fn build_split(
542    sidebar_root: &gtk::Box,
543    content: &impl IsA<gtk::Widget>,
544    sidebar_toggle: &gtk::ToggleButton,
545) -> libadwaita::OverlaySplitView {
546    let split = libadwaita::OverlaySplitView::builder()
547        .sidebar(sidebar_root)
548        .content(content)
549        .show_sidebar(true)
550        .collapsed(true)
551        .enable_show_gesture(true)
552        .enable_hide_gesture(true)
553        .min_sidebar_width(180.0)
554        .max_sidebar_width(260.0)
555        .sidebar_width_fraction(0.3)
556        .build();
557    split
558        .bind_property("show-sidebar", sidebar_toggle, "active")
559        .sync_create()
560        .bidirectional()
561        .build();
562    split
563}
564
565/// Navigation view whose root page holds the header and split view.
566fn build_nav(
567    window: &libadwaita::ApplicationWindow,
568    header: &libadwaita::HeaderBar,
569    split: &libadwaita::OverlaySplitView,
570) -> libadwaita::NavigationView {
571    let toolbar = libadwaita::ToolbarView::builder().build();
572    toolbar.add_top_bar(header);
573    toolbar.set_content(Some(split));
574    let nav = libadwaita::NavigationView::new();
575    let root_page = libadwaita::NavigationPage::builder()
576        .child(&toolbar)
577        .title("Library")
578        .can_pop(false)
579        .build();
580    nav.add(&root_page);
581    window.set_content(Some(&nav));
582    nav
583}
584
585fn breakpoint(condition: &str) -> libadwaita::Breakpoint {
586    libadwaita::Breakpoint::new(
587        libadwaita::BreakpointCondition::parse(condition).expect("valid breakpoint condition"),
588    )
589}
590
591/// Phone width: show the bottom navigation bar and switch the grid to narrow layout.
592fn add_narrow_breakpoint(
593    window: &libadwaita::ApplicationWindow,
594    narrow: Rc<Cell<bool>>,
595    content: &ContentParts,
596) {
597    let narrow_bp = breakpoint("max-width: 600px");
598    narrow_bp.add_setter(&content.bottom_nav.root, "visible", Some(&true.to_value()));
599    let (narrow_apply, canvas_apply) = (narrow.clone(), content.grid.canvas.clone());
600    narrow_bp.connect_apply(move |_| {
601        log::info!("NARROW breakpoint APPLIED: setting narrow=true");
602        narrow_apply.set(true);
603        canvas_apply.set_narrow(true);
604    });
605    let canvas_unapply = content.grid.canvas.clone();
606    narrow_bp.connect_unapply(move |_| {
607        log::info!("NARROW breakpoint UNAPPLIED: setting narrow=false");
608        narrow.set(false);
609        canvas_unapply.set_narrow(false);
610    });
611    window.add_breakpoint(narrow_bp);
612}
613
614/// Desktop width: horizontal controls, docked sidebar, and longer album-link labels.
615fn add_desktop_breakpoint(
616    window: &libadwaita::ApplicationWindow,
617    content: &ContentParts,
618    split: &libadwaita::OverlaySplitView,
619) {
620    let desktop_bp = breakpoint("min-width: 600px");
621    let window_apply = window.clone();
622    desktop_bp.connect_apply(move |_| window_apply.add_css_class("mimick-wide"));
623    let window_unapply = window.clone();
624    desktop_bp.connect_unapply(move |_| window_unapply.remove_css_class("mimick-wide"));
625    desktop_bp.add_setter(split, "collapsed", Some(&false.to_value()));
626    desktop_bp.add_setter(
627        &content.album_link.sync_button,
628        "label",
629        Some(&"Sync…".to_value()),
630    );
631    desktop_bp.add_setter(
632        &content.album_link.link_button,
633        "label",
634        Some(&"Link folder…".to_value()),
635    );
636    window.add_breakpoint(desktop_bp);
637    // Tablet width: reserved for chrome tweaks below typical desktop widths.
638    window.add_breakpoint(breakpoint("min-width: 600px and max-width: 1000px"));
639}
640
641/// F9 toggles the sidebar.
642fn add_sidebar_shortcut(
643    window: &libadwaita::ApplicationWindow,
644    split: &libadwaita::OverlaySplitView,
645) {
646    let f9 = gtk::Shortcut::builder()
647        .trigger(&gtk::ShortcutTrigger::parse_string("F9").unwrap())
648        .action(&gtk::CallbackAction::new(clone!(
649            #[strong]
650            split,
651            move |_, _| {
652                split.set_show_sidebar(!split.shows_sidebar());
653                glib::Propagation::Stop
654            }
655        )))
656        .build();
657    let shortcut_controller = gtk::ShortcutController::new();
658    shortcut_controller.add_shortcut(f9);
659    window.add_controller(shortcut_controller);
660}