mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
fix(gui): steady the photo gallery and fill the details panel
The month scrubber sat under the overlay scrollbar, which widens under the pointer, so the two collided. Keep the scrubber clear of it. The gallery jumped when thumbnails loaded, on zoom and on tab switches. Relayout anchored on the first bound row, but ListView keeps off-screen rows bound with stale allocations, and scroll_to on an off-screen row aligns it to the top. Anchor on the first mapped row that is actually visible instead. The viewer's details panel opened empty: the title's hexpand propagated up to the revealer, which then took all the width. Set hexpand on the revealer explicitly. EXIF details read like a camera: Make, Model and Lens lose their EXIF quotes, a model that already names its maker is not repeated, shutter speeds are rounded (1/19 s), and ISO is labelled.
This commit is contained in:
1 parent
2fa5eaa75b
commit
0b7236bd4a
11 files changed
+1452
-1302
No files matched your search
@@ -419,6 +419,21 @@ pub(crate) fn read_exif(path: &str) -> ExifInfo {
|
||||
.get_field(tag, exif::In::PRIMARY)
|
||||
.map(|f| f.display_value().with_unit(&reader).to_string())
|
||||
};
|
||||
// Text tags, without the quotes `display_value` wraps each string in and
|
||||
// the NUL padding some cameras leave behind.
|
||||
let ascii = |tag: exif::Tag| {
|
||||
let field = reader.get_field(tag, exif::In::PRIMARY)?;
|
||||
let exif::Value::Ascii(parts) = &field.value else {
|
||||
return None;
|
||||
};
|
||||
let text = parts
|
||||
.iter()
|
||||
.map(|part| String::from_utf8_lossy(part).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let text = text.trim_matches(|c: char| c == '\0' || c.is_whitespace());
|
||||
(!text.is_empty()).then(|| text.to_string())
|
||||
};
|
||||
|
||||
if let Some(size) = field(exif::Tag::PixelXDimension)
|
||||
.zip(field(exif::Tag::PixelYDimension))
|
||||
@@ -428,33 +443,41 @@ pub(crate) fn read_exif(path: &str) -> ExifInfo {
|
||||
// Translators: label of a photo's pixel dimensions in the details panel.
|
||||
fields.push((gettext("Dimensions"), size));
|
||||
}
|
||||
if let Some(taken) = field(exif::Tag::DateTimeOriginal) {
|
||||
if let Some(taken) = ascii(exif::Tag::DateTimeOriginal) {
|
||||
// Translators: label of when a photo was taken, in the details panel.
|
||||
fields.push((pgettext("photo property", "Taken"), taken));
|
||||
}
|
||||
|
||||
let camera = [exif::Tag::Make, exif::Tag::Model]
|
||||
.iter()
|
||||
.filter_map(|tag| field(*tag))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if !camera.is_empty() {
|
||||
if let Some(camera) = camera_name(ascii(exif::Tag::Make), ascii(exif::Tag::Model)) {
|
||||
// Translators: label of the camera model in a photo's details panel.
|
||||
fields.push((gettext("Camera"), camera));
|
||||
}
|
||||
if let Some(lens) = field(exif::Tag::LensModel) {
|
||||
if let Some(lens) = ascii(exif::Tag::LensModel) {
|
||||
// Translators: label of the lens model in a photo's details panel.
|
||||
fields.push((gettext("Lens"), lens));
|
||||
}
|
||||
|
||||
let shutter = reader
|
||||
.get_field(exif::Tag::ExposureTime, exif::In::PRIMARY)
|
||||
.and_then(|f| match &f.value {
|
||||
exif::Value::Rational(r) => r.first().map(|r| r.to_f64()),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|seconds| seconds.is_finite() && *seconds > 0.0)
|
||||
.map(format_shutter);
|
||||
let iso = reader
|
||||
.get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY)
|
||||
.and_then(|f| f.value.get_uint(0))
|
||||
// Translators: a photo's ISO sensitivity, such as "ISO 400".
|
||||
.map(|n| gettext_f("ISO {value}", &[("value", &n.to_string())]));
|
||||
let exposure: Vec<String> = [
|
||||
exif::Tag::FNumber,
|
||||
exif::Tag::ExposureTime,
|
||||
exif::Tag::PhotographicSensitivity,
|
||||
exif::Tag::FocalLength,
|
||||
field(exif::Tag::FNumber),
|
||||
shutter,
|
||||
iso,
|
||||
field(exif::Tag::FocalLength),
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|tag| field(*tag))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
if !exposure.is_empty() {
|
||||
// Translators: label of aperture, shutter speed, ISO and focal length in a photo's details panel.
|
||||
@@ -479,6 +502,29 @@ pub(crate) fn read_exif(path: &str) -> ExifInfo {
|
||||
ExifInfo { fields, coords }
|
||||
}
|
||||
|
||||
/// "Google Pixel 6" from Make "Google" and Model "Pixel 6", but just "Canon EOS
|
||||
/// R5" where the model already names the maker.
|
||||
fn camera_name(make: Option<String>, model: Option<String>) -> Option<String> {
|
||||
match (make, model) {
|
||||
(Some(make), Some(model)) if model.to_lowercase().starts_with(&make.to_lowercase()) => {
|
||||
Some(model)
|
||||
}
|
||||
(Some(make), Some(model)) => Some(format!("{make} {model}")),
|
||||
(make, model) => make.or(model),
|
||||
}
|
||||
}
|
||||
|
||||
/// A shutter speed the way cameras print it: "1/250 s" below a second, "2.5 s"
|
||||
/// from one up.
|
||||
fn format_shutter(seconds: f64) -> String {
|
||||
if seconds >= 0.95 {
|
||||
let text = format!("{seconds:.1}");
|
||||
format!("{} s", text.strip_suffix(".0").unwrap_or(&text))
|
||||
} else {
|
||||
format!("1/{} s", (1.0 / seconds).round())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to
|
||||
/// decimal degrees, negating for the S/W hemispheres.
|
||||
pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> {
|
||||
@@ -1019,8 +1065,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
info_panel.set_width_request(320);
|
||||
info_panel.append(&info_scroll);
|
||||
|
||||
// Set explicitly: the title's hexpand would otherwise propagate up and have
|
||||
// the revealer take half the window even while it is closed.
|
||||
let info_revealer = gtk4::Revealer::builder()
|
||||
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
|
||||
.hexpand(false)
|
||||
.child(&info_panel)
|
||||
.build();
|
||||
|
||||
@@ -1425,7 +1474,27 @@ pub(crate) fn over_photo(picture: >k4::Picture, x: f64, y: f64) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{named_link, position_label};
|
||||
use super::{camera_name, format_shutter, named_link, position_label};
|
||||
|
||||
#[test]
|
||||
fn shutter_speeds_read_like_a_camera() {
|
||||
assert_eq!(format_shutter(1.0 / 18.869_704_689_121_615), "1/19 s");
|
||||
assert_eq!(format_shutter(0.001), "1/1000 s");
|
||||
assert_eq!(format_shutter(2.5), "2.5 s");
|
||||
assert_eq!(format_shutter(1.0), "1 s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_name_does_not_repeat_the_maker() {
|
||||
let s = |v: &str| Some(v.to_string());
|
||||
assert_eq!(camera_name(s("Google"), s("Pixel 6")), s("Google Pixel 6"));
|
||||
assert_eq!(
|
||||
camera_name(s("Canon"), s("Canon EOS R5")),
|
||||
s("Canon EOS R5")
|
||||
);
|
||||
assert_eq!(camera_name(None, s("iPhone 15")), s("iPhone 15"));
|
||||
assert_eq!(camera_name(None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_counter_counts_the_library_while_pages_are_left() {
|
||||
|
||||
@@ -157,10 +157,12 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
|
||||
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
|
||||
/// The rows currently realised by the ListView, as row index -> the uid of
|
||||
/// its first photo (absent for a heading row). A resize or a zoom step
|
||||
/// changes how many tiles fit per row, so the row model has to be rebuilt —
|
||||
/// this is what lets the rebuild put the user back where they were.
|
||||
pub(crate) bound: RefCell<BTreeMap<u32, Option<String>>>,
|
||||
/// its first photo (absent for a heading row) and the row's widget. A
|
||||
/// resize or a zoom step changes how many tiles fit per row, so the row
|
||||
/// model has to be rebuilt — this is what lets the rebuild put the user
|
||||
/// back where they were. The ListView realises rows beyond both edges of
|
||||
/// the viewport, so the widget is what tells which of them is on screen.
|
||||
pub(crate) bound: RefCell<BTreeMap<u32, (Option<String>, gtk4::Box)>>,
|
||||
/// The ListView itself, so a rebuild can scroll back to the row the user was
|
||||
/// looking at.
|
||||
pub(crate) list: gtk4::ListView,
|
||||
@@ -434,7 +436,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_end(4)
|
||||
// Clear of the overlay scrollbar, which widens under the pointer.
|
||||
.margin_end(18)
|
||||
.tooltip_text(gettext("Jump to a month"))
|
||||
.visible(false)
|
||||
.build();
|
||||
@@ -900,7 +903,7 @@ pub(crate) fn wire_gallery(
|
||||
.gallery
|
||||
.bound
|
||||
.borrow_mut()
|
||||
.insert(item.position(), row.anchor());
|
||||
.insert(item.position(), (row.anchor(), row_box.clone()));
|
||||
});
|
||||
|
||||
// ListView recycles row widgets, so a scrolled-away row must give up its
|
||||
@@ -2004,7 +2007,7 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
||||
/// rebuilt. What that would cost the user is their scroll position, so the
|
||||
/// topmost realised row's first photo is remembered and scrolled back to.
|
||||
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
||||
let anchor: Option<String> = ui.gallery.bound.borrow().values().flatten().next().cloned();
|
||||
let anchor = top_anchor(ui);
|
||||
repaint_gallery(ui);
|
||||
let Some(anchor) = anchor else { return };
|
||||
let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else {
|
||||
@@ -2015,6 +2018,30 @@ pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
||||
.scroll_to(row, gtk4::ListScrollFlags::empty(), None);
|
||||
}
|
||||
|
||||
/// The first photo of the topmost photo row that reaches into the viewport.
|
||||
///
|
||||
/// Not simply the first bound row: the ListView keeps rows bound well outside
|
||||
/// the viewport (unmapped, with their last allocation), and scrolling one of
|
||||
/// those "back" to the top is a jump. Falls back to the first bound photo row
|
||||
/// while the list isn't on screen.
|
||||
fn top_anchor(ui: &Rc<Ui>) -> Option<String> {
|
||||
let list = &ui.gallery.list;
|
||||
let bound = ui.gallery.bound.borrow();
|
||||
let mut photo_rows = bound
|
||||
.values()
|
||||
.filter_map(|(uid, row)| uid.as_ref().map(|uid| (uid, row)));
|
||||
let first = photo_rows.clone().next().map(|(uid, _)| uid.clone());
|
||||
photo_rows
|
||||
.find(|(_, row)| {
|
||||
row.is_mapped()
|
||||
&& row
|
||||
.compute_bounds(list)
|
||||
.is_some_and(|rect| rect.y() + rect.height() > 0.0)
|
||||
})
|
||||
.map(|(uid, _)| uid.clone())
|
||||
.or(first)
|
||||
}
|
||||
|
||||
/// Which row of the rendered model holds `uid`, if any.
|
||||
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
|
||||
(0..store.n_items()).find(|i| {
|
||||
@@ -2329,7 +2356,7 @@ fn sync_scrubber_position(ui: &Rc<Ui>) {
|
||||
if ui.gallery.scrubbing.get() || !ui.gallery.scrubber.is_visible() {
|
||||
return;
|
||||
}
|
||||
let Some(uid) = ui.gallery.bound.borrow().values().flatten().next().cloned() else {
|
||||
let Some(uid) = top_anchor(ui) else {
|
||||
return;
|
||||
};
|
||||
let Some(capture_time) = find_photo_index(&ui.gallery.model, &uid)
|
||||
|
||||
Reference in new issue
Block a user