Skip to main content

mimick/library/
controls.rs

1//! Header bar button wiring, search, sort, source-mode, timeline, and sidebar/grid handlers.
2//!
3//! Connects signal handlers for the library header bar controls including
4//! the search entry, sort-mode dropdown, view-source toggle, timeline
5//! scrubber, and sidebar collapse button.
6
7use std::rc::Rc;
8
9use glib::clone;
10use gtk::prelude::*;
11use libadwaita::prelude::*;
12
13use crate::library::asset_object::AssetObject;
14use crate::library::state::{LibrarySortMode, LibrarySource};
15use crate::settings_window::{build_settings_window_with_parent, show_queue_inspector};
16
17use super::download::{open_local_with_default_app, spawn_video_handoff};
18use super::{
19    LibraryWindowUi, apply_timeline_ui_state, load_albums, load_source_page, load_status,
20    open_lightbox, refresh_albums_view, update_timeline_banner_if_active,
21};
22
23pub(super) fn connect_controls(ui: Rc<LibraryWindowUi>) {
24    let action_settings = gtk::gio::SimpleAction::new("settings", None);
25    action_settings.connect_activate(clone!(
26        #[strong]
27        ui,
28        move |_, _| {
29            build_settings_window_with_parent(&ui.app, ui.ctx.clone(), Some(&ui.window));
30        }
31    ));
32    ui.window.add_action(&action_settings);
33
34    let action_queue = gtk::gio::SimpleAction::new("queue", None);
35    action_queue.connect_activate(clone!(
36        #[strong]
37        ui,
38        move |_, _| {
39            show_queue_inspector(&ui.window, ui.ctx.queue_manager.clone());
40        }
41    ));
42    ui.window.add_action(&action_queue);
43
44    let action_refresh = gtk::gio::SimpleAction::new("refresh", None);
45    action_refresh.connect_activate(clone!(
46        #[strong]
47        ui,
48        move |_, _| {
49            refresh_library_surfaces(ui.clone(), true);
50        }
51    ));
52    ui.window.add_action(&action_refresh);
53
54    ui.sidebar.connection_row.connect_activated(clone!(
55        #[strong]
56        ui,
57        move |_| {
58            super::server_stats_dialog::present(ui.ctx.clone(), &ui.window);
59        }
60    ));
61    ui.sidebar.server_row.connect_activated(clone!(
62        #[strong]
63        ui,
64        move |_| {
65            super::server_stats_dialog::present(ui.ctx.clone(), &ui.window);
66        }
67    ));
68
69    ui.upload_button.connect_clicked(clone!(
70        #[strong]
71        ui,
72        move |_| {
73            let album = match ui.ctx.library_state.lock().source.clone() {
74                LibrarySource::Album { id, name }
75                | LibrarySource::AlbumLocal { id, name }
76                | LibrarySource::AlbumUnified { id, name } => Some((id, name)),
77                _ => None,
78            };
79            super::upload_picker::pick_and_upload(&ui.window, ui.ctx.clone(), album);
80        }
81    ));
82
83    ui.back_button.connect_clicked(clone!(
84        #[strong]
85        ui,
86        move |_| {
87            navigate_back(ui.clone());
88        }
89    ));
90
91    // Alt+Left -> back. Attached to the window so it works regardless of focus.
92    let alt_left = gtk::Shortcut::builder()
93        .trigger(&gtk::ShortcutTrigger::parse_string("<Alt>Left").unwrap())
94        .action(&gtk::CallbackAction::new(clone!(
95            #[strong]
96            ui,
97            move |_, _| {
98                if navigate_back(ui.clone()) {
99                    glib::Propagation::Stop
100                } else {
101                    glib::Propagation::Proceed
102                }
103            }
104        )))
105        .build();
106    let alt_left_controller = gtk::ShortcutController::new();
107    alt_left_controller.add_shortcut(alt_left);
108    ui.window.add_controller(alt_left_controller);
109
110    let f5_controller = gtk::EventControllerKey::new();
111    f5_controller.connect_key_pressed(clone!(
112        #[strong]
113        ui,
114        move |_, keyval, _, _| {
115            if keyval == gtk::gdk::Key::F5 {
116                let _ = ui
117                    .window
118                    .upcast_ref::<gtk::Widget>()
119                    .activate_action("win.refresh", None);
120                glib::Propagation::Stop
121            } else {
122                glib::Propagation::Proceed
123            }
124        }
125    ));
126    ui.window.add_controller(f5_controller);
127
128    // Rebuild the sort dropdown model when the visible content view changes:
129    // each view has its own sort taxonomy.
130    ui.content_stack.connect_visible_child_notify(clone!(
131        #[strong]
132        ui,
133        move |stack| {
134            let view = stack.visible_child_name();
135            match view.as_deref() {
136                Some("albums") => {
137                    let model = gtk::StringList::new(&["Newest", "Name", "Most assets"]);
138                    ui.sort_mode.set_model(Some(&model));
139                    ui.sort_mode.set_selected(0);
140                    super::albums_view::set_search_filter(&ui.albums, "");
141                }
142                Some("explore") => {
143                    let model = gtk::StringList::new(&["Default"]);
144                    ui.sort_mode.set_model(Some(&model));
145                    ui.sort_mode.set_selected(0);
146                    super::explore_view::set_people_search(&ui.explore, "");
147                }
148                _ => {
149                    let model = gtk::StringList::new(&["Newest", "Filename", "File Type"]);
150                    ui.sort_mode.set_model(Some(&model));
151                    ui.sort_mode.set_selected(0);
152                    super::albums_view::set_search_filter(&ui.albums, "");
153                    super::explore_view::set_people_search(&ui.explore, "");
154                }
155            }
156        }
157    ));
158
159    ui.source_mode.connect_selected_notify(clone!(
160        #[strong]
161        ui,
162        move |dropdown| {
163            if ui.source_mode_suppressed.get() {
164                return;
165            }
166            let album_ctx = match ui.ctx.library_state.lock().source.clone() {
167                LibrarySource::Album { id, name }
168                | LibrarySource::AlbumLocal { id, name }
169                | LibrarySource::AlbumUnified { id, name } => Some((id, name)),
170                _ => None,
171            };
172            let source = match (dropdown.selected(), album_ctx) {
173                (1, Some((id, name))) => LibrarySource::AlbumLocal { id, name },
174                (1, None) => LibrarySource::LocalAll,
175                (2, Some((id, name))) => LibrarySource::AlbumUnified { id, name },
176                (2, None) => LibrarySource::Unified,
177                (_, Some((id, name))) => LibrarySource::Album { id, name },
178                (_, None) => {
179                    if ui.timeline_toggle.is_active() {
180                        LibrarySource::Timeline
181                    } else {
182                        LibrarySource::AllAssets
183                    }
184                }
185            };
186
187            let request = ui.ctx.library_state.lock().navigate_to(source);
188            apply_timeline_ui_state(&ui, &request.1);
189            load_source_page(ui.clone(), request, false);
190            update_back_button(&ui);
191        }
192    ));
193
194    ui.timeline_toggle.connect_toggled(clone!(
195        #[strong]
196        ui,
197        move |toggle| {
198            if !toggle.is_sensitive() {
199                return;
200            }
201            let current = ui.ctx.library_state.lock().source.clone();
202            if !matches!(current, LibrarySource::AllAssets | LibrarySource::Timeline) {
203                toggle.set_active(false);
204                return;
205            }
206            if matches!(current, LibrarySource::Timeline) == toggle.is_active() {
207                return;
208            }
209            let next_source = if toggle.is_active() {
210                LibrarySource::Timeline
211            } else {
212                LibrarySource::AllAssets
213            };
214            let request = ui.ctx.library_state.lock().navigate_to(next_source);
215            apply_timeline_ui_state(&ui, &request.1);
216            load_source_page(ui.clone(), request, false);
217            update_back_button(&ui);
218        }
219    ));
220
221    ui.sort_mode.connect_selected_notify(clone!(
222        #[strong]
223        ui,
224        move |dropdown| {
225            // Route sort selection by active view. Albums uses an
226            // album-specific sort taxonomy; Explore intentionally doesn't
227            // sort the people row (curated server order).
228            let view = ui.content_stack.visible_child_name();
229            if view.as_deref() == Some("albums") {
230                let mode = match dropdown.selected() {
231                    1 => super::albums_view::AlbumsSort::Name,
232                    2 => super::albums_view::AlbumsSort::MostAssets,
233                    _ => super::albums_view::AlbumsSort::Newest,
234                };
235                super::albums_view::set_sort_mode(&ui.albums, mode);
236                return;
237            }
238            let sort_mode = match dropdown.selected() {
239                1 => LibrarySortMode::Filename,
240                2 => LibrarySortMode::FileType,
241                _ => LibrarySortMode::NewestFirst,
242            };
243
244            let mut state = ui.ctx.library_state.lock();
245            state.apply_sort(sort_mode);
246            ui.grid
247                .model
248                .reset(&ui.ctx, &state.assets, &state.sort_mode);
249        }
250    ));
251}
252
253pub(super) fn refresh_library_surfaces(ui: Rc<LibraryWindowUi>, include_current_source: bool) {
254    load_albums(ui.clone());
255    load_status(ui.clone());
256
257    // Clearing `populated` marks the cached explore/albums grids as stale
258    // but, by itself, won't repaint a visible tab. Re-trigger the loader
259    // for whichever grid is currently on-screen so the user sees fresh
260    // content (with spinners) instead of silently-stale tiles.
261    let visible = ui.content_stack.visible_child_name();
262    ui.explore.populated.set(false);
263    match visible.as_deref() {
264        Some("explore") => super::load_explore_landing(ui.clone()),
265        Some("albums") => super::refresh_albums_view(ui.clone()),
266        _ => {}
267    }
268
269    if include_current_source {
270        let request = {
271            let source = ui.ctx.library_state.lock().source.clone();
272            ui.ctx.library_state.lock().switch_source(source)
273        };
274        load_source_page(ui, request, false);
275    }
276}
277
278pub(super) fn refresh_library_after_mutation(ui: Rc<LibraryWindowUi>, prefer_current_source: bool) {
279    refresh_library_surfaces(ui, prefer_current_source);
280}
281
282pub(super) fn connect_sidebar_handlers(ui: Rc<LibraryWindowUi>) {
283    // Photos / Explore (fixed destinations).
284    ui.sidebar.fixed_list.connect_row_selected(clone!(
285        #[strong]
286        ui,
287        move |_, row| {
288            let Some(row) = row else {
289                return;
290            };
291            let key = row.tooltip_text().unwrap_or_default();
292            ui.sidebar.albums_list.unselect_all();
293            let is_search = key.as_str() == "search";
294            // Show/hide the search form revealer.
295            ui.search_view.root.set_reveal_child(is_search);
296            match key.as_str() {
297                "photos" => sidebar_dispatch(ui.clone(), LibrarySource::Timeline),
298                "search" => {
299                    // Show the empty state so the grid area isn't blank.
300                    // Once the user submits a search, results replace this.
301                    ui.content_stack.set_visible_child_name("empty");
302                    // Focus the search entry for immediate typing.
303                    ui.search_view.search_entry.grab_focus();
304                }
305                "explore" => sidebar_dispatch(ui.clone(), LibrarySource::Explore),
306                "albums" => {
307                    ui.album_link_row.set_visible(false);
308                    if let Some(parent) = ui.album_link_row.parent() {
309                        parent.set_visible(false);
310                    }
311                    if ui.albums.populated.get() {
312                        ui.content_stack.set_visible_child_name("albums");
313                    } else {
314                        ui.content_stack.set_visible_child_name("loading");
315                        refresh_albums_view(ui.clone());
316                    }
317                }
318                _ => {}
319            }
320            auto_hide_sidebar(&ui);
321        }
322    ));
323
324    ui.sidebar.albums_list.connect_row_selected(clone!(
325        #[strong]
326        ui,
327        move |_, row| {
328            if ui.sidebar_suppressed.get() {
329                return;
330            }
331            let Some(row) = row else {
332                return;
333            };
334            let Some(tooltip) = row.tooltip_text() else {
335                return;
336            };
337            let mut parts = tooltip.splitn(2, ':');
338            let id = parts.next().unwrap_or_default().to_string();
339            let name = parts.next().unwrap_or("Album").to_string();
340            ui.sidebar.fixed_list.unselect_all();
341            ui.search_view.root.set_reveal_child(false);
342            sidebar_dispatch(ui.clone(), LibrarySource::Album { id, name });
343            auto_hide_sidebar(&ui);
344        }
345    ));
346}
347
348fn auto_hide_sidebar(ui: &Rc<LibraryWindowUi>) {
349    if ui.split.is_collapsed() {
350        ui.split.set_show_sidebar(false);
351    }
352}
353
354pub(super) fn update_back_button(ui: &Rc<LibraryWindowUi>) {
355    let can_go_back = ui.ctx.library_state.lock().can_go_back();
356    ui.back_button.set_sensitive(can_go_back);
357}
358
359/// Pop the navigation history and switch to the previous source. Returns
360/// true if a back-step was taken, false when the history was empty.
361pub(super) fn navigate_back(ui: Rc<LibraryWindowUi>) -> bool {
362    let request = ui.ctx.library_state.lock().navigate_back();
363    if let Some(request) = request {
364        apply_timeline_ui_state(&ui, &request.1);
365        load_source_page(ui.clone(), request, false);
366        update_back_button(&ui);
367        true
368    } else {
369        false
370    }
371}
372
373/// Common path for sidebar selections. Skips redundant dispatches so the
374/// `reload_sidebar` programmatic re-selection doesn't loop into another
375/// fetch -- but only when the content stack is already showing the current
376/// source, since the Albums grid moves the stack without changing source.
377pub(super) fn sidebar_dispatch(ui: Rc<LibraryWindowUi>, source: LibrarySource) {
378    let on_albums_grid = ui.content_stack.visible_child_name().as_deref() == Some("albums");
379    if !on_albums_grid && ui.ctx.library_state.lock().source == source {
380        return;
381    }
382    let request = ui.ctx.library_state.lock().navigate_to(source);
383    apply_timeline_ui_state(&ui, &request.1);
384    load_source_page(ui.clone(), request, false);
385    update_back_button(&ui);
386}
387
388pub(super) fn connect_grid_handlers(ui: Rc<LibraryWindowUi>) {
389    let ui_for_activate = ui.clone();
390    ui.grid.canvas.set_activate_handler(move |position| {
391        let Some(item) = ui_for_activate
392            .grid
393            .model
394            .item(position)
395            .and_downcast::<AssetObject>()
396        else {
397            return;
398        };
399        let asset_id = item.property::<String>("id");
400        let filename = item.property::<String>("filename");
401        let local_path = item.property::<String>("local-path");
402        let asset_type = item.property::<String>("asset-type");
403
404        // Videos open in the system default player.
405        if asset_type.eq_ignore_ascii_case("VIDEO") {
406            if !local_path.is_empty() {
407                open_local_with_default_app(&local_path);
408            } else {
409                spawn_video_handoff(ui_for_activate.clone(), asset_id, filename);
410            }
411            return;
412        }
413
414        open_lightbox(ui_for_activate.clone(), position);
415    });
416
417    let scroll_pending = Rc::new(std::cell::Cell::new(false));
418    ui.grid.scrolled.vadjustment().connect_value_changed(clone!(
419        #[strong]
420        ui,
421        #[strong]
422        scroll_pending,
423        move |_adj| {
424            if scroll_pending.replace(true) {
425                return;
426            }
427            let ui = ui.clone();
428            let scroll_pending = scroll_pending.clone();
429            glib::idle_add_local_once(move || {
430                scroll_pending.set(false);
431                let adj = ui.grid.scrolled.vadjustment();
432                update_timeline_banner_if_active(&ui, &adj);
433
434                let threshold = (adj.upper() - adj.page_size()) * 0.50;
435                if adj.value() < threshold {
436                    return;
437                }
438
439                let next = ui.ctx.library_state.lock().load_next_page_if_needed();
440                if let Some(request) = next {
441                    load_source_page(ui.clone(), request, true);
442                }
443            });
444        }
445    ));
446}