mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
feat(gui): photos context menu, filter empty states and import banner (phase 5)
This commit is contained in:
1 parent
8bc52edeb4
commit
fdda35918f
5 files changed
+275
-13
No files matched your search
@@ -459,6 +459,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
status: gallery_widgets.status.clone(),
|
status: gallery_widgets.status.clone(),
|
||||||
retry: gallery_widgets.retry.clone(),
|
retry: gallery_widgets.retry.clone(),
|
||||||
more: gallery_widgets.more.clone(),
|
more: gallery_widgets.more.clone(),
|
||||||
|
import_banner: gallery_widgets.import_banner.clone(),
|
||||||
upload: gallery_widgets.upload.clone(),
|
upload: gallery_widgets.upload.clone(),
|
||||||
import: gallery_widgets.import.clone(),
|
import: gallery_widgets.import.clone(),
|
||||||
empty_actions: gallery_widgets.empty_actions.clone(),
|
empty_actions: gallery_widgets.empty_actions.clone(),
|
||||||
@@ -1126,6 +1127,12 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
sign_out_action.connect_activate(move |_, _| sign_out(&ui_out));
|
sign_out_action.connect_activate(move |_, _| sign_out(&ui_out));
|
||||||
window.add_action(&sign_out_action);
|
window.add_action(&sign_out_action);
|
||||||
|
|
||||||
|
// The Photos page's import banner leads here.
|
||||||
|
let show_import = gio::SimpleAction::new("show-import", None);
|
||||||
|
let ui_import = ui.clone();
|
||||||
|
show_import.connect_activate(move |_, _| ui_import.stack.set_visible_child_name("takeout"));
|
||||||
|
window.add_action(&show_import);
|
||||||
|
|
||||||
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
||||||
let win = window.clone();
|
let win = window.clone();
|
||||||
shortcuts.connect_activate(move |_, _| show_shortcuts(&win));
|
shortcuts.connect_activate(move |_, _| show_shortcuts(&win));
|
||||||
@@ -1208,6 +1215,7 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
|
|||||||
("<Control>plus", "Larger thumbnails"),
|
("<Control>plus", "Larger thumbnails"),
|
||||||
("<Control>minus", "Smaller thumbnails"),
|
("<Control>minus", "Smaller thumbnails"),
|
||||||
("<Control>0", "Reset thumbnail size"),
|
("<Control>0", "Reset thumbnail size"),
|
||||||
|
("<Control>a", "Select all"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ fn set_favorite_icon(button: >k4::ToggleButton, favorite: bool) {
|
|||||||
|
|
||||||
/// Write a favourite change back into the loaded gallery page, so returning to
|
/// Write a favourite change back into the loaded gallery page, so returning to
|
||||||
/// the grid (or reopening the photo) shows what was just set without a reload.
|
/// the grid (or reopening the photo) shows what was just set without a reload.
|
||||||
fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
|
pub(crate) fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
|
||||||
let Some(idx) = find_photo_index(&ui.gallery.model, uid) else {
|
let Some(idx) = find_photo_index(&ui.gallery.model, uid) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ pub(crate) struct GalleryState {
|
|||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) more: gtk4::Button,
|
pub(crate) more: gtk4::Button,
|
||||||
|
/// Says a Google Photos import is running, with a way to its page.
|
||||||
|
pub(crate) import_banner: adw::Banner,
|
||||||
pub(crate) upload: gtk4::Button,
|
pub(crate) upload: gtk4::Button,
|
||||||
/// Opens the Google Photos Takeout import chooser.
|
/// Opens the Google Photos Takeout import chooser.
|
||||||
pub(crate) import: gtk4::Button,
|
pub(crate) import: gtk4::Button,
|
||||||
@@ -150,7 +152,7 @@ pub(crate) struct GalleryState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||||
pub(crate) const PHOTOS_PAGE: usize = 60;
|
pub(crate) const PHOTOS_PAGE: usize = 200;
|
||||||
|
|
||||||
/// Gallery row height in px: the zoom range, its default, and the step one
|
/// Gallery row height in px: the zoom range, its default, and the step one
|
||||||
/// Ctrl+scroll notch (or Ctrl+±) moves it by. A justified row is scaled to the
|
/// Ctrl+scroll notch (or Ctrl+±) moves it by. A justified row is scaled to the
|
||||||
@@ -284,6 +286,8 @@ pub(crate) struct GalleryWidgets {
|
|||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) title: adw::WindowTitle,
|
pub(crate) title: adw::WindowTitle,
|
||||||
pub(crate) more: gtk4::Button,
|
pub(crate) more: gtk4::Button,
|
||||||
|
/// Says a Google Photos import is running, with a way to its page.
|
||||||
|
pub(crate) import_banner: adw::Banner,
|
||||||
pub(crate) list: gtk4::ListView,
|
pub(crate) list: gtk4::ListView,
|
||||||
pub(crate) scroll: gtk4::ScrolledWindow,
|
pub(crate) scroll: gtk4::ScrolledWindow,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
@@ -521,8 +525,9 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
// The page's two views, as one segmented control under the title: the
|
// The page's two views, as one segmented control under the title: the
|
||||||
// timeline, and the albums. This is navigation, not a filter — which is why
|
// timeline, and the albums. This is navigation, not a filter — which is why
|
||||||
// it sits above the filter row rather than beside the kind toggles.
|
// it sits above the filter row rather than beside the kind toggles.
|
||||||
|
// "Timeline", not "Photos": the kind filter below has a Photos tab too.
|
||||||
let photos_btn = gtk4::ToggleButton::builder()
|
let photos_btn = gtk4::ToggleButton::builder()
|
||||||
.label("Photos")
|
.label("Timeline")
|
||||||
.active(true)
|
.active(true)
|
||||||
.build();
|
.build();
|
||||||
let albums_btn = gtk4::ToggleButton::builder().label("Albums").build();
|
let albums_btn = gtk4::ToggleButton::builder().label("Albums").build();
|
||||||
@@ -577,6 +582,12 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
content.add_named(&albums_stack, Some("albums"));
|
content.add_named(&albums_stack, Some("albums"));
|
||||||
|
|
||||||
|
let import_banner = adw::Banner::builder()
|
||||||
|
.title("Importing from Google Photos…")
|
||||||
|
.button_label("View")
|
||||||
|
.action_name("win.show-import")
|
||||||
|
.build();
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(12);
|
inner.set_margin_top(12);
|
||||||
inner.set_margin_bottom(12);
|
inner.set_margin_bottom(12);
|
||||||
@@ -586,7 +597,10 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
inner.append(&filter_bar);
|
inner.append(&filter_bar);
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
|
|
||||||
let (frame, header, title) = page_frame("Photos", &inner);
|
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||||
|
body.append(&import_banner);
|
||||||
|
body.append(&inner);
|
||||||
|
let (frame, header, title) = page_frame("Photos", &body);
|
||||||
header.pack_start(&back);
|
header.pack_start(&back);
|
||||||
header.pack_start(&upload);
|
header.pack_start(&upload);
|
||||||
header.pack_end(&refresh);
|
header.pack_end(&refresh);
|
||||||
@@ -602,6 +616,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
status,
|
status,
|
||||||
title,
|
title,
|
||||||
more,
|
more,
|
||||||
|
import_banner,
|
||||||
list,
|
list,
|
||||||
scroll,
|
scroll,
|
||||||
retry,
|
retry,
|
||||||
@@ -900,6 +915,7 @@ pub(crate) fn wire_gallery(
|
|||||||
Some("plus" | "equal" | "KP_Add") => zoom_gallery(&ui_keys, ROW_STEP),
|
Some("plus" | "equal" | "KP_Add") => zoom_gallery(&ui_keys, ROW_STEP),
|
||||||
Some("minus" | "KP_Subtract") => zoom_gallery(&ui_keys, -ROW_STEP),
|
Some("minus" | "KP_Subtract") => zoom_gallery(&ui_keys, -ROW_STEP),
|
||||||
Some("0" | "KP_0") => set_gallery_tile(&ui_keys, ROW_DEFAULT),
|
Some("0" | "KP_0") => set_gallery_tile(&ui_keys, ROW_DEFAULT),
|
||||||
|
Some("a" | "A") => select_all_photos(&ui_keys),
|
||||||
_ => return glib::Propagation::Proceed,
|
_ => return glib::Propagation::Proceed,
|
||||||
}
|
}
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
@@ -1343,6 +1359,17 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
|||||||
});
|
});
|
||||||
button.add_controller(modifier);
|
button.add_controller(modifier);
|
||||||
|
|
||||||
|
let context = gtk4::GestureClick::builder().button(3).build();
|
||||||
|
let ui_context = ui.clone();
|
||||||
|
let photo = tile.photo.clone();
|
||||||
|
context.connect_pressed(move |gesture, _, x, y| {
|
||||||
|
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
||||||
|
if let Some(anchor) = gesture.widget().and_downcast::<gtk4::Button>() {
|
||||||
|
show_photo_menu(&ui_context, &photo, &anchor, x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
button.add_controller(context);
|
||||||
|
|
||||||
// A still opens in the in-app lightbox; a video can't render there, so it
|
// A still opens in the in-app lightbox; a video can't render there, so it
|
||||||
// downloads and hands off to an external player instead. In selection mode
|
// downloads and hands off to an external player instead. In selection mode
|
||||||
// a tile picks instead of opening: the lightbox is one Escape away.
|
// a tile picks instead of opening: the lightbox is one Escape away.
|
||||||
@@ -1424,6 +1451,11 @@ fn selected_file_count(ui: &Rc<Ui>, uids: &[String]) -> usize {
|
|||||||
|
|
||||||
pub(crate) fn delete_selected(ui: &Rc<Ui>) {
|
pub(crate) fn delete_selected(ui: &Rc<Ui>) {
|
||||||
let uids: Vec<String> = ui.gallery.selected.borrow().iter().cloned().collect();
|
let uids: Vec<String> = ui.gallery.selected.borrow().iter().cloned().collect();
|
||||||
|
confirm_trash_photos(ui, uids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm, then move the photos behind `uids` to Proton trash.
|
||||||
|
pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2086,6 +2118,175 @@ pub(crate) fn short_capture_time(secs: i64) -> String {
|
|||||||
|
|
||||||
/// Fetch a timeline page from the daemon. When `append` is false the model is
|
/// Fetch a timeline page from the daemon. When `append` is false the model is
|
||||||
/// cleared first (fresh load); otherwise the next page is tacked on.
|
/// cleared first (fresh load); otherwise the next page is tacked on.
|
||||||
|
/// The empty state for a timeline filtered to `kind`, favourites and/or a
|
||||||
|
/// `month`: what is missing, in the filter's own words.
|
||||||
|
pub(crate) fn empty_timeline_text(
|
||||||
|
kind: Option<PhotoKind>,
|
||||||
|
favorites: bool,
|
||||||
|
month: Option<&str>,
|
||||||
|
) -> (String, String) {
|
||||||
|
let what = match kind {
|
||||||
|
None => "photos",
|
||||||
|
Some(PhotoKind::Photo) => "photos",
|
||||||
|
Some(PhotoKind::Video) => "videos",
|
||||||
|
Some(PhotoKind::Raw) => "raw files",
|
||||||
|
};
|
||||||
|
let when = month.map(|m| format!(" in {m}")).unwrap_or_default();
|
||||||
|
match (favorites, kind, month) {
|
||||||
|
(false, None, None) => (
|
||||||
|
"No photos yet".to_string(),
|
||||||
|
"Photos you upload to Proton Drive appear here.".to_string(),
|
||||||
|
),
|
||||||
|
(true, None, None) => (
|
||||||
|
"No favourites yet".to_string(),
|
||||||
|
"Star a photo in the viewer or from its menu to find it here.".to_string(),
|
||||||
|
),
|
||||||
|
(true, _, _) => (
|
||||||
|
format!("No favourite {what}{when}"),
|
||||||
|
"Turn off the favourites filter to see everything.".to_string(),
|
||||||
|
),
|
||||||
|
(false, _, _) => (
|
||||||
|
format!("No {what}{when}"),
|
||||||
|
"Try another filter or month.".to_string(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the next page when the timeline does not fill the window yet: with no
|
||||||
|
/// scrollbar there is no scrolling to trigger it.
|
||||||
|
fn fill_viewport(ui: &Rc<Ui>) {
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::idle_add_local_once(move || {
|
||||||
|
let Some(adj) = ui.gallery.list.vadjustment() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if ui.gallery.more.is_visible()
|
||||||
|
&& ui.gallery.more.is_sensitive()
|
||||||
|
&& adj.upper() <= adj.page_size() + 1.0
|
||||||
|
{
|
||||||
|
load_gallery(&ui, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select every photo loaded so far (Ctrl+A).
|
||||||
|
pub(crate) fn select_all_photos(ui: &Rc<Ui>) {
|
||||||
|
set_selection_mode(ui, true);
|
||||||
|
{
|
||||||
|
let mut selected = ui.gallery.selected.borrow_mut();
|
||||||
|
for idx in 0..ui.gallery.model.n_items() {
|
||||||
|
if let Some(boxed) = ui.gallery.model.item(idx).and_downcast::<BoxedAnyObject>() {
|
||||||
|
selected.insert(boxed.borrow::<PhotoItem>().uid.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sync_selection_bar(ui);
|
||||||
|
repaint_gallery(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tile's right-click menu.
|
||||||
|
fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: >k4::Button, x: f64, y: f64) {
|
||||||
|
let menu = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||||
|
let popover = gtk4::Popover::builder()
|
||||||
|
.has_arrow(false)
|
||||||
|
.position(gtk4::PositionType::Bottom)
|
||||||
|
.pointing_to(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1))
|
||||||
|
.child(&menu)
|
||||||
|
.build();
|
||||||
|
popover.set_parent(anchor);
|
||||||
|
popover.connect_closed(|p| p.unparent());
|
||||||
|
let item = |label: &str, icon: &str, run: Box<dyn Fn()>| {
|
||||||
|
let button = menu_item(label, icon);
|
||||||
|
let pop = popover.clone();
|
||||||
|
button.connect_clicked(move |_| {
|
||||||
|
pop.popdown();
|
||||||
|
run();
|
||||||
|
});
|
||||||
|
menu.append(&button);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
|
if photo.kind == PhotoKind::Video {
|
||||||
|
item(
|
||||||
|
"Play",
|
||||||
|
"media-playback-start-symbolic",
|
||||||
|
Box::new(move || play_video(&ui_c, uid.clone())),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
item(
|
||||||
|
"Open",
|
||||||
|
"document-open-symbolic",
|
||||||
|
Box::new(move || open_photo_viewer(&ui_c, uid.clone())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (ui_c, uid, favorite) = (ui.clone(), photo.uid.clone(), photo.favorite);
|
||||||
|
item(
|
||||||
|
if favorite {
|
||||||
|
"Remove from Favourites"
|
||||||
|
} else {
|
||||||
|
"Add to Favourites"
|
||||||
|
},
|
||||||
|
if favorite {
|
||||||
|
"non-starred-symbolic"
|
||||||
|
} else {
|
||||||
|
"starred-symbolic"
|
||||||
|
},
|
||||||
|
Box::new(move || set_photo_favorite(&ui_c, uid.clone(), !favorite)),
|
||||||
|
);
|
||||||
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
|
item(
|
||||||
|
"Select",
|
||||||
|
"selection-mode-symbolic",
|
||||||
|
Box::new(move || {
|
||||||
|
set_selection_mode(&ui_c, true);
|
||||||
|
toggle_selected(&ui_c, &uid);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
|
item(
|
||||||
|
"Move to Trash…",
|
||||||
|
"user-trash-symbolic",
|
||||||
|
Box::new(move || confirm_trash_photos(&ui_c, vec![uid.clone()])),
|
||||||
|
);
|
||||||
|
popover.popup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Star or unstar a photo from the grid, and show the new state.
|
||||||
|
fn set_photo_favorite(ui: &Rc<Ui>, uid: String, favorite: bool) {
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::SetPhotoFavorite {
|
||||||
|
uid: uid.clone(),
|
||||||
|
favorite,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
set_gallery_favorite(&ui, &uid, favorite);
|
||||||
|
repaint_gallery(&ui);
|
||||||
|
toast(
|
||||||
|
&ui,
|
||||||
|
if favorite {
|
||||||
|
"Added to Favourites"
|
||||||
|
} else {
|
||||||
|
"Removed from Favourites"
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
toast_error(&ui, "Couldn't change the favourite", &message)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't change the favourite",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||||
if ui.gallery.loading.get() {
|
if ui.gallery.loading.get() {
|
||||||
return;
|
return;
|
||||||
@@ -2171,26 +2372,51 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
}
|
}
|
||||||
repaint_gallery(&ui);
|
repaint_gallery(&ui);
|
||||||
if ui.gallery.model.n_items() == 0 {
|
if ui.gallery.model.n_items() == 0 {
|
||||||
|
let filtered = ui.gallery.kind.get().is_some()
|
||||||
|
|| ui.gallery.favorites.get()
|
||||||
|
|| ui.gallery.range.get().is_some();
|
||||||
let (title, description) = if ui.gallery.album.borrow().is_some() {
|
let (title, description) = if ui.gallery.album.borrow().is_some() {
|
||||||
("Empty album", "This album has no photos in it.")
|
|
||||||
} else {
|
|
||||||
(
|
(
|
||||||
"No photos yet",
|
"Empty album".to_string(),
|
||||||
"Photos you upload to Proton Drive appear here.",
|
"This album has no photos in it.".to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let month = ui
|
||||||
|
.gallery
|
||||||
|
.range
|
||||||
|
.get()
|
||||||
|
.and_then(|_| ui.gallery.dates.selected_item())
|
||||||
|
.and_downcast::<gtk4::StringObject>()
|
||||||
|
.map(|month| month.string().to_string());
|
||||||
|
empty_timeline_text(
|
||||||
|
ui.gallery.kind.get(),
|
||||||
|
ui.gallery.favorites.get(),
|
||||||
|
month.as_deref(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
gallery_status(&ui, "image-x-generic-symbolic", title, description, false);
|
gallery_status(
|
||||||
// Only the whole-timeline empty state: an empty *album* is
|
&ui,
|
||||||
// filled by adding existing photos to it, not by uploading
|
if ui.gallery.favorites.get() {
|
||||||
// into it, so the buttons would point the wrong way.
|
"starred-symbolic"
|
||||||
|
} else {
|
||||||
|
"image-x-generic-symbolic"
|
||||||
|
},
|
||||||
|
&title,
|
||||||
|
&description,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// Only the whole, unfiltered timeline offers Upload: an
|
||||||
|
// empty *album* is filled by adding existing photos to it,
|
||||||
|
// and an empty filter is not a library that needs photos.
|
||||||
ui.gallery
|
ui.gallery
|
||||||
.empty_actions
|
.empty_actions
|
||||||
.set_visible(ui.gallery.album.borrow().is_none());
|
.set_visible(ui.gallery.album.borrow().is_none() && !filtered);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.content.set_visible_child_name("timeline");
|
ui.gallery.content.set_visible_child_name("timeline");
|
||||||
// Offer "Load more" only when the page came back full.
|
// Offer "Load more" only when the page came back full.
|
||||||
ui.gallery.more.set_visible(items.len() == PHOTOS_PAGE);
|
ui.gallery.more.set_visible(items.len() == PHOTOS_PAGE);
|
||||||
|
fill_viewport(&ui);
|
||||||
}
|
}
|
||||||
// A failed *next* page keeps the photos already on screen — the failure
|
// A failed *next* page keeps the photos already on screen — the failure
|
||||||
// goes to a toast rather than wiping the timeline for a status page.
|
// goes to a toast rather than wiping the timeline for a status page.
|
||||||
@@ -2305,3 +2531,23 @@ pub(crate) fn play_video(ui: &Rc<Ui>, uid: String) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::empty_timeline_text;
|
||||||
|
use pdfs_core::control::PhotoKind;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_filter_names_what_it_filtered() {
|
||||||
|
assert_eq!(empty_timeline_text(None, false, None).0, "No photos yet");
|
||||||
|
assert_eq!(empty_timeline_text(None, true, None).0, "No favourites yet");
|
||||||
|
assert_eq!(
|
||||||
|
empty_timeline_text(Some(PhotoKind::Video), false, Some("June 2024")).0,
|
||||||
|
"No videos in June 2024"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
empty_timeline_text(Some(PhotoKind::Raw), true, None).0,
|
||||||
|
"No favourite raw files"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -608,6 +608,9 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
|||||||
// A Takeout import outlives the page that started it, so it is polled
|
// A Takeout import outlives the page that started it, so it is polled
|
||||||
// wherever the user has navigated to — otherwise leaving the page means
|
// wherever the user has navigated to — otherwise leaving the page means
|
||||||
// never being told it finished.
|
// never being told it finished.
|
||||||
|
ui.gallery
|
||||||
|
.import_banner
|
||||||
|
.set_revealed(ui.takeout.running.get());
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("takeout") || ui.takeout.running.get() {
|
if ui.stack.visible_child_name().as_deref() == Some("takeout") || ui.takeout.running.get() {
|
||||||
refresh_takeout(ui);
|
refresh_takeout(ui);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ Schema: **30** (unchanged).
|
|||||||
whole selection. Rename selects the name without its extension.
|
whole selection. Rename selects the name without its extension.
|
||||||
- **Drop to upload.** Files and folders dropped on My Files upload into the folder on screen. The
|
- **Drop to upload.** Files and folders dropped on My Files upload into the folder on screen. The
|
||||||
upload toast links to the Sync page.
|
upload toast links to the Sync page.
|
||||||
|
- **Photos.** The main view is called Timeline. Right-clicking a photo opens a menu to open it,
|
||||||
|
favourite it, select it or move it to Trash; Ctrl+A selects every loaded photo. An empty filter
|
||||||
|
says what it filtered ("No videos in June 2024") instead of the first-run hint. A banner shows
|
||||||
|
while a Takeout import runs and links to it. Pages load 200 photos at a time and keep loading
|
||||||
|
until the window is full.
|
||||||
|
|
||||||
## [1.11.1] — 2026-09-19
|
## [1.11.1] — 2026-09-19
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user