mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(gui): show a grouped shot as one tile, and reach its other files
The grouping landed in the daemon; this is what it looks like. A tile whose shot was stored as more than one file badges that in the corner — "RAW" when one of the members is a raw file, since that is the word the person is looking for, and a plain count otherwise (a live photo, a burst). The lightbox gains a control that steps through the group's files, which is the only way to reach the RAW now that the grid shows the shot once. Deleting says what it will do: the daemon takes the whole group, so the confirmation names the file count when a selection stands for more files than tiles.
This commit is contained in:
1 parent
afd71713d5
commit
c1df3b6cc2
8 files changed
+283
-5
No files matched your search
@@ -265,6 +265,7 @@ fn load_proton_theme() {
|
||||
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }}\n\
|
||||
.photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\
|
||||
.photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\
|
||||
.photo-group-badge {{ font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }}\n\
|
||||
.photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\
|
||||
.album-card {{ padding: 0; border-radius: 16px; transition: background 180ms ease; }}\n\
|
||||
.album-card:hover {{ background: alpha(currentColor, 0.07); }}\n\
|
||||
@@ -1287,6 +1288,59 @@ mod tests {
|
||||
assert!(plan_rows(&[], 1000, ROW_DEFAULT).is_empty());
|
||||
}
|
||||
|
||||
/// One file of a shot, for the group-switch tests.
|
||||
fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem {
|
||||
PhotoItem {
|
||||
uid: uid.into(),
|
||||
capture_time: 0,
|
||||
thumb_path: None,
|
||||
name: name.map(str::to_string),
|
||||
ratio: None,
|
||||
no_thumb: false,
|
||||
kind,
|
||||
favorite: false,
|
||||
group_size: 2,
|
||||
has_raw: kind == PhotoKind::Raw,
|
||||
}
|
||||
}
|
||||
|
||||
/// The lightbox steps through a shot's files and comes back round, so a
|
||||
/// burst of three is as reachable as a RAW+JPEG pair.
|
||||
#[test]
|
||||
fn the_group_switch_cycles_through_every_file() {
|
||||
let members = [
|
||||
member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo),
|
||||
member("raw", Some("IMG_1.CR2"), PhotoKind::Raw),
|
||||
member("clip", None, PhotoKind::Video),
|
||||
];
|
||||
let next = |uid| next_group_member(&members, uid).map(|item| item.uid.as_str());
|
||||
assert_eq!(next("jpeg"), Some("raw"));
|
||||
assert_eq!(next("raw"), Some("clip"));
|
||||
assert_eq!(next("clip"), Some("jpeg"));
|
||||
// A photo that is not in the group leaves the button alone rather than
|
||||
// jumping somewhere arbitrary.
|
||||
assert_eq!(next("elsewhere"), None);
|
||||
assert!(next_group_member(&[], "jpeg").is_none());
|
||||
}
|
||||
|
||||
/// The switch names what it will show; a file whose name the daemon has not
|
||||
/// resolved still gets a sentence rather than a blank.
|
||||
#[test]
|
||||
fn a_group_member_without_a_name_is_still_described() {
|
||||
assert_eq!(
|
||||
member_label(&member("raw", Some("IMG_1.CR2"), PhotoKind::Raw)),
|
||||
"IMG_1.CR2"
|
||||
);
|
||||
assert_eq!(
|
||||
member_label(&member("raw", None, PhotoKind::Raw)),
|
||||
"the raw file"
|
||||
);
|
||||
assert_eq!(
|
||||
member_label(&member("clip", Some(""), PhotoKind::Video)),
|
||||
"the video"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_narrower_than_one_photo_still_lays_it_out() {
|
||||
// A tile of at least one px, rather than a zero-width widget or a
|
||||
|
||||
@@ -24,6 +24,11 @@ pub(crate) struct Viewer {
|
||||
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
|
||||
pub(crate) favorite: gtk4::ToggleButton,
|
||||
pub(crate) favorite_suppress: Cell<bool>,
|
||||
/// The button that switches between the files of one shot, and the files it
|
||||
/// switches between (the JPEG and the RAW of a photo, a live photo and its
|
||||
/// clip). Empty, and the button hidden, for a photo stored as one file.
|
||||
pub(crate) group_btn: gtk4::Button,
|
||||
pub(crate) group: RefCell<Vec<PhotoItem>>,
|
||||
/// uid of the photo currently on screen.
|
||||
pub(crate) uid: RefCell<String>,
|
||||
/// On-disk path of the full-size photo, once it has been downloaded.
|
||||
@@ -81,6 +86,59 @@ fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
|
||||
/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately
|
||||
/// so the lightbox never opens on a blank screen, ask the daemon for the
|
||||
/// full-size file, and swap it in — plus its EXIF — when it lands.
|
||||
/// Ask the daemon which files this shot was stored as, and offer a switch when
|
||||
/// there is more than one. The grid shows such a shot as a single tile, so the
|
||||
/// lightbox is the only place the RAW is reachable.
|
||||
fn load_photo_group(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) {
|
||||
viewer.group.borrow_mut().clear();
|
||||
viewer.group_btn.set_visible(false);
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::PhotoGroup {
|
||||
uid: uid.to_string(),
|
||||
},
|
||||
);
|
||||
let viewer = viewer.clone();
|
||||
let uid = uid.to_string();
|
||||
glib::spawn_future_local(async move {
|
||||
let Ok(Ok(Response::Photos { items, .. })) = rx.recv().await else {
|
||||
return;
|
||||
};
|
||||
// A late reply for a photo the user has already left must not relabel
|
||||
// the button under the one now on screen.
|
||||
if *viewer.uid.borrow() != uid || items.len() < 2 {
|
||||
return;
|
||||
}
|
||||
if let Some(next) = next_group_member(&items, &uid) {
|
||||
viewer
|
||||
.group_btn
|
||||
.set_tooltip_text(Some(&format!("Show {}", member_label(next))));
|
||||
}
|
||||
*viewer.group.borrow_mut() = items;
|
||||
viewer.group_btn.set_visible(true);
|
||||
});
|
||||
}
|
||||
|
||||
/// The member after `uid`, wrapping round — the button steps through a shot's
|
||||
/// files rather than toggling two of them, so a burst of three works too.
|
||||
pub(crate) fn next_group_member<'a>(members: &'a [PhotoItem], uid: &str) -> Option<&'a PhotoItem> {
|
||||
let at = members.iter().position(|item| item.uid == uid)?;
|
||||
members.get((at + 1) % members.len())
|
||||
}
|
||||
|
||||
/// What to call one file of a shot: its name, or its kind when the daemon has
|
||||
/// not resolved a name for it.
|
||||
pub(crate) fn member_label(item: &PhotoItem) -> String {
|
||||
match item.name.as_deref() {
|
||||
Some(name) if !name.is_empty() => name.to_string(),
|
||||
_ => match item.kind {
|
||||
PhotoKind::Raw => "the raw file".to_string(),
|
||||
PhotoKind::Video => "the video".to_string(),
|
||||
PhotoKind::Photo => "the photo".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
viewer.spinner.set_visible(true);
|
||||
viewer.spinner.start();
|
||||
@@ -96,6 +154,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.is_some_and(|boxed| boxed.borrow::<PhotoItem>().favorite);
|
||||
show_favorite(viewer, favorite);
|
||||
load_photo_group(ui, viewer, &uid);
|
||||
|
||||
// The thumbnail the gallery already decoded stands in for the full photo
|
||||
// while it downloads: blurry for a moment beats black for a second.
|
||||
@@ -503,6 +562,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
favorite_btn.add_css_class("flat");
|
||||
favorite_btn.add_css_class("viewer-action-btn");
|
||||
|
||||
// Only shown for a shot stored as more than one file; `load_photo_group`
|
||||
// decides that per photo.
|
||||
let group_btn = action("view-paged-symbolic", "Other files of this photo");
|
||||
group_btn.set_visible(false);
|
||||
|
||||
let download_btn = action("document-save-symbolic", "Save a copy…");
|
||||
let delete_btn = action("user-trash-symbolic", "Move to Trash (Delete)");
|
||||
let open_ext_btn = action("document-open-symbolic", "Open with another app");
|
||||
@@ -514,6 +578,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
top_bar.set_valign(gtk4::Align::Start);
|
||||
top_bar.set_hexpand(true);
|
||||
top_bar.append(&titles);
|
||||
top_bar.append(&group_btn);
|
||||
top_bar.append(&favorite_btn);
|
||||
top_bar.append(&info_toggle);
|
||||
top_bar.append(&download_btn);
|
||||
@@ -616,6 +681,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
info_map: info_map.clone(),
|
||||
favorite: favorite_btn.clone(),
|
||||
favorite_suppress: Cell::new(false),
|
||||
group_btn: group_btn.clone(),
|
||||
group: RefCell::new(Vec::new()),
|
||||
coords: RefCell::new(None),
|
||||
uid: RefCell::new(initial_uid.clone()),
|
||||
path: RefCell::new(None),
|
||||
@@ -709,6 +776,18 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
trash_photos(&ui_delete, vec![uid]);
|
||||
});
|
||||
|
||||
let ui_group = ui.clone();
|
||||
let viewer_group = viewer.clone();
|
||||
group_btn.connect_clicked(move |_| {
|
||||
let uid = viewer_group.uid.borrow().clone();
|
||||
let next =
|
||||
next_group_member(&viewer_group.group.borrow(), &uid).map(|item| item.uid.clone());
|
||||
if let Some(next) = next {
|
||||
*viewer_group.uid.borrow_mut() = next.clone();
|
||||
load_photo(&ui_group, &viewer_group, next);
|
||||
}
|
||||
});
|
||||
|
||||
let w_download = window.clone();
|
||||
let viewer_download = viewer.clone();
|
||||
download_btn.connect_clicked(move |_| {
|
||||
|
||||
@@ -1283,6 +1283,24 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||
overlay.add_overlay(&badge);
|
||||
}
|
||||
|
||||
// A shot stored as more than one file says so, in the corner the caption
|
||||
// does not use. "RAW" is the useful word when one of the members is a raw
|
||||
// file — that is what the person wants to find — and a plain count covers
|
||||
// the rest (a live photo, a burst).
|
||||
if tile.photo.has_raw || tile.photo.group_size > 1 {
|
||||
let badge = gtk4::Label::builder()
|
||||
.label(if tile.photo.has_raw {
|
||||
"RAW".to_string()
|
||||
} else {
|
||||
format!("{}", tile.photo.group_size)
|
||||
})
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Start)
|
||||
.build();
|
||||
badge.add_css_class("photo-group-badge");
|
||||
overlay.add_overlay(&badge);
|
||||
}
|
||||
|
||||
// Selection mode marks every tile, picked or not: a check that only appears
|
||||
// once a photo is chosen leaves the user guessing what else is clickable.
|
||||
if tile.selecting {
|
||||
@@ -1404,17 +1422,41 @@ fn sync_selection_bar(ui: &Rc<Ui>) {
|
||||
}
|
||||
|
||||
/// Confirm, then move every selected photo to Proton trash.
|
||||
/// How many files the selected tiles stand for. A tile whose photo the model no
|
||||
/// longer holds counts as one, which is what it looks like on screen.
|
||||
fn selected_file_count(ui: &Rc<Ui>, uids: &[String]) -> usize {
|
||||
let wanted: HashSet<&str> = uids.iter().map(String::as_str).collect();
|
||||
let mut files = 0;
|
||||
for idx in 0..ui.gallery.model.n_items() {
|
||||
let Some(boxed) = ui.gallery.model.item(idx).and_downcast::<BoxedAnyObject>() else {
|
||||
continue;
|
||||
};
|
||||
let photo = boxed.borrow::<PhotoItem>();
|
||||
if wanted.contains(photo.uid.as_str()) {
|
||||
files += photo.group_size.max(1) as usize;
|
||||
}
|
||||
}
|
||||
files.max(uids.len())
|
||||
}
|
||||
|
||||
pub(crate) fn delete_selected(ui: &Rc<Ui>) {
|
||||
let uids: Vec<String> = ui.gallery.selected.borrow().iter().cloned().collect();
|
||||
if uids.is_empty() {
|
||||
return;
|
||||
}
|
||||
// A selected tile can stand for more than one file — a shot kept as RAW and
|
||||
// JPEG — and all of them go. The dialog says so rather than letting the user
|
||||
// find out from the trash.
|
||||
let files = selected_file_count(ui, &uids);
|
||||
let body = match (uids.len(), files) {
|
||||
(1, 1) => "Move this photo to Trash?".to_string(),
|
||||
(1, files) => format!("Move this photo to Trash? It is stored as {files} files."),
|
||||
(n, files) if files == n => format!("Move {n} photos to Trash?"),
|
||||
(n, files) => format!("Move {n} photos to Trash? They are stored as {files} files."),
|
||||
};
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Move to Trash")
|
||||
.body(match uids.len() {
|
||||
1 => "Move this photo to Trash?".to_string(),
|
||||
n => format!("Move {n} photos to Trash?"),
|
||||
})
|
||||
.body(body)
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("trash", "Move to Trash");
|
||||
|
||||
Reference in new issue
Block a user