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:
nvrl committed 2026-09-19 15:39:14 +02:00
1 parent afd71713d5
commit c1df3b6cc2
8 files changed
+283 -5

No files matched your search

+54
View File
@@ -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 |_| {
+46 -4
View File
@@ -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");