1use 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
33pub(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
56pub(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
68pub(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
91pub(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 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 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: &[>k::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(>k::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 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(>k::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
375fn 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
465fn 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 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: >k::Box,
543 content: &impl IsA<gtk::Widget>,
544 sidebar_toggle: >k::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
565fn 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
591fn 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
614fn 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 window.add_breakpoint(breakpoint("min-width: 600px and max-width: 1000px"));
639}
640
641fn add_sidebar_shortcut(
643 window: &libadwaita::ApplicationWindow,
644 split: &libadwaita::OverlaySplitView,
645) {
646 let f9 = gtk::Shortcut::builder()
647 .trigger(>k::ShortcutTrigger::parse_string("F9").unwrap())
648 .action(>k::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}