mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 17:32:00 +01:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
602f1c9577 | ||
|
|
9132c7ebd5 | ||
|
|
94304ccc1a | ||
|
|
74cd6fd2a9 | ||
|
|
4b669168ee | ||
|
|
ba5b6a8bf2 | ||
|
|
3295eda1f0 | ||
|
|
41212efdf3 | ||
|
|
5f0b15fe70 | ||
|
|
033c8d3d71 |
No files matched your search
@@ -996,21 +996,15 @@ impl TokenSink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the unsaved rotation, if there is one. A blob that moved on, or
|
/// Write the unsaved rotation, if there is one. A blob that moved on drops
|
||||||
/// is gone with a logout, drops it: there is nothing it may still go into.
|
/// it. A missing blob is retried: some Secret Service providers also report
|
||||||
|
/// locked or temporarily hidden items as absent. Never recreate it here.
|
||||||
fn flush(&self) -> Result<()> {
|
fn flush(&self) -> Result<()> {
|
||||||
let mut state = self.state.lock();
|
let mut state = self.state.lock();
|
||||||
let Some(tokens) = state.unsaved.clone() else {
|
let Some(tokens) = state.unsaved.clone() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let mut stored = match load() {
|
let mut stored = load()?;
|
||||||
Ok(stored) => stored,
|
|
||||||
Err(Error::NotLoggedIn) => {
|
|
||||||
state.settle();
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
};
|
|
||||||
match take_rotation(
|
match take_rotation(
|
||||||
&mut stored,
|
&mut stored,
|
||||||
&self.session_id,
|
&self.session_id,
|
||||||
@@ -1306,6 +1300,47 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// oo7 0.7.0 reports a locked collection's items as absent (upstream #614).
|
||||||
|
/// Treating that as logout loses the only live tokens (B284, issue #78).
|
||||||
|
#[test]
|
||||||
|
fn a_temporarily_missing_session_does_not_discard_a_rotation() {
|
||||||
|
let _turn = mock_keyring();
|
||||||
|
write_stored(&stored_with("r")).unwrap();
|
||||||
|
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
||||||
|
let entry = keyring_entry().unwrap();
|
||||||
|
entry
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<keyring_core::mock::Cred>()
|
||||||
|
.unwrap()
|
||||||
|
.set_error(keyring_core::Error::NoEntry);
|
||||||
|
|
||||||
|
assert!(sink.rotated(tokens("a2", "r2")).is_err());
|
||||||
|
assert!(sink.state.lock().unsaved.is_some());
|
||||||
|
assert_eq!(load().unwrap().refresh_token, "r");
|
||||||
|
|
||||||
|
sink.flush().unwrap();
|
||||||
|
assert_eq!(load().unwrap().refresh_token, "r2");
|
||||||
|
assert!(sink.state.lock().unsaved.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login() {
|
||||||
|
let _turn = mock_keyring();
|
||||||
|
let sink = TokenSink::new("s", "r".into());
|
||||||
|
sink.state.lock().unsaved = Some(tokens("a2", "r2"));
|
||||||
|
|
||||||
|
assert!(matches!(sink.flush(), Err(Error::NotLoggedIn)));
|
||||||
|
assert!(matches!(load(), Err(Error::NotLoggedIn)));
|
||||||
|
assert!(sink.state.lock().unsaved.is_some());
|
||||||
|
|
||||||
|
let mut login = stored_with("fresh");
|
||||||
|
login.session_id = "new".into();
|
||||||
|
write_stored(&login).unwrap();
|
||||||
|
sink.flush().unwrap();
|
||||||
|
assert!(sink.state.lock().unsaved.is_none());
|
||||||
|
assert_eq!(load().unwrap().refresh_token, "fresh");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
|
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
|
||||||
let unsaved = UnsavedRotation {
|
let unsaved = UnsavedRotation {
|
||||||
|
|||||||
@@ -1792,6 +1792,11 @@ pub struct PhotoItem {
|
|||||||
/// the JPEG it shows is not the whole of what is stored.
|
/// the JPEG it shows is not the whole of what is stored.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub has_raw: bool,
|
pub has_raw: bool,
|
||||||
|
/// GeoNames id of the town the photo was taken in, when its location is
|
||||||
|
/// known; [`crate::places::city`] names it. Older daemons omit it, and
|
||||||
|
/// album entries do not carry it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub place_id: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A daemon too old to classify a timeline entry is assumed to have served a
|
/// A daemon too old to classify a timeline entry is assumed to have served a
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ impl Db {
|
|||||||
// made, and it shows exactly what they put in it.
|
// made, and it shows exactly what they put in it.
|
||||||
group_size: 1,
|
group_size: 1,
|
||||||
has_raw: false,
|
has_raw: false,
|
||||||
|
// Album rows carry no location.
|
||||||
|
place_id: None,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
@@ -247,6 +249,8 @@ impl Db {
|
|||||||
// made, and it shows exactly what they put in it.
|
// made, and it shows exactly what they put in it.
|
||||||
group_size: 1,
|
group_size: 1,
|
||||||
has_raw: false,
|
has_raw: false,
|
||||||
|
// Album rows carry no location.
|
||||||
|
place_id: None,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ pub struct StoredPhoto {
|
|||||||
/// Whether any member of the group is a camera raw file. The grid badges
|
/// Whether any member of the group is a camera raw file. The grid badges
|
||||||
/// that, because the JPEG it shows is not the whole of what is stored.
|
/// that, because the JPEG it shows is not the whole of what is stored.
|
||||||
pub has_raw: bool,
|
pub has_raw: bool,
|
||||||
|
/// The town the photo was taken in, once its location has been read.
|
||||||
|
pub place_id: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
||||||
@@ -256,7 +258,7 @@ fn group_photos(entries: &[Grouped]) -> Vec<String> {
|
|||||||
/// own group: the column is `NULL` until the next refresh computes it, and a
|
/// own group: the column is `NULL` until the next refresh computes it, and a
|
||||||
/// `NULL` would otherwise match nothing and drop the photo off the page.
|
/// `NULL` would otherwise match nothing and drop the photo off the page.
|
||||||
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
|
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
|
||||||
p.kind, p.favorite, g.n, g.raw \
|
p.kind, p.favorite, g.n, g.raw, p.place_id \
|
||||||
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
|
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
|
||||||
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
|
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
|
||||||
|
|
||||||
@@ -272,6 +274,7 @@ fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
|
|||||||
favorite: r.get::<_, i64>(6)? != 0,
|
favorite: r.get::<_, i64>(6)? != 0,
|
||||||
group_size: r.get::<_, i64>(7)?.max(1) as usize,
|
group_size: r.get::<_, i64>(7)?.max(1) as usize,
|
||||||
has_raw: r.get::<_, i64>(8)? != 0,
|
has_raw: r.get::<_, i64>(8)? != 0,
|
||||||
|
place_id: r.get(9)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -363,6 +363,23 @@ fn place_photos_carry_their_own_location() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A timeline photo names the town it was taken in, so the viewer can show it;
|
||||||
|
/// one without a location names none.
|
||||||
|
#[test]
|
||||||
|
fn timeline_photos_name_their_town() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
located("b", 500, "IMG_5.JPG", BERLIN),
|
||||||
|
TimelineRow::new("n", 400),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let berlin = db.photo_places().unwrap()[0].id;
|
||||||
|
let mut photos = db.photos_by_uid(&["b".into(), "n".into()]).unwrap();
|
||||||
|
photos.sort_by(|a, b| a.uid.cmp(&b.uid));
|
||||||
|
let towns: Vec<Option<u32>> = photos.iter().map(|photo| photo.place_id).collect();
|
||||||
|
assert_eq!(towns, [Some(berlin), None]);
|
||||||
|
}
|
||||||
|
|
||||||
/// A refresh that skips an already-resolved photo keeps where it was taken; one
|
/// A refresh that skips an already-resolved photo keeps where it was taken; one
|
||||||
/// that reads the node again takes what the node now says.
|
/// that reads the node again takes what the node now says.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -3168,6 +3168,28 @@ impl Core {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`Core::resolve_anywhere`], widened to the Photos timeline for sharing.
|
||||||
|
///
|
||||||
|
/// A photo lives on the photos volume, which no location shows, so the
|
||||||
|
/// residency proof above never finds it. The timeline in the `photos` table
|
||||||
|
/// is this daemon's proof instead: it lists only photos on the user's own
|
||||||
|
/// volume, which are theirs to share. A photo in an album someone else
|
||||||
|
/// shared is not in it and stays refused.
|
||||||
|
pub(crate) fn resolve_shareable(&self, uid: &str) -> CoreResult<NodeUid> {
|
||||||
|
resolve_anywhere_with(
|
||||||
|
uid,
|
||||||
|
|uid| self.owns_visible_uid(uid),
|
||||||
|
|uid| {
|
||||||
|
let uid = uid.to_string();
|
||||||
|
Ok(self.db.mirror_contains_uid(&uid)?
|
||||||
|
|| !self
|
||||||
|
.db
|
||||||
|
.photos_by_uid(std::slice::from_ref(&uid))?
|
||||||
|
.is_empty())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn source_parent_uid(&self, ino: u64, rel: &Path) -> CoreResult<NodeUid> {
|
fn source_parent_uid(&self, ino: u64, rel: &Path) -> CoreResult<NodeUid> {
|
||||||
let state = self.state();
|
let state = self.state();
|
||||||
let entry = state
|
let entry = state
|
||||||
|
|||||||
@@ -1027,6 +1027,7 @@ impl Core {
|
|||||||
favorite: photo.favorite,
|
favorite: photo.favorite,
|
||||||
group_size: photo.group_size as u32,
|
group_size: photo.group_size as u32,
|
||||||
has_raw: photo.has_raw,
|
has_raw: photo.has_raw,
|
||||||
|
place_id: photo.place_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ impl Core {
|
|||||||
role: &str,
|
role: &str,
|
||||||
message: Option<&str>,
|
message: Option<&str>,
|
||||||
) -> CoreResult<(usize, usize)> {
|
) -> CoreResult<(usize, usize)> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.share_node_for_uid(&uid, emails, role, message)
|
self.share_node_for_uid(&uid, emails, role, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ impl Core {
|
|||||||
&self,
|
&self,
|
||||||
uid: &str,
|
uid: &str,
|
||||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.list_share_for_uid(&uid)
|
self.list_share_for_uid(&uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ impl Core {
|
|||||||
kind: ShareEntryKind,
|
kind: ShareEntryKind,
|
||||||
role: &str,
|
role: &str,
|
||||||
) -> CoreResult<()> {
|
) -> CoreResult<()> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.update_share_role_for_uid(&uid, id, kind, role)
|
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ impl Core {
|
|||||||
id: &str,
|
id: &str,
|
||||||
kind: ShareEntryKind,
|
kind: ShareEntryKind,
|
||||||
) -> CoreResult<()> {
|
) -> CoreResult<()> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.remove_share_entry_for_uid(&uid, id, kind)
|
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ impl Core {
|
|||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
expires: Option<i64>,
|
expires: Option<i64>,
|
||||||
) -> CoreResult<PublicLinkInfo> {
|
) -> CoreResult<PublicLinkInfo> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.create_public_link_for_uid(&uid, role, password, expires)
|
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +391,7 @@ impl Core {
|
|||||||
password: LinkChange<String>,
|
password: LinkChange<String>,
|
||||||
expires: LinkChange<i64>,
|
expires: LinkChange<i64>,
|
||||||
) -> CoreResult<PublicLinkInfo> {
|
) -> CoreResult<PublicLinkInfo> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +460,7 @@ impl Core {
|
|||||||
/// [`Core::remove_public_link`] for a node addressed through any daemon
|
/// [`Core::remove_public_link`] for a node addressed through any daemon
|
||||||
/// location.
|
/// location.
|
||||||
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
|
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.remove_public_link_for_uid(&uid, id)
|
self.remove_public_link_for_uid(&uid, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,7 +486,7 @@ impl Core {
|
|||||||
|
|
||||||
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
|
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
|
||||||
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
|
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
|
||||||
let uid = self.resolve_anywhere(uid)?;
|
let uid = self.resolve_shareable(uid)?;
|
||||||
self.stop_sharing_for_uid(&uid)
|
self.stop_sharing_for_uid(&uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,14 +61,27 @@ scale.browser-status-meter trough { min-width: 140px; min-height: 6px; }
|
|||||||
|
|
||||||
/* Photo viewer: always light-on-dark, whatever the system style */
|
/* Photo viewer: always light-on-dark, whatever the system style */
|
||||||
.photo-viewer-window { background-color: #111014; }
|
.photo-viewer-window { background-color: #111014; }
|
||||||
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }
|
.media-player-window video { background-color: black; }
|
||||||
|
.media-player-note { color: rgba(255, 255, 255, 0.5); }
|
||||||
|
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.45) 60%, rgba(0, 0, 0, 0)); padding: 10px 16px 40px 20px; color: white; }
|
||||||
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
|
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
|
||||||
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
|
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
|
||||||
.viewer-action-btn { color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }
|
/* Dark discs with a white glyph read on a white photo as well as on a black one. */
|
||||||
.viewer-action-btn:hover { background-color: rgba(255, 255, 255, 0.28); color: white; }
|
.viewer-action-btn { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 50%; min-width: 36px; min-height: 36px; margin-left: 4px; -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }
|
||||||
|
.viewer-action-btn:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
|
||||||
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||||
.viewer-close-btn { background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }
|
.viewer-close-btn { margin-left: 12px; }
|
||||||
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
|
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
|
||||||
|
.viewer-group-pill { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 999px; min-height: 36px; padding: 0 14px; font-weight: 700; font-size: 0.85rem; }
|
||||||
|
.viewer-group-pill:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
|
||||||
|
.viewer-group-pill.lit { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||||
|
/* The thumbnail strip along the lightbox's bottom edge, the photo on screen ringed in the accent. */
|
||||||
|
.viewer-strip-bar { background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); padding: 28px 12px 10px 12px; }
|
||||||
|
.viewer-strip { background: none; }
|
||||||
|
.viewer-strip > row { padding: 2px; margin: 0 2px; border-radius: 8px; background: none; }
|
||||||
|
.viewer-strip > row:hover { background-color: rgba(255, 255, 255, 0.15); }
|
||||||
|
.viewer-strip > row:selected { background-color: var(--accent-bg-color); }
|
||||||
|
.viewer-strip-thumb { border-radius: 6px; background-color: rgba(255, 255, 255, 0.08); }
|
||||||
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
|
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
|
||||||
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
|
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
|
||||||
.viewer-nav-btn:disabled { opacity: 0; }
|
.viewer-nav-btn:disabled { opacity: 0; }
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ use widgets::crumb::*;
|
|||||||
use widgets::details::*;
|
use widgets::details::*;
|
||||||
use widgets::file_list::*;
|
use widgets::file_list::*;
|
||||||
use widgets::menu::*;
|
use widgets::menu::*;
|
||||||
|
use widgets::photo_drag::*;
|
||||||
|
use widgets::player::*;
|
||||||
use widgets::scrubber::*;
|
use widgets::scrubber::*;
|
||||||
use widgets::share_dialog::*;
|
use widgets::share_dialog::*;
|
||||||
use widgets::thumbnails::*;
|
use widgets::thumbnails::*;
|
||||||
@@ -525,6 +527,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
jump: Cell::new(None),
|
jump: Cell::new(None),
|
||||||
burst: Cell::new(false),
|
burst: Cell::new(false),
|
||||||
import_banner: gallery_widgets.import_banner.clone(),
|
import_banner: gallery_widgets.import_banner.clone(),
|
||||||
|
upload_bar: gallery_widgets.upload_bar.clone(),
|
||||||
upload: gallery_widgets.upload.clone(),
|
upload: gallery_widgets.upload.clone(),
|
||||||
empty_actions: gallery_widgets.empty_actions.clone(),
|
empty_actions: gallery_widgets.empty_actions.clone(),
|
||||||
title: gallery_widgets.title.clone(),
|
title: gallery_widgets.title.clone(),
|
||||||
@@ -1339,7 +1342,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
// The Albums view's New Album button.
|
// The Albums view's New Album button.
|
||||||
let new_album = gio::SimpleAction::new("new-album", None);
|
let new_album = gio::SimpleAction::new("new-album", None);
|
||||||
let ui_album = ui.clone();
|
let ui_album = ui.clone();
|
||||||
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new()));
|
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new(), None));
|
||||||
window.add_action(&new_album);
|
window.add_action(&new_album);
|
||||||
|
|
||||||
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
||||||
@@ -1517,7 +1520,7 @@ fn debug_info(ui: &Rc<Ui>) -> String {
|
|||||||
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
|
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
|
||||||
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
|
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
|
||||||
fn show_shortcuts(window: &adw::ApplicationWindow) {
|
fn show_shortcuts(window: &adw::ApplicationWindow) {
|
||||||
const GROUPS: [(&str, &[(&str, &str)]); 4] = [
|
const GROUPS: [(&str, &[(&str, &str)]); 5] = [
|
||||||
(
|
(
|
||||||
gettext_noop("General"),
|
gettext_noop("General"),
|
||||||
&[
|
&[
|
||||||
@@ -1560,8 +1563,20 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
|
|||||||
("Home End", gettext_noop("First / last photo")),
|
("Home End", gettext_noop("First / last photo")),
|
||||||
("i", gettext_noop("Show details")),
|
("i", gettext_noop("Show details")),
|
||||||
("f", gettext_noop("Fullscreen")),
|
("f", gettext_noop("Fullscreen")),
|
||||||
|
("F5", gettext_noop("Slideshow")),
|
||||||
|
("F10", gettext_noop("More")),
|
||||||
|
("<Control>c", gettext_noop("Copy Image")),
|
||||||
("Delete", gettext_noop("Move to Trash")),
|
("Delete", gettext_noop("Move to Trash")),
|
||||||
("Escape", gettext_noop("Close")),
|
("Escape <Alt>Left", gettext_noop("Close")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
gettext_noop("Video Player"),
|
||||||
|
&[
|
||||||
|
("space k", gettext_noop("Play / pause")),
|
||||||
|
("j l", gettext_noop("Back / forward 10 seconds")),
|
||||||
|
("m", gettext_noop("Mute")),
|
||||||
|
("f", gettext_noop("Fullscreen")),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -2010,6 +2025,7 @@ mod tests {
|
|||||||
favorite: false,
|
favorite: false,
|
||||||
group_size: 2,
|
group_size: 2,
|
||||||
has_raw: kind == PhotoKind::Raw,
|
has_raw: kind == PhotoKind::Raw,
|
||||||
|
place_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2032,6 +2048,40 @@ mod tests {
|
|||||||
assert!(next_group_member(&[], "jpeg").is_none());
|
assert!(next_group_member(&[], "jpeg").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The upload bar counts the upload under way, not the ones finished.
|
||||||
|
#[test]
|
||||||
|
fn the_upload_bar_counts_the_upload_under_way() {
|
||||||
|
assert_eq!(upload_progress_text(0, 12), "Uploading 1 of 12 items…");
|
||||||
|
assert_eq!(upload_progress_text(11, 12), "Uploading 12 of 12 items…");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pill names what kind of shot it switches through, and lights up
|
||||||
|
/// on the file that is not the shot's main one.
|
||||||
|
#[test]
|
||||||
|
fn the_group_pill_names_the_kind_of_shot() {
|
||||||
|
let pair = [
|
||||||
|
member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo),
|
||||||
|
member("raw", Some("IMG_1.CR2"), PhotoKind::Raw),
|
||||||
|
];
|
||||||
|
assert_eq!(group_pill(&pair, "jpeg"), Some(("RAW".into(), false)));
|
||||||
|
assert_eq!(group_pill(&pair, "raw"), Some(("RAW".into(), true)));
|
||||||
|
|
||||||
|
let live = [
|
||||||
|
member("still", Some("IMG_2.HEIC"), PhotoKind::Photo),
|
||||||
|
member("clip", Some("IMG_2.MOV"), PhotoKind::Video),
|
||||||
|
];
|
||||||
|
assert_eq!(group_pill(&live, "still"), Some(("Live".into(), false)));
|
||||||
|
assert_eq!(group_pill(&live, "clip"), Some(("Live".into(), true)));
|
||||||
|
|
||||||
|
let burst = [
|
||||||
|
member("a", None, PhotoKind::Photo),
|
||||||
|
member("b", None, PhotoKind::Photo),
|
||||||
|
member("c", None, PhotoKind::Photo),
|
||||||
|
];
|
||||||
|
assert_eq!(group_pill(&burst, "b"), Some(("2 of 3".into(), false)));
|
||||||
|
assert_eq!(group_pill(&burst, "elsewhere"), None);
|
||||||
|
}
|
||||||
|
|
||||||
/// The switch names what it will show; a file whose name the daemon has not
|
/// The switch names what it will show; a file whose name the daemon has not
|
||||||
/// resolved still gets a sentence rather than a blank.
|
/// resolved still gets a sentence rather than a blank.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -225,12 +225,13 @@ fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: >k4::Widget, x: f64
|
|||||||
/// while the entry is blank.
|
/// while the entry is blank.
|
||||||
fn prompt_album_name(
|
fn prompt_album_name(
|
||||||
ui: &Rc<Ui>,
|
ui: &Rc<Ui>,
|
||||||
|
parent: Option<gtk4::Window>,
|
||||||
heading: &str,
|
heading: &str,
|
||||||
action: &str,
|
action: &str,
|
||||||
current: &str,
|
current: &str,
|
||||||
on_name: impl Fn(String) + 'static,
|
on_name: impl Fn(String) + 'static,
|
||||||
) {
|
) {
|
||||||
let win = ui_window(ui);
|
let win = parent.or_else(|| ui_window(ui));
|
||||||
let dialog = adw::AlertDialog::builder().heading(heading).build();
|
let dialog = adw::AlertDialog::builder().heading(heading).build();
|
||||||
let group = adw::PreferencesGroup::new();
|
let group = adw::PreferencesGroup::new();
|
||||||
let row = adw::EntryRow::builder()
|
let row = adw::EntryRow::builder()
|
||||||
@@ -260,10 +261,12 @@ fn prompt_album_name(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Ask for a name and create an album, then add `photos` to it, if any.
|
/// Ask for a name and create an album, then add `photos` to it, if any.
|
||||||
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
///
|
||||||
|
/// The dialogs open over `parent`, or over the main window without one.
|
||||||
|
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
|
||||||
let ui_c = ui.clone();
|
let ui_c = ui.clone();
|
||||||
let (heading, action) = (gettext("New Album"), gettext("Create"));
|
let (heading, action) = (gettext("New Album"), gettext("Create"));
|
||||||
prompt_album_name(ui, &heading, &action, "", move |name| {
|
prompt_album_name(ui, parent, &heading, &action, "", move |name| {
|
||||||
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
|
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
|
||||||
let ui = ui_c.clone();
|
let ui = ui_c.clone();
|
||||||
let photos = photos.clone();
|
let photos = photos.clone();
|
||||||
@@ -296,7 +299,7 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
|||||||
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||||
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||||
let (heading, action) = (gettext("Rename Album"), gettext("Rename"));
|
let (heading, action) = (gettext("Rename Album"), gettext("Rename"));
|
||||||
prompt_album_name(ui, &heading, &action, &album.name, move |name| {
|
prompt_album_name(ui, None, &heading, &action, &album.name, move |name| {
|
||||||
run_album_request(
|
run_album_request(
|
||||||
&ui_c,
|
&ui_c,
|
||||||
Request::RenameAlbum {
|
Request::RenameAlbum {
|
||||||
@@ -368,7 +371,7 @@ fn reload_album_grid(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Offer our own albums to add `photos` to, plus a new one.
|
/// Offer our own albums to add `photos` to, plus a new one.
|
||||||
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
@@ -390,7 +393,7 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if albums.is_empty() {
|
if albums.is_empty() {
|
||||||
prompt_new_album(&ui, photos);
|
prompt_new_album(&ui, photos, parent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,11 +439,11 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
|||||||
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
|
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let ui_c = ui.clone();
|
let (ui_c, parent_c) = (ui.clone(), parent.clone());
|
||||||
dialog.connect_response(Some("new"), move |_, _| {
|
dialog.connect_response(Some("new"), move |_, _| {
|
||||||
prompt_new_album(&ui_c, photos.clone())
|
prompt_new_album(&ui_c, photos.clone(), parent_c.clone())
|
||||||
});
|
});
|
||||||
dialog.present(ui_window(&ui).as_ref());
|
dialog.present(parent.or_else(|| ui_window(&ui)).as_ref());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,10 +598,7 @@ pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
|||||||
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
|
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery
|
ui.gallery.await_thumb(&uid, picture);
|
||||||
.thumb_wanted
|
|
||||||
.borrow_mut()
|
|
||||||
.insert(uid.clone(), picture.clone());
|
|
||||||
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
||||||
if !queue.contains(&uid) {
|
if !queue.contains(&uid) {
|
||||||
queue.push_back(uid);
|
queue.push_back(uid);
|
||||||
|
|||||||
@@ -898,27 +898,15 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
|
|||||||
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
let mut menu = ActionMenu::new();
|
let mut menu = ActionMenu::new();
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
// Open does what a double-click does: a video or a song plays in the app,
|
||||||
|
// and Open With is the way to another player.
|
||||||
menu.item(&pgettext("verb", "Open"), move || {
|
menu.item(&pgettext("verb", "Open"), move || {
|
||||||
// Open always means "download a local copy and hand off", even for a
|
activate_entry(&ui_c, &entry_c)
|
||||||
// media file — `activate_entry` would otherwise stream it.
|
|
||||||
if is_streamable_media_entry(&entry_c) {
|
|
||||||
download_and_open(&ui_c, &entry_c);
|
|
||||||
} else {
|
|
||||||
activate_entry(&ui_c, &entry_c);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
menu.item(&gettext("Open With…"), move || {
|
menu.item(&gettext("Open With…"), move || {
|
||||||
open_entry_with(&ui_c, &entry_c)
|
open_entry_with(&ui_c, &entry_c)
|
||||||
});
|
});
|
||||||
// Play streams from the mount, no download; Open above still fetches a
|
|
||||||
// local copy for anyone who wants one.
|
|
||||||
if is_streamable_media_entry(entry) {
|
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
|
||||||
menu.item(&pgettext("verb", "Play"), move || {
|
|
||||||
stream_entry(&ui_c, &entry_c)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
menu.section();
|
menu.section();
|
||||||
|
|
||||||
// Kept through a pinned folder above it, the entry has no pin of its own
|
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||||
@@ -1107,38 +1095,8 @@ pub(crate) fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Op
|
|||||||
Some(entry)
|
Some(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether an entry can be opened through FUSE instead of fully materialized.
|
/// Open an entry the Nautilus way: folders descend, media plays in the app,
|
||||||
pub(crate) fn is_streamable_media_entry(entry: &DirEntry) -> bool {
|
/// streamed from the mount, other files download-and-open.
|
||||||
drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stream media straight from the mount, no download. Drive folders *are* part
|
|
||||||
/// of the FUSE mount, so a player pointed at `<mountpoint>/<rel>` reads the file
|
|
||||||
/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it
|
|
||||||
/// seeks and buffers — instead of waiting for the whole file to land like
|
|
||||||
/// [`Request::OpenFile`] does. This is the point of the feature: a 2 GB HEVC
|
|
||||||
/// `.mkv` starts playing in seconds.
|
|
||||||
pub(crate) fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
|
||||||
let rel = entry_rel(ui, entry);
|
|
||||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
|
||||||
let abs = mounted_path(&mountpoint, &rel);
|
|
||||||
let Some(path) = abs.to_str() else {
|
|
||||||
toast_error(
|
|
||||||
ui,
|
|
||||||
&gettext("Couldn't play media"),
|
|
||||||
&gettext("The file path isn't valid UTF-8."),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
toast(
|
|
||||||
ui,
|
|
||||||
&gettext_f("Streaming “{name}”…", &[("name", &entry.name)]),
|
|
||||||
);
|
|
||||||
play_external(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open an entry the Nautilus way: folders descend, media streams from the mount,
|
|
||||||
/// other files download-and-open.
|
|
||||||
pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
let rel = entry_rel(ui, entry);
|
let rel = entry_rel(ui, entry);
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
@@ -1151,16 +1109,16 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
} else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia {
|
} else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia {
|
||||||
// Media streams rather than downloads: that is exactly the "play it,
|
// Media streams rather than downloads: that is exactly the "play it,
|
||||||
// don't fetch the whole thing" behaviour this is for.
|
// don't fetch the whole thing" behaviour this is for.
|
||||||
stream_entry(ui, entry);
|
open_media_player(ui, entry);
|
||||||
} else {
|
} else {
|
||||||
download_and_open(ui, entry);
|
download_and_open(ui, entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand a file to the user's default application: through the mount when it is
|
/// Hand a file to the user's default application: through the mount when it is
|
||||||
/// there, otherwise downloaded into the cache first. The open path behind both
|
/// there, otherwise downloaded into the cache first. The open path behind a
|
||||||
/// a plain double-click and the context menu's "Open" (including for a video,
|
/// double-click and the context menu's "Open" for anything but a folder or
|
||||||
/// when the user wants their default application rather than the player).
|
/// media, and for media the mount doesn't show.
|
||||||
///
|
///
|
||||||
/// The mount comes first because the cache blob is a *copy* keyed by content
|
/// The mount comes first because the cache blob is a *copy* keyed by content
|
||||||
/// hash — an application saving into it writes where Drive will never look, and
|
/// hash — an application saving into it writes where Drive will never look, and
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ pub(crate) struct Viewer {
|
|||||||
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
|
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
|
||||||
pub(crate) favorite: gtk4::ToggleButton,
|
pub(crate) favorite: gtk4::ToggleButton,
|
||||||
pub(crate) favorite_suppress: Cell<bool>,
|
pub(crate) favorite_suppress: Cell<bool>,
|
||||||
/// The button that switches between the files of one shot, and the files it
|
/// The pill 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
|
/// 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.
|
/// clip). Empty, and the pill hidden, for a photo stored as one file.
|
||||||
pub(crate) group_btn: gtk4::Button,
|
pub(crate) group_btn: gtk4::Button,
|
||||||
pub(crate) group: RefCell<Vec<PhotoItem>>,
|
pub(crate) group: RefCell<Vec<PhotoItem>>,
|
||||||
/// uid of the photo currently on screen.
|
/// uid of the photo currently on screen.
|
||||||
@@ -81,14 +81,25 @@ pub(crate) struct Viewer {
|
|||||||
/// rests; whether the pointer is over one of them (which keeps them up);
|
/// rests; whether the pointer is over one of them (which keeps them up);
|
||||||
/// and the pending fade-out.
|
/// and the pending fade-out.
|
||||||
pub(crate) chrome: Vec<gtk4::Revealer>,
|
pub(crate) chrome: Vec<gtk4::Revealer>,
|
||||||
|
/// The strip of thumbnails along the bottom, its selection (the photo on
|
||||||
|
/// screen), and the revealer it fades with. Hidden in a slideshow.
|
||||||
|
pub(crate) strip: gtk4::ListView,
|
||||||
|
pub(crate) strip_selection: gtk4::SingleSelection,
|
||||||
|
pub(crate) strip_reveal: gtk4::Revealer,
|
||||||
pub(crate) chrome_hover: Cell<bool>,
|
pub(crate) chrome_hover: Cell<bool>,
|
||||||
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
|
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
|
||||||
/// The slideshow toggle; the timer that moves it on; and whether it put
|
/// The ⋮ button, and whether its menu is open, which keeps the controls
|
||||||
/// the window in fullscreen, so stopping it only leaves a fullscreen it
|
/// up the way resting the pointer on them does.
|
||||||
|
pub(crate) more: gtk4::Button,
|
||||||
|
pub(crate) menu_open: Cell<bool>,
|
||||||
|
/// Whether a slideshow runs; the timer that moves it on; and whether it
|
||||||
|
/// put the window in fullscreen, so stopping it only leaves a fullscreen it
|
||||||
/// entered itself.
|
/// entered itself.
|
||||||
pub(crate) slideshow: gtk4::ToggleButton,
|
pub(crate) slideshow: Cell<bool>,
|
||||||
pub(crate) slide_source: RefCell<Option<glib::SourceId>>,
|
pub(crate) slide_source: RefCell<Option<glib::SourceId>>,
|
||||||
pub(crate) slide_fullscreened: Cell<bool>,
|
pub(crate) slide_fullscreened: Cell<bool>,
|
||||||
|
/// Toasts over the lightbox. The main window's would land behind it.
|
||||||
|
pub(crate) toasts: adw::ToastOverlay,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
|
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
|
||||||
@@ -163,6 +174,14 @@ fn load_photo_group(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) {
|
|||||||
.group_btn
|
.group_btn
|
||||||
.set_tooltip_text(Some(&member_tooltip(next)));
|
.set_tooltip_text(Some(&member_tooltip(next)));
|
||||||
}
|
}
|
||||||
|
if let Some((label, lit)) = group_pill(&items, &uid) {
|
||||||
|
viewer.group_btn.set_label(&label);
|
||||||
|
if lit {
|
||||||
|
viewer.group_btn.add_css_class("lit");
|
||||||
|
} else {
|
||||||
|
viewer.group_btn.remove_css_class("lit");
|
||||||
|
}
|
||||||
|
}
|
||||||
*viewer.group.borrow_mut() = items;
|
*viewer.group.borrow_mut() = items;
|
||||||
viewer.group_btn.set_visible(true);
|
viewer.group_btn.set_visible(true);
|
||||||
});
|
});
|
||||||
@@ -175,6 +194,34 @@ pub(crate) fn next_group_member<'a>(members: &'a [PhotoItem], uid: &str) -> Opti
|
|||||||
members.get((at + 1) % members.len())
|
members.get((at + 1) % members.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the group pill reads for the shot whose files are `members`, with
|
||||||
|
/// `uid` on screen, and whether it is lit. A raw pair reads "RAW", lit while
|
||||||
|
/// the raw file is shown; a live photo reads "Live", lit while its clip plays;
|
||||||
|
/// a burst counts its frames.
|
||||||
|
pub(crate) fn group_pill(members: &[PhotoItem], uid: &str) -> Option<(String, bool)> {
|
||||||
|
let at = members.iter().position(|item| item.uid == uid)?;
|
||||||
|
let shown = members[at].kind;
|
||||||
|
if members.iter().any(|item| item.kind == PhotoKind::Raw) {
|
||||||
|
// Translators: badge on a photo tile whose shot includes a raw camera file.
|
||||||
|
return Some((gettext("RAW"), shown == PhotoKind::Raw));
|
||||||
|
}
|
||||||
|
if members.len() == 2 && members.iter().any(|item| item.kind == PhotoKind::Video) {
|
||||||
|
// Translators: the switch between a live photo and its short clip in the photo viewer.
|
||||||
|
return Some((pgettext("live photo", "Live"), shown == PhotoKind::Video));
|
||||||
|
}
|
||||||
|
Some((
|
||||||
|
// Translators: the lightbox counter, such as "12 of 340"; {position} is the photo's place, {total} how many there are.
|
||||||
|
gettext_f(
|
||||||
|
"{position} of {total}",
|
||||||
|
&[
|
||||||
|
("position", &thousands(at + 1)),
|
||||||
|
("total", &thousands(members.len())),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// What to call one file of a shot: its name, or its kind when the daemon has
|
/// What to call one file of a shot: its name, or its kind when the daemon has
|
||||||
/// not resolved a name for it.
|
/// not resolved a name for it.
|
||||||
pub(crate) fn member_label(item: &PhotoItem) -> String {
|
pub(crate) fn member_label(item: &PhotoItem) -> String {
|
||||||
@@ -232,6 +279,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
clear_info(viewer);
|
clear_info(viewer);
|
||||||
stop_video(viewer);
|
stop_video(viewer);
|
||||||
viewer.media.set_visible_child_name("photo");
|
viewer.media.set_visible_child_name("photo");
|
||||||
|
sync_strip(viewer);
|
||||||
reset_zoom(viewer);
|
reset_zoom(viewer);
|
||||||
// A slideshow waits for this photo to land before it starts counting.
|
// A slideshow waits for this photo to land before it starts counting.
|
||||||
disarm_slide(viewer);
|
disarm_slide(viewer);
|
||||||
@@ -309,6 +357,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
None => {
|
None => {
|
||||||
viewer.video.set_filename(Some(&path));
|
viewer.video.set_filename(Some(&path));
|
||||||
viewer.media.set_visible_child_name("video");
|
viewer.media.set_visible_child_name("video");
|
||||||
|
sync_strip(&viewer);
|
||||||
*viewer.path.borrow_mut() = Some(path.clone());
|
*viewer.path.borrow_mut() = Some(path.clone());
|
||||||
show_info(&viewer, size, ExifInfo::default());
|
show_info(&viewer, size, ExifInfo::default());
|
||||||
}
|
}
|
||||||
@@ -341,14 +390,15 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
/// Start the slideshow: fullscreen, controls out of the way, and the photo on
|
/// Start the slideshow: fullscreen, controls out of the way, and the photo on
|
||||||
/// screen counted down once it has loaded.
|
/// screen counted down once it has loaded.
|
||||||
fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
if viewer.slideshow.replace(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !viewer.window.is_fullscreen() {
|
if !viewer.window.is_fullscreen() {
|
||||||
viewer.window.fullscreen();
|
viewer.window.fullscreen();
|
||||||
viewer.slide_fullscreened.set(true);
|
viewer.slide_fullscreened.set(true);
|
||||||
}
|
}
|
||||||
viewer
|
|
||||||
.slideshow
|
|
||||||
.set_icon_name("media-playback-pause-symbolic");
|
|
||||||
hide_chrome(viewer);
|
hide_chrome(viewer);
|
||||||
|
sync_strip(viewer);
|
||||||
if !viewer.loading.get() {
|
if !viewer.loading.get() {
|
||||||
arm_slide(ui, viewer);
|
arm_slide(ui, viewer);
|
||||||
}
|
}
|
||||||
@@ -356,13 +406,20 @@ fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
|||||||
|
|
||||||
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
|
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
|
||||||
fn stop_slideshow(viewer: &Rc<Viewer>) {
|
fn stop_slideshow(viewer: &Rc<Viewer>) {
|
||||||
|
viewer.slideshow.set(false);
|
||||||
|
sync_strip(viewer);
|
||||||
disarm_slide(viewer);
|
disarm_slide(viewer);
|
||||||
if viewer.slide_fullscreened.replace(false) {
|
if viewer.slide_fullscreened.replace(false) {
|
||||||
viewer.window.unfullscreen();
|
viewer.window.unfullscreen();
|
||||||
}
|
}
|
||||||
viewer.slideshow.set_icon_name("pdfs-slideshow-symbolic");
|
}
|
||||||
if viewer.slideshow.is_active() {
|
|
||||||
viewer.slideshow.set_active(false);
|
/// Start the slideshow, or stop the one running.
|
||||||
|
fn toggle_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
if viewer.slideshow.get() {
|
||||||
|
stop_slideshow(viewer);
|
||||||
|
} else {
|
||||||
|
start_slideshow(ui, viewer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +427,7 @@ fn stop_slideshow(viewer: &Rc<Viewer>) {
|
|||||||
/// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video.
|
/// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video.
|
||||||
fn arm_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
fn arm_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
disarm_slide(viewer);
|
disarm_slide(viewer);
|
||||||
if !viewer.slideshow.is_active() {
|
if !viewer.slideshow.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let playing_video = viewer.media.visible_child_name().as_deref() == Some("video");
|
let playing_video = viewer.media.visible_child_name().as_deref() == Some("video");
|
||||||
@@ -790,7 +847,7 @@ fn show_chrome(viewer: &Rc<Viewer>) {
|
|||||||
};
|
};
|
||||||
// This timer has fired, so its id must not be removed again.
|
// This timer has fired, so its id must not be removed again.
|
||||||
viewer.chrome_source.borrow_mut().take();
|
viewer.chrome_source.borrow_mut().take();
|
||||||
if !viewer.chrome_hover.get() {
|
if !viewer.chrome_hover.get() && !viewer.menu_open.get() {
|
||||||
hide_chrome(&viewer);
|
hide_chrome(&viewer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -808,14 +865,14 @@ fn hide_chrome(viewer: &Rc<Viewer>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Copy the photo on screen to where the user picks, under its Drive name.
|
/// Copy the photo on screen to where the user picks, under its Drive name.
|
||||||
pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: >k4::Window, source: &str, name: &str) {
|
pub(crate) fn save_photo_to_disk(viewer: &Rc<Viewer>, source: &str, name: &str) {
|
||||||
let dialog = gtk4::FileDialog::builder()
|
let dialog = gtk4::FileDialog::builder()
|
||||||
.title(gettext("Save a Copy"))
|
.title(gettext("Save a Copy"))
|
||||||
.initial_name(name)
|
.initial_name(name)
|
||||||
.build();
|
.build();
|
||||||
let source = source.to_string();
|
let source = source.to_string();
|
||||||
let ui = ui.clone();
|
let toasts = viewer.toasts.clone();
|
||||||
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
|
dialog.save(Some(&viewer.window), gio::Cancellable::NONE, move |res| {
|
||||||
// An error here is the dialog being dismissed.
|
// An error here is the dialog being dismissed.
|
||||||
let Some(dest) = res.ok().and_then(|file| file.path()) else {
|
let Some(dest) = res.ok().and_then(|file| file.path()) else {
|
||||||
return;
|
return;
|
||||||
@@ -824,26 +881,67 @@ pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: >k4::Window, source: &st
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let copied =
|
let copied =
|
||||||
gio::spawn_blocking(move || std::fs::copy(&source, &dest).map(|_| dest)).await;
|
gio::spawn_blocking(move || std::fs::copy(&source, &dest).map(|_| dest)).await;
|
||||||
match copied {
|
let message = match copied {
|
||||||
Ok(Ok(dest)) => {
|
Ok(Ok(dest)) => {
|
||||||
let saved = dest.file_name().map_or_else(
|
let saved = dest.file_name().map_or_else(
|
||||||
|| dest.display().to_string(),
|
|| dest.display().to_string(),
|
||||||
|name| name.to_string_lossy().into_owned(),
|
|name| name.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
// Translators: {name} is the saved file's name.
|
// Translators: {name} is the saved file's name.
|
||||||
toast(&ui, &gettext_f("Saved “{name}”", &[("name", &saved)]));
|
gettext_f("Saved “{name}”", &[("name", &saved)])
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => toast_error(&ui, &gettext("Couldn't save the copy"), &e.to_string()),
|
Ok(Err(e)) => failure(&gettext("Couldn't save the copy"), &e.to_string()),
|
||||||
Err(_) => toast_error(
|
Err(_) => failure(
|
||||||
&ui,
|
|
||||||
&gettext("Couldn't save the copy"),
|
&gettext("Couldn't save the copy"),
|
||||||
&gettext("The copy was interrupted."),
|
&gettext("The copy was interrupted."),
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
toasts.add_toast(
|
||||||
|
adw::Toast::builder()
|
||||||
|
.title(&message)
|
||||||
|
.use_markup(false)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A failed action's toast text, worded like [`toast_error`]'s.
|
||||||
|
fn failure(what: &str, detail: &str) -> String {
|
||||||
|
tracing::warn!("{what}: {detail}");
|
||||||
|
// Translators: a failed action's toast. {what} says what failed, such as
|
||||||
|
// "Couldn't rename"; {detail} is the reason, usually untranslated text
|
||||||
|
// from the mount service.
|
||||||
|
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put the photo on screen on the clipboard as an image, for pasting into a
|
||||||
|
/// chat or a document.
|
||||||
|
fn copy_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
let Some(texture) = viewer
|
||||||
|
.picture
|
||||||
|
.paintable()
|
||||||
|
.and_downcast::<gtk4::gdk::Texture>()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
viewer.window.clipboard().set_texture(&texture);
|
||||||
|
viewer.toasts.add_toast(
|
||||||
|
adw::Toast::builder()
|
||||||
|
// Translators: toast after the photo viewer put the photo on the clipboard.
|
||||||
|
.title(gettext("Image copied"))
|
||||||
|
.use_markup(false)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the Share dialog for the photo on screen, over the lightbox.
|
||||||
|
fn share_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
let uid = viewer.uid.borrow().clone();
|
||||||
|
let name = viewer.name.borrow().clone().unwrap_or_default();
|
||||||
|
share_photo(ui, uid, name, Some(viewer.window.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
/// A symlink to the cached file `path` under its Drive `name`, in a directory
|
/// A symlink to the cached file `path` under its Drive `name`, in a directory
|
||||||
/// of its own below `base`. The cache names files by content hash, with no
|
/// of its own below `base`. The cache names files by content hash, with no
|
||||||
/// extension; the app chooser and the app it launches both need the real name
|
/// extension; the app chooser and the app it launches both need the real name
|
||||||
@@ -983,16 +1081,124 @@ pub(crate) fn position_label(index: u32, loaded: u32, more: bool, total: Option<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the lightbox's title (the photo's date) and its "12 of 340" counter.
|
/// The lightbox's title: when the photo was taken, as "Wednesday, October 7,
|
||||||
|
/// 2026, 13:42".
|
||||||
|
fn photo_title(capture_time: i64) -> String {
|
||||||
|
let title = dates::full(capture_time);
|
||||||
|
if title.is_empty() {
|
||||||
|
gettext("Unknown Date")
|
||||||
|
} else {
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the lightbox's title (the photo's date) and the line under it: the town
|
||||||
|
/// it was taken in, when known, and its "12 of 340" counter.
|
||||||
fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_time: i64) {
|
fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_time: i64) {
|
||||||
let loaded = ui.gallery.model.n_items();
|
let loaded = ui.gallery.model.n_items();
|
||||||
let more = ui.gallery.has_more.get();
|
let more = ui.gallery.has_more.get();
|
||||||
viewer.prev.set_sensitive(index > 0);
|
viewer.prev.set_sensitive(index > 0);
|
||||||
viewer.next.set_sensitive(index + 1 < loaded || more);
|
viewer.next.set_sensitive(index + 1 < loaded || more);
|
||||||
viewer.title.set_label(&format_capture_time(capture_time));
|
viewer.title.set_label(&photo_title(capture_time));
|
||||||
|
let position = position_label(index, loaded, more, gallery_total(ui));
|
||||||
|
let place = viewer_item(ui, viewer, &viewer.uid.borrow())
|
||||||
|
.and_then(|item| item.place_id)
|
||||||
|
.and_then(pdfs_core::places::city)
|
||||||
|
.map(|city| city.name);
|
||||||
|
let line = match place {
|
||||||
|
Some(place) => {
|
||||||
|
let args = [("place", place), ("position", position.as_str())];
|
||||||
|
// Translators: the line under the photo viewer's title, such as "Berlin · 12 of 340"; {place} is a town, {position} the counter.
|
||||||
|
gettext_f("{place} · {position}", &args)
|
||||||
|
}
|
||||||
|
None => position,
|
||||||
|
};
|
||||||
|
viewer.counter.set_label(&line);
|
||||||
|
sync_strip(viewer);
|
||||||
|
mark_in_strip(viewer, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the strip of thumbnails when there is more than one photo to pick
|
||||||
|
/// from. Not in a slideshow, and not over a video, where it would cover the
|
||||||
|
/// player's seek bar.
|
||||||
|
fn sync_strip(viewer: &Rc<Viewer>) {
|
||||||
|
let video = viewer.media.visible_child_name().as_deref() == Some("video");
|
||||||
viewer
|
viewer
|
||||||
.counter
|
.strip_reveal
|
||||||
.set_label(&position_label(index, loaded, more, gallery_total(ui)));
|
.set_visible(viewer.strip_selection.n_items() > 1 && !viewer.slideshow.get() && !video);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the photo at `index` in the strip and bring it into view.
|
||||||
|
fn mark_in_strip(viewer: &Rc<Viewer>, index: u32) {
|
||||||
|
if index >= viewer.strip_selection.n_items() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewer.strip_selection.set_selected(index);
|
||||||
|
viewer
|
||||||
|
.strip
|
||||||
|
.scroll_to(index, gtk4::ListScrollFlags::NONE, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The strip of thumbnails along the lightbox's bottom edge: the gallery's
|
||||||
|
/// photos in its order, the one on screen marked. A click steps straight to a
|
||||||
|
/// photo; the thumbnails come through the gallery's own pipeline and cache.
|
||||||
|
fn build_strip(ui: &Rc<Ui>) -> (gtk4::ListView, gtk4::SingleSelection) {
|
||||||
|
let selection = gtk4::SingleSelection::new(Some(ui.gallery.model.clone()));
|
||||||
|
selection.set_autoselect(false);
|
||||||
|
selection.set_can_unselect(true);
|
||||||
|
|
||||||
|
let factory = gtk4::SignalListItemFactory::new();
|
||||||
|
factory.connect_setup(|_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let picture = gtk4::Picture::builder()
|
||||||
|
.content_fit(gtk4::ContentFit::Cover)
|
||||||
|
.width_request(56)
|
||||||
|
.height_request(56)
|
||||||
|
.can_shrink(true)
|
||||||
|
.build();
|
||||||
|
picture.add_css_class("viewer-strip-thumb");
|
||||||
|
// The arrow keys belong to the lightbox, not to the strip.
|
||||||
|
item.set_focusable(false);
|
||||||
|
item.set_child(Some(&picture));
|
||||||
|
});
|
||||||
|
let ui_bind = ui.clone();
|
||||||
|
factory.connect_bind(move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let Some(picture) = item.child().and_downcast::<gtk4::Picture>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(boxed) = item.item().and_downcast::<BoxedAnyObject>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let photo = boxed.borrow::<PhotoItem>().clone();
|
||||||
|
picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
||||||
|
picture.set_tooltip_text(Some(&dates::full(photo.capture_time)));
|
||||||
|
want_thumb(&ui_bind, &photo, &picture);
|
||||||
|
schedule_thumbs(&ui_bind);
|
||||||
|
});
|
||||||
|
let ui_unbind = ui.clone();
|
||||||
|
factory.connect_unbind(move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let (Some(picture), Some(boxed)) = (
|
||||||
|
item.child().and_downcast::<gtk4::Picture>(),
|
||||||
|
item.item().and_downcast::<BoxedAnyObject>(),
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let uid = boxed.borrow::<PhotoItem>().uid.clone();
|
||||||
|
ui_unbind
|
||||||
|
.gallery
|
||||||
|
.release_thumb(&uid, |waiting| waiting == &picture);
|
||||||
|
});
|
||||||
|
|
||||||
|
let list = gtk4::ListView::builder()
|
||||||
|
.model(&selection)
|
||||||
|
.factory(&factory)
|
||||||
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
.focusable(false)
|
||||||
|
.build();
|
||||||
|
list.add_css_class("viewer-strip");
|
||||||
|
(list, selection)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The in-app lightbox: the photo or video, edge-to-edge on a dark backdrop,
|
/// The in-app lightbox: the photo or video, edge-to-edge on a dark backdrop,
|
||||||
@@ -1041,6 +1247,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.build();
|
.build();
|
||||||
|
wire_player(&video, &window);
|
||||||
let media = gtk4::Stack::new();
|
let media = gtk4::Stack::new();
|
||||||
media.add_named(&scroller, Some("photo"));
|
media.add_named(&scroller, Some("photo"));
|
||||||
media.add_named(&video, Some("video"));
|
media.add_named(&video, Some("video"));
|
||||||
@@ -1125,22 +1332,21 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
favorite_btn.add_css_class("flat");
|
favorite_btn.add_css_class("flat");
|
||||||
favorite_btn.add_css_class("viewer-action-btn");
|
favorite_btn.add_css_class("viewer-action-btn");
|
||||||
|
|
||||||
let slideshow_btn = gtk4::ToggleButton::builder()
|
|
||||||
.icon_name("pdfs-slideshow-symbolic")
|
|
||||||
.tooltip_text(gettext("Slideshow (F5)"))
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
slideshow_btn.add_css_class("flat");
|
|
||||||
slideshow_btn.add_css_class("viewer-action-btn");
|
|
||||||
|
|
||||||
// Only shown for a shot stored as more than one file; `load_photo_group`
|
// Only shown for a shot stored as more than one file; `load_photo_group`
|
||||||
// decides that per photo.
|
// decides that per photo and labels it ("RAW", "Live", "2 of 3").
|
||||||
let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo"));
|
let group_btn = gtk4::Button::builder()
|
||||||
group_btn.set_visible(false);
|
.valign(gtk4::Align::Center)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
group_btn.add_css_class("flat");
|
||||||
|
group_btn.add_css_class("viewer-group-pill");
|
||||||
|
|
||||||
let download_btn = action("document-save-symbolic", &gettext("Save a Copy…"));
|
// Everything used less often than the favorite and the details sits in
|
||||||
let delete_btn = action("user-trash-symbolic", &gettext("Move to Trash (Delete)"));
|
// one menu, named in words rather than in icons.
|
||||||
let open_ext_btn = action("document-open-symbolic", &gettext("Open With…"));
|
let more_btn = action("view-more-symbolic", &gettext("More"));
|
||||||
|
let fullscreen_btn = fullscreen_button(&window);
|
||||||
|
fullscreen_btn.add_css_class("flat");
|
||||||
|
fullscreen_btn.add_css_class("viewer-action-btn");
|
||||||
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
|
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
|
||||||
close_btn.add_css_class("viewer-close-btn");
|
close_btn.add_css_class("viewer-close-btn");
|
||||||
|
|
||||||
@@ -1150,14 +1356,26 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
top_bar.append(&group_btn);
|
top_bar.append(&group_btn);
|
||||||
top_bar.append(&favorite_btn);
|
top_bar.append(&favorite_btn);
|
||||||
top_bar.append(&info_toggle);
|
top_bar.append(&info_toggle);
|
||||||
top_bar.append(&slideshow_btn);
|
top_bar.append(&fullscreen_btn);
|
||||||
top_bar.append(&download_btn);
|
top_bar.append(&more_btn);
|
||||||
top_bar.append(&delete_btn);
|
|
||||||
top_bar.append(&open_ext_btn);
|
|
||||||
top_bar.append(&close_btn);
|
top_bar.append(&close_btn);
|
||||||
let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start);
|
let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start);
|
||||||
overlay.add_overlay(&top_reveal);
|
overlay.add_overlay(&top_reveal);
|
||||||
|
|
||||||
|
let (strip, strip_selection) = build_strip(ui);
|
||||||
|
let strip_scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::External)
|
||||||
|
.vscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.child(&strip)
|
||||||
|
.build();
|
||||||
|
strip_scroll.add_css_class("viewer-strip-bar");
|
||||||
|
let strip_reveal = fade(
|
||||||
|
strip_scroll.upcast_ref(),
|
||||||
|
gtk4::Align::Fill,
|
||||||
|
gtk4::Align::End,
|
||||||
|
);
|
||||||
|
overlay.add_overlay(&strip_reveal);
|
||||||
|
|
||||||
// Details panel: docked beside the image as a real surface, so opening it
|
// Details panel: docked beside the image as a real surface, so opening it
|
||||||
// narrows the photo instead of covering part of it.
|
// narrows the photo instead of covering part of it.
|
||||||
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
@@ -1245,7 +1463,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
narrow.add_setter(&info_split, "collapsed", Some(&true.to_value()));
|
narrow.add_setter(&info_split, "collapsed", Some(&true.to_value()));
|
||||||
adw_window.add_breakpoint(narrow);
|
adw_window.add_breakpoint(narrow);
|
||||||
let body = info_split.clone();
|
let body = info_split.clone();
|
||||||
adw_window.set_content(Some(&body));
|
let toasts = adw::ToastOverlay::new();
|
||||||
|
toasts.set_child(Some(&body));
|
||||||
|
adw_window.set_content(Some(&toasts));
|
||||||
|
|
||||||
let viewer = Rc::new(Viewer {
|
let viewer = Rc::new(Viewer {
|
||||||
window: window.clone(),
|
window: window.clone(),
|
||||||
@@ -1276,12 +1496,23 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
pinch_start: Cell::new(1.0),
|
pinch_start: Cell::new(1.0),
|
||||||
drag_start: Cell::new((0.0, 0.0)),
|
drag_start: Cell::new((0.0, 0.0)),
|
||||||
pointer: Cell::new((0.0, 0.0)),
|
pointer: Cell::new((0.0, 0.0)),
|
||||||
chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
|
chrome: vec![
|
||||||
|
top_reveal.clone(),
|
||||||
|
prev_reveal.clone(),
|
||||||
|
next_reveal.clone(),
|
||||||
|
strip_reveal.clone(),
|
||||||
|
],
|
||||||
|
strip: strip.clone(),
|
||||||
|
strip_selection: strip_selection.clone(),
|
||||||
|
strip_reveal: strip_reveal.clone(),
|
||||||
chrome_hover: Cell::new(false),
|
chrome_hover: Cell::new(false),
|
||||||
chrome_source: RefCell::new(None),
|
chrome_source: RefCell::new(None),
|
||||||
slideshow: slideshow_btn.clone(),
|
more: more_btn.clone(),
|
||||||
|
menu_open: Cell::new(false),
|
||||||
|
slideshow: Cell::new(false),
|
||||||
slide_source: RefCell::new(None),
|
slide_source: RefCell::new(None),
|
||||||
slide_fullscreened: Cell::new(false),
|
slide_fullscreened: Cell::new(false),
|
||||||
|
toasts,
|
||||||
});
|
});
|
||||||
|
|
||||||
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
|
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
|
||||||
@@ -1307,7 +1538,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
body.add_controller(motion);
|
body.add_controller(motion);
|
||||||
for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
|
for revealer in [&top_reveal, &prev_reveal, &next_reveal, &strip_reveal] {
|
||||||
let hover = gtk4::EventControllerMotion::new();
|
let hover = gtk4::EventControllerMotion::new();
|
||||||
let viewer_enter = Rc::downgrade(&viewer);
|
let viewer_enter = Rc::downgrade(&viewer);
|
||||||
hover.connect_enter(move |_, _, _| {
|
hover.connect_enter(move |_, _, _| {
|
||||||
@@ -1423,13 +1654,50 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
w_close.close();
|
w_close.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A click on a thumbnail selects it; step there. Every selection the
|
||||||
|
// lightbox sets itself is the photo on screen, and the model's own changes
|
||||||
|
// (a page landing, a favorite rewritten in place) keep the selected photo
|
||||||
|
// or drop it, so a selection elsewhere can only be the user's.
|
||||||
|
let ui_strip = ui.clone();
|
||||||
|
let viewer_strip = Rc::downgrade(&viewer);
|
||||||
|
strip_selection.connect_selection_changed(move |selection, _, _| {
|
||||||
|
let Some(viewer) = viewer_strip.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let picked = selection.selected();
|
||||||
|
let Some(current) = find_photo_index(&ui_strip.gallery.model, &viewer.uid.borrow()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if picked != gtk4::INVALID_LIST_POSITION && picked != current {
|
||||||
|
navigate_photo(&ui_strip, &viewer, picked as i32 - current as i32);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let viewer_items = Rc::downgrade(&viewer);
|
||||||
|
let items_handler = ui
|
||||||
|
.gallery
|
||||||
|
.model
|
||||||
|
.connect_items_changed(move |model, _, _, _| {
|
||||||
|
let Some(viewer) = viewer_items.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(index) = find_photo_index(model, &viewer.uid.borrow()) {
|
||||||
|
viewer.strip_selection.set_selected(index);
|
||||||
|
}
|
||||||
|
sync_strip(&viewer);
|
||||||
|
});
|
||||||
|
|
||||||
// The one strong handle on the viewer. Every handler holds a weak one: a
|
// The one strong handle on the viewer. Every handler holds a weak one: a
|
||||||
// strong one in a handler of a widget the viewer holds is a cycle, which
|
// strong one in a handler of a widget the viewer holds is a cycle, which
|
||||||
// kept every closed lightbox, its window and its full-size photo in memory
|
// kept every closed lightbox, its window and its full-size photo in memory
|
||||||
// for the life of the app.
|
// for the life of the app.
|
||||||
let keep = RefCell::new(Some(viewer.clone()));
|
let keep = RefCell::new(Some(viewer.clone()));
|
||||||
let ui_close = ui.clone();
|
let ui_close = ui.clone();
|
||||||
|
let items_handler = RefCell::new(Some(items_handler));
|
||||||
window.connect_close_request(move |_| {
|
window.connect_close_request(move |_| {
|
||||||
|
// The gallery's model outlives the lightbox.
|
||||||
|
if let Some(handler) = items_handler.borrow_mut().take() {
|
||||||
|
ui_close.gallery.model.disconnect(handler);
|
||||||
|
}
|
||||||
if let Some(viewer) = keep.borrow_mut().take() {
|
if let Some(viewer) = keep.borrow_mut().take() {
|
||||||
reveal_photo(&ui_close, &viewer.uid.borrow());
|
reveal_photo(&ui_close, &viewer.uid.borrow());
|
||||||
stop_video(&viewer);
|
stop_video(&viewer);
|
||||||
@@ -1457,36 +1725,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_slides = ui.clone();
|
|
||||||
let viewer_slides = Rc::downgrade(&viewer);
|
|
||||||
slideshow_btn.connect_toggled(move |toggle| {
|
|
||||||
let Some(viewer_slides) = viewer_slides.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if toggle.is_active() {
|
|
||||||
start_slideshow(&ui_slides, &viewer_slides);
|
|
||||||
} else {
|
|
||||||
stop_slideshow(&viewer_slides);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let toggle_off = info_toggle.clone();
|
let toggle_off = info_toggle.clone();
|
||||||
info_close.connect_clicked(move |_| toggle_off.set_active(false));
|
info_close.connect_clicked(move |_| toggle_off.set_active(false));
|
||||||
|
|
||||||
let ui_map = ui.clone();
|
let ui_map = ui.clone();
|
||||||
let viewer_map = Rc::downgrade(&viewer);
|
let viewer_map = Rc::downgrade(&viewer);
|
||||||
let w_map = window.downgrade();
|
|
||||||
info_map.connect_clicked(move |_| {
|
info_map.connect_clicked(move |_| {
|
||||||
let Some(w_map) = w_map.upgrade() else {
|
if let Some(viewer_map) = viewer_map.upgrade() {
|
||||||
return;
|
show_photo_on_map(&ui_map, &viewer_map);
|
||||||
};
|
|
||||||
let Some(viewer_map) = viewer_map.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let coords = *viewer_map.coords.borrow();
|
|
||||||
if let Some((lat, lon)) = coords {
|
|
||||||
w_map.close();
|
|
||||||
show_on_map(&ui_map, lat, lon);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1538,24 +1784,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deleting the photo on screen closes the lightbox: what it was showing is
|
|
||||||
// gone, and leaving it open on a trashed photo invites a second Delete on
|
|
||||||
// something the user cannot see.
|
|
||||||
let ui_delete = ui.clone();
|
|
||||||
let viewer_delete = Rc::downgrade(&viewer);
|
|
||||||
let w_delete = window.downgrade();
|
|
||||||
delete_btn.connect_clicked(move |_| {
|
|
||||||
let Some(w_delete) = w_delete.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(viewer_delete) = viewer_delete.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let uid = viewer_delete.uid.borrow().clone();
|
|
||||||
w_delete.close();
|
|
||||||
trash_photos(&ui_delete, vec![uid], false);
|
|
||||||
});
|
|
||||||
|
|
||||||
let ui_group = ui.clone();
|
let ui_group = ui.clone();
|
||||||
let viewer_group = Rc::downgrade(&viewer);
|
let viewer_group = Rc::downgrade(&viewer);
|
||||||
group_btn.connect_clicked(move |_| {
|
group_btn.connect_clicked(move |_| {
|
||||||
@@ -1571,38 +1799,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_download = ui.clone();
|
let ui_more = ui.clone();
|
||||||
let w_download = window.downgrade();
|
let viewer_more = Rc::downgrade(&viewer);
|
||||||
let viewer_download = Rc::downgrade(&viewer);
|
more_btn.connect_clicked(move |_| {
|
||||||
download_btn.connect_clicked(move |_| {
|
if let Some(viewer_more) = viewer_more.upgrade() {
|
||||||
let Some(w_download) = w_download.upgrade() else {
|
open_viewer_menu(&ui_more, &viewer_more);
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(viewer_download) = viewer_download.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(path) = viewer_download.path.borrow().as_deref() {
|
|
||||||
let name = viewer_download.name.borrow();
|
|
||||||
save_photo_to_disk(
|
|
||||||
&ui_download,
|
|
||||||
&w_download,
|
|
||||||
path,
|
|
||||||
name.as_deref().unwrap_or("photo"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let w_ext = window.downgrade();
|
|
||||||
let viewer_ext = Rc::downgrade(&viewer);
|
|
||||||
open_ext_btn.connect_clicked(move |_| {
|
|
||||||
let Some(w_ext) = w_ext.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(viewer_ext) = viewer_ext.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(path) = viewer_ext.path.borrow().as_deref() {
|
|
||||||
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1680,13 +1881,22 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
return glib::Propagation::Proceed;
|
return glib::Propagation::Proceed;
|
||||||
};
|
};
|
||||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||||
|
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
||||||
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
||||||
|
if playing_video
|
||||||
|
&& let Some(name) = key.name()
|
||||||
|
&& player_key(&viewer_key.video, &name, state)
|
||||||
|
{
|
||||||
|
return glib::Propagation::Stop;
|
||||||
|
}
|
||||||
match key.name().as_deref() {
|
match key.name().as_deref() {
|
||||||
Some("space") if playing_video => {
|
Some("space") if playing_video => {
|
||||||
if let Some(stream) = viewer_key.video.media_stream() {
|
if let Some(stream) = viewer_key.video.media_stream() {
|
||||||
stream.set_playing(!stream.is_playing());
|
stream.set_playing(!stream.is_playing());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Alt+Left is Back everywhere else in the app.
|
||||||
|
Some("Left") if alt => w_key.close(),
|
||||||
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
|
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
|
||||||
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
|
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
|
||||||
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
|
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
|
||||||
@@ -1695,30 +1905,20 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
|
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
|
||||||
Some("0" | "KP_0") => reset_zoom(&viewer_key),
|
Some("0" | "KP_0") => reset_zoom(&viewer_key),
|
||||||
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
|
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
|
||||||
Some("F5") => viewer_key
|
Some("F5") => toggle_slideshow(&ui_key, &viewer_key),
|
||||||
.slideshow
|
|
||||||
.set_active(!viewer_key.slideshow.is_active()),
|
|
||||||
// Escape ends a slideshow before it closes anything.
|
// Escape ends a slideshow before it closes anything.
|
||||||
Some("Escape") if viewer_key.slideshow.is_active() => {
|
Some("Escape") if viewer_key.slideshow.get() => stop_slideshow(&viewer_key),
|
||||||
viewer_key.slideshow.set_active(false)
|
Some("F10" | "Menu") => open_viewer_menu(&ui_key, &viewer_key),
|
||||||
}
|
|
||||||
Some("i") => viewer_key
|
Some("i") => viewer_key
|
||||||
.info_toggle
|
.info_toggle
|
||||||
.set_active(!viewer_key.info_toggle.is_active()),
|
.set_active(!viewer_key.info_toggle.is_active()),
|
||||||
Some("f" | "F11") => {
|
Some("f" | "F11") => toggle_fullscreen(&w_key),
|
||||||
if w_key.is_fullscreen() {
|
Some("Delete" | "KP_Delete") => trash_shown_photo(&ui_key, &viewer_key),
|
||||||
w_key.unfullscreen();
|
|
||||||
} else {
|
|
||||||
w_key.fullscreen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some("Delete" | "KP_Delete") => {
|
|
||||||
let uid = viewer_key.uid.borrow().clone();
|
|
||||||
w_key.close();
|
|
||||||
trash_photos(&ui_key, vec![uid], false);
|
|
||||||
}
|
|
||||||
Some("Escape" | "q") => w_key.close(),
|
Some("Escape" | "q") => w_key.close(),
|
||||||
Some("w") if ctrl => w_key.close(),
|
Some("w") if ctrl => w_key.close(),
|
||||||
|
Some("c") if ctrl && viewer_key.path.borrow().is_some() && !playing_video => {
|
||||||
|
copy_shown_photo(&ui_key, &viewer_key)
|
||||||
|
}
|
||||||
_ => return glib::Propagation::Proceed,
|
_ => return glib::Propagation::Proceed,
|
||||||
}
|
}
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
@@ -1729,6 +1929,112 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
window
|
window
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The ⋮ menu: what the lightbox can do with the photo on screen besides
|
||||||
|
/// starring it and showing its details. Built when it opens, so it offers
|
||||||
|
/// only what applies to this photo.
|
||||||
|
fn open_viewer_menu(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
let weak = Rc::downgrade(viewer);
|
||||||
|
let with_viewer = move |run: fn(&Rc<Ui>, &Rc<Viewer>), ui: &Rc<Ui>| {
|
||||||
|
let (ui, weak) = (ui.clone(), weak.clone());
|
||||||
|
move || {
|
||||||
|
if let Some(viewer) = weak.upgrade() {
|
||||||
|
run(&ui, &viewer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
menu.item(&gettext("Slideshow"), with_viewer(toggle_slideshow, ui));
|
||||||
|
if viewer.media.visible_child_name().as_deref() == Some("video") {
|
||||||
|
let video = viewer.video.clone();
|
||||||
|
menu.toggle(&gettext("Repeat"), video.is_loop(), move |on| {
|
||||||
|
video.set_loop(on)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
menu.section();
|
||||||
|
// These need the full-size file, which may still be downloading.
|
||||||
|
if viewer.path.borrow().is_some() {
|
||||||
|
let still = viewer.media.visible_child_name().as_deref() == Some("photo");
|
||||||
|
if still {
|
||||||
|
// Translators: menu item that puts the photo on the clipboard as an image.
|
||||||
|
menu.item(&gettext("Copy Image"), with_viewer(copy_shown_photo, ui));
|
||||||
|
}
|
||||||
|
menu.item(&gettext("Save a Copy…"), with_viewer(save_shown_photo, ui));
|
||||||
|
menu.item(&gettext("Open With…"), with_viewer(open_shown_photo, ui));
|
||||||
|
}
|
||||||
|
if photo_shareable(ui) {
|
||||||
|
menu.item(&gettext("Share…"), with_viewer(share_shown_photo, ui));
|
||||||
|
}
|
||||||
|
// A RAW is not on the timeline, and an album holds the shot it belongs to.
|
||||||
|
if find_photo_index(&ui.gallery.model, &viewer.uid.borrow()).is_some() {
|
||||||
|
menu.item(
|
||||||
|
&gettext("Add to Album…"),
|
||||||
|
with_viewer(add_shown_photo_to_album, ui),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if viewer.coords.borrow().is_some() {
|
||||||
|
menu.item(&gettext("Show on Map"), with_viewer(show_photo_on_map, ui));
|
||||||
|
}
|
||||||
|
menu.section();
|
||||||
|
menu.item(
|
||||||
|
&gettext("Move to Trash…"),
|
||||||
|
with_viewer(trash_shown_photo, ui),
|
||||||
|
);
|
||||||
|
|
||||||
|
let button = &viewer.more;
|
||||||
|
let popover = menu.popup_at(
|
||||||
|
button,
|
||||||
|
f64::from(button.width()) / 2.0,
|
||||||
|
f64::from(button.height()),
|
||||||
|
);
|
||||||
|
viewer.menu_open.set(true);
|
||||||
|
let weak = Rc::downgrade(viewer);
|
||||||
|
popover.connect_closed(move |_| {
|
||||||
|
if let Some(viewer) = weak.upgrade() {
|
||||||
|
viewer.menu_open.set(false);
|
||||||
|
// A slideshow started from the menu keeps the controls hidden.
|
||||||
|
if !viewer.slideshow.get() {
|
||||||
|
show_chrome(&viewer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
if let Some(path) = viewer.path.borrow().as_deref() {
|
||||||
|
let name = viewer.name.borrow();
|
||||||
|
save_photo_to_disk(viewer, path, name.as_deref().unwrap_or("photo"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
if let Some(path) = viewer.path.borrow().as_deref() {
|
||||||
|
open_with(&viewer.window, path, viewer.name.borrow().as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_shown_photo_to_album(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
let uid = viewer.uid.borrow().clone();
|
||||||
|
prompt_add_to_album(ui, vec![uid], Some(viewer.window.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the lightbox and show where the photo was taken.
|
||||||
|
fn show_photo_on_map(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
let coords = *viewer.coords.borrow();
|
||||||
|
if let Some((lat, lon)) = coords {
|
||||||
|
viewer.window.close();
|
||||||
|
show_on_map(ui, lat, lon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask, then move the photo on screen to the trash. Confirmed, the lightbox
|
||||||
|
/// closes first: what it was showing is gone, and leaving it open on a
|
||||||
|
/// trashed photo invites a second Delete on something the user cannot see.
|
||||||
|
fn trash_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
let uid = viewer.uid.borrow().clone();
|
||||||
|
let window = viewer.window.clone();
|
||||||
|
ask_trash_photos(ui, vec![uid], Some(window.clone()), move || window.close());
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
|
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
|
||||||
/// rather than the backdrop around it. [`gtk4::ContentFit::Contain`] centres the
|
/// rather than the backdrop around it. [`gtk4::ContentFit::Contain`] centres the
|
||||||
/// image and letterboxes the rest, so the drawn rectangle is the widget scaled
|
/// image and letterboxes the rest, so the drawn rectangle is the widget scaled
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ pub(crate) struct GalleryState {
|
|||||||
pub(crate) burst: Cell<bool>,
|
pub(crate) burst: Cell<bool>,
|
||||||
/// Says a Google Photos import is running, with a way to its page.
|
/// Says a Google Photos import is running, with a way to its page.
|
||||||
pub(crate) import_banner: adw::Banner,
|
pub(crate) import_banner: adw::Banner,
|
||||||
|
/// The bar that counts uploads from this computer through, under the
|
||||||
|
/// header while any run.
|
||||||
|
pub(crate) upload_bar: UploadBar,
|
||||||
pub(crate) upload: adw::SplitButton,
|
pub(crate) upload: adw::SplitButton,
|
||||||
/// Upload / Import offered on the *empty timeline* status page, so a fresh
|
/// Upload / Import offered on the *empty timeline* status page, so a fresh
|
||||||
/// account is a place to start rather than a dead end. Hidden on every other
|
/// account is a place to start rather than a dead end. Hidden on every other
|
||||||
@@ -157,10 +160,11 @@ pub(crate) struct GalleryState {
|
|||||||
/// Photos the daemon reported as having no thumbnail at all, so a tile that
|
/// Photos the daemon reported as having no thumbnail at all, so a tile that
|
||||||
/// can never be filled isn't requested again on every scroll past it.
|
/// can never be filled isn't requested again on every scroll past it.
|
||||||
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
|
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
|
||||||
/// Tiles on screen still waiting for their thumbnail, by uid. Populated as
|
/// Pictures on screen still waiting for their thumbnail, by uid. Populated
|
||||||
/// sections are bound, drained as batches land, cleared on unbind — so a
|
/// as they are bound, drained as batches land, cleared on unbind — so a
|
||||||
/// batch only ever paints a widget that is still showing that photo.
|
/// batch only ever paints a widget that is still showing that photo. One
|
||||||
pub(crate) thumb_wanted: RefCell<HashMap<String, gtk4::Picture>>,
|
/// photo can be waited for twice: by its tile and by the lightbox's strip.
|
||||||
|
pub(crate) thumb_wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
|
||||||
/// Uids queued for the next [`Request::PhotoThumbs`] batch, and whether a
|
/// Uids queued for the next [`Request::PhotoThumbs`] batch, and whether a
|
||||||
/// batch is already in flight (only one at a time, so a long scroll can't
|
/// batch is already in flight (only one at a time, so a long scroll can't
|
||||||
/// stack requests on the daemon).
|
/// stack requests on the daemon).
|
||||||
@@ -243,6 +247,27 @@ impl GalleryState {
|
|||||||
pub(crate) fn in_collection(&self) -> bool {
|
pub(crate) fn in_collection(&self) -> bool {
|
||||||
self.album.borrow().is_some() || self.place.borrow().is_some()
|
self.album.borrow().is_some() || self.place.borrow().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Have `picture` painted with the thumbnail of `uid` once it lands.
|
||||||
|
pub(crate) fn await_thumb(&self, uid: &str, picture: >k4::Picture) {
|
||||||
|
let mut wanted = self.thumb_wanted.borrow_mut();
|
||||||
|
let waiting = wanted.entry(uid.to_string()).or_default();
|
||||||
|
if !waiting.contains(picture) {
|
||||||
|
waiting.push(picture.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop waiting for `uid` in the pictures `drop` picks, because they show
|
||||||
|
/// another photo now.
|
||||||
|
pub(crate) fn release_thumb(&self, uid: &str, drop: impl Fn(>k4::Picture) -> bool) {
|
||||||
|
let mut wanted = self.thumb_wanted.borrow_mut();
|
||||||
|
if let Some(waiting) = wanted.get_mut(uid) {
|
||||||
|
waiting.retain(|picture| !drop(picture));
|
||||||
|
if waiting.is_empty() {
|
||||||
|
wanted.remove(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||||
@@ -456,6 +481,9 @@ pub(crate) struct GalleryWidgets {
|
|||||||
pub(crate) scrubber: Scrubber,
|
pub(crate) scrubber: Scrubber,
|
||||||
/// Says a Google Photos import is running, with a way to its page.
|
/// Says a Google Photos import is running, with a way to its page.
|
||||||
pub(crate) import_banner: adw::Banner,
|
pub(crate) import_banner: adw::Banner,
|
||||||
|
/// The bar that counts uploads from this computer through, under the
|
||||||
|
/// header while any run.
|
||||||
|
pub(crate) upload_bar: UploadBar,
|
||||||
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,
|
||||||
@@ -881,6 +909,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
.action_name("win.show-import")
|
.action_name("win.show-import")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
let upload_bar = UploadBar::new();
|
||||||
|
|
||||||
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);
|
||||||
@@ -891,6 +921,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
|
|
||||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||||
body.append(&import_banner);
|
body.append(&import_banner);
|
||||||
|
body.append(&upload_bar.revealer);
|
||||||
body.append(&inner);
|
body.append(&inner);
|
||||||
// The title slot holds the view switch on the page itself, the album's or
|
// The title slot holds the view switch on the page itself, the album's or
|
||||||
// place's name inside one, and the count in selection mode (see
|
// place's name inside one, and the count in selection mode (see
|
||||||
@@ -921,6 +952,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
pager,
|
pager,
|
||||||
scrubber,
|
scrubber,
|
||||||
import_banner,
|
import_banner,
|
||||||
|
upload_bar,
|
||||||
list,
|
list,
|
||||||
scroll,
|
scroll,
|
||||||
retry,
|
retry,
|
||||||
@@ -1314,14 +1346,10 @@ pub(crate) fn wire_gallery(
|
|||||||
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
|
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
|
||||||
&& let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::<GalleryRow>()
|
&& let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::<GalleryRow>()
|
||||||
{
|
{
|
||||||
let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut();
|
|
||||||
for tile in tiles {
|
for tile in tiles {
|
||||||
if wanted
|
ui_unbind
|
||||||
.get(&tile.photo.uid)
|
.gallery
|
||||||
.is_some_and(|picture| picture.is_ancestor(&row_box))
|
.release_thumb(&tile.photo.uid, |picture| picture.is_ancestor(&row_box));
|
||||||
{
|
|
||||||
wanted.remove(&tile.photo.uid);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1366,6 +1394,13 @@ pub(crate) fn wire_gallery(
|
|||||||
sync_float_heading(&ui_scroll);
|
sync_float_heading(&ui_scroll);
|
||||||
ui_scroll.gallery.scrubber.flash();
|
ui_scroll.gallery.scrubber.flash();
|
||||||
});
|
});
|
||||||
|
// Zooming out or making the window taller can leave every loaded photo on
|
||||||
|
// one screen. Nothing scrolls then, so the scrolling above never asks for
|
||||||
|
// the next page: ask whenever the content shrinks under the viewport.
|
||||||
|
let ui_fill = ui.clone();
|
||||||
|
scroll
|
||||||
|
.vadjustment()
|
||||||
|
.connect_changed(move |_| fill_viewport(&ui_fill));
|
||||||
wire_scrubber(ui);
|
wire_scrubber(ui);
|
||||||
wire_drag_select(ui);
|
wire_drag_select(ui);
|
||||||
|
|
||||||
@@ -1441,7 +1476,7 @@ pub(crate) fn wire_gallery(
|
|||||||
let ui_album = ui.clone();
|
let ui_album = ui.clone();
|
||||||
selection.album.connect_clicked(move |_| {
|
selection.album.connect_clicked(move |_| {
|
||||||
let uids: Vec<String> = ui_album.gallery.selected.borrow().iter().cloned().collect();
|
let uids: Vec<String> = ui_album.gallery.selected.borrow().iter().cloned().collect();
|
||||||
prompt_add_to_album(&ui_album, uids);
|
prompt_add_to_album(&ui_album, uids, None);
|
||||||
});
|
});
|
||||||
let ui_remove = ui.clone();
|
let ui_remove = ui.clone();
|
||||||
selection.remove.connect_clicked(move |_| {
|
selection.remove.connect_clicked(move |_| {
|
||||||
@@ -1495,9 +1530,11 @@ pub(crate) fn wire_gallery(
|
|||||||
gtk4::gdk::DragAction::COPY,
|
gtk4::gdk::DragAction::COPY,
|
||||||
);
|
);
|
||||||
let ui_drop = ui.clone();
|
let ui_drop = ui.clone();
|
||||||
drop.connect_accept(move |_, _| {
|
drop.connect_accept(move |_, drop| {
|
||||||
let gallery = &ui_drop.gallery;
|
let gallery = &ui_drop.gallery;
|
||||||
!gallery.in_collection()
|
// Photos dragged out of the timeline are already in it.
|
||||||
|
drop.drag().is_none()
|
||||||
|
&& !gallery.in_collection()
|
||||||
&& gallery.view_switch.active() == VIEW_TIMELINE
|
&& gallery.view_switch.active() == VIEW_TIMELINE
|
||||||
&& gallery.library.get()
|
&& gallery.library.get()
|
||||||
&& *ui_drop.mounted.borrow()
|
&& *ui_drop.mounted.borrow()
|
||||||
@@ -1895,6 +1932,86 @@ pub(crate) fn copy_without_overwrite(
|
|||||||
unreachable!("some numbered name is free")
|
unreachable!("some numbered name is free")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How far the uploads from this computer have come: a count and a bar, so a
|
||||||
|
/// drop of a few hundred photos shows it is moving. Batches started while one
|
||||||
|
/// runs join its count.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct UploadBar {
|
||||||
|
pub(crate) revealer: gtk4::Revealer,
|
||||||
|
label: gtk4::Label,
|
||||||
|
progress: gtk4::ProgressBar,
|
||||||
|
/// Uploads finished, and uploads asked for, since the bar last emptied.
|
||||||
|
count: Rc<Cell<(usize, usize)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UploadBar {
|
||||||
|
fn new() -> Self {
|
||||||
|
let label = gtk4::Label::builder()
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
|
.build();
|
||||||
|
let progress = gtk4::ProgressBar::builder().hexpand(true).build();
|
||||||
|
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||||
|
content.set_margin_top(10);
|
||||||
|
content.set_margin_bottom(4);
|
||||||
|
content.set_margin_start(16);
|
||||||
|
content.set_margin_end(16);
|
||||||
|
content.append(&label);
|
||||||
|
content.append(&progress);
|
||||||
|
let revealer = gtk4::Revealer::builder()
|
||||||
|
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
||||||
|
.child(&content)
|
||||||
|
.build();
|
||||||
|
Self {
|
||||||
|
revealer,
|
||||||
|
label,
|
||||||
|
progress,
|
||||||
|
count: Rc::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count `n` more uploads in.
|
||||||
|
pub(crate) fn add(&self, n: usize) {
|
||||||
|
let (done, total) = self.count.get();
|
||||||
|
self.count.set((done, total + n));
|
||||||
|
self.paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count one upload as finished, whether or not it went through.
|
||||||
|
pub(crate) fn finish(&self) {
|
||||||
|
let (done, total) = self.count.get();
|
||||||
|
let done = (done + 1).min(total);
|
||||||
|
if done == total {
|
||||||
|
self.count.set((0, 0));
|
||||||
|
self.revealer.set_reveal_child(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.count.set((done, total));
|
||||||
|
self.paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(&self) {
|
||||||
|
let (done, total) = self.count.get();
|
||||||
|
if total == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.label.set_label(&upload_progress_text(done, total));
|
||||||
|
self.progress.set_fraction(done as f64 / total as f64);
|
||||||
|
self.revealer.set_reveal_child(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Uploading 3 of 12 items…": the upload under way is number `done + 1`.
|
||||||
|
pub(crate) fn upload_progress_text(done: usize, total: usize) -> String {
|
||||||
|
// Translators: progress above the photo timeline while photos upload; {position} is the upload under way, {n} how many there are.
|
||||||
|
ngettext_f(
|
||||||
|
"Uploading {position} of {n} item…",
|
||||||
|
"Uploading {position} of {n} items…",
|
||||||
|
total as u64,
|
||||||
|
&[("position", &thousands(done + 1))],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Upload photos and videos from this computer into the timeline, one after
|
/// Upload photos and videos from this computer into the timeline, one after
|
||||||
/// the other, then reload it once.
|
/// the other, then reload it once.
|
||||||
pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
||||||
@@ -1909,15 +2026,7 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
|||||||
let total = uploads.len();
|
let total = uploads.len();
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
if total > 1 {
|
if total > 1 {
|
||||||
toast(
|
ui.gallery.upload_bar.add(total);
|
||||||
ui,
|
|
||||||
&ngettext_f(
|
|
||||||
"Uploading {n} item…",
|
|
||||||
"Uploading {n} items…",
|
|
||||||
total as u64,
|
|
||||||
&[],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
@@ -1955,6 +2064,9 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if total > 1 {
|
||||||
|
ui.gallery.upload_bar.finish();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
if uploaded > 0 {
|
if uploaded > 0 {
|
||||||
@@ -2363,6 +2475,23 @@ impl TileWidget {
|
|||||||
});
|
});
|
||||||
button.add_controller(context);
|
button.add_controller(context);
|
||||||
|
|
||||||
|
// A picked photo drags out, with the rest of the selection, into a
|
||||||
|
// file manager or any app that takes files. A drag from anywhere else
|
||||||
|
// selects instead (see `wire_drag_select`).
|
||||||
|
let drag_out = gtk4::DragSource::new();
|
||||||
|
drag_out.set_actions(gtk4::gdk::DragAction::COPY);
|
||||||
|
let ui_drag = ui.clone();
|
||||||
|
let drag_uid = uid.clone();
|
||||||
|
drag_out.connect_prepare(move |source, x, y| {
|
||||||
|
let photos = dragged_photos(&ui_drag, &drag_uid()?)?;
|
||||||
|
if let Some(tile) = source.widget() {
|
||||||
|
let icon = gtk4::WidgetPaintable::new(Some(&tile));
|
||||||
|
source.set_icon(Some(&icon), x as i32, y as i32);
|
||||||
|
}
|
||||||
|
Some(PhotoDrag::new(ui_drag.dirs.control_socket(), photos).upcast())
|
||||||
|
});
|
||||||
|
button.add_controller(drag_out);
|
||||||
|
|
||||||
// A tile opens in the in-app lightbox, which plays a video in place.
|
// A tile opens in the in-app lightbox, which plays a video in place.
|
||||||
// In selection mode a tile picks instead of opening: the lightbox is
|
// In selection mode a tile picks instead of opening: the lightbox is
|
||||||
// one Escape away.
|
// one Escape away.
|
||||||
@@ -2455,13 +2584,28 @@ impl TileWidget {
|
|||||||
let Some(photo) = self.photo.borrow_mut().take() else {
|
let Some(photo) = self.photo.borrow_mut().take() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut wanted = ui.gallery.thumb_wanted.borrow_mut();
|
ui.gallery
|
||||||
if wanted.get(&photo.uid) == Some(&self.picture) {
|
.release_thumb(&photo.uid, |picture| picture == &self.picture);
|
||||||
wanted.remove(&photo.uid);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The photos a drag that starts on `uid` carries out: the whole selection,
|
||||||
|
/// in timeline order, as uid and name. `None` unless `uid` is picked.
|
||||||
|
fn dragged_photos(ui: &Rc<Ui>, uid: &str) -> Option<Vec<(String, Option<String>)>> {
|
||||||
|
let selected = ui.gallery.selected.borrow();
|
||||||
|
if !ui.gallery.selecting.get() || !selected.contains(uid) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let model = &ui.gallery.model;
|
||||||
|
let photos: Vec<(String, Option<String>)> = (0..model.n_items())
|
||||||
|
.filter_map(|index| model.item(index).and_downcast::<BoxedAnyObject>())
|
||||||
|
.map(|boxed| boxed.borrow::<PhotoItem>().clone())
|
||||||
|
.filter(|photo| selected.contains(&photo.uid))
|
||||||
|
.map(|photo| (photo.uid, photo.name))
|
||||||
|
.collect();
|
||||||
|
(!photos.is_empty()).then_some(photos)
|
||||||
|
}
|
||||||
|
|
||||||
/// Add or remove one CSS class, for widgets that are rebound rather than
|
/// Add or remove one CSS class, for widgets that are rebound rather than
|
||||||
/// rebuilt.
|
/// rebuilt.
|
||||||
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
|
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
|
||||||
@@ -2540,6 +2684,9 @@ struct DragSelect {
|
|||||||
pointer: (f64, f64),
|
pointer: (f64, f64),
|
||||||
/// The edge autoscroll, while the pointer is near the top or bottom.
|
/// The edge autoscroll, while the pointer is near the top or bottom.
|
||||||
scroll: Option<glib::SourceId>,
|
scroll: Option<glib::SourceId>,
|
||||||
|
/// The drag started on a picked photo, so it carries the selection out
|
||||||
|
/// of the app rather than selecting.
|
||||||
|
out: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dragging across the timeline selects every photo from where the drag
|
/// Dragging across the timeline selects every photo from where the drag
|
||||||
@@ -2556,21 +2703,33 @@ fn wire_drag_select(ui: &Rc<Ui>) {
|
|||||||
let state = Rc::new(RefCell::new(DragSelect::default()));
|
let state = Rc::new(RefCell::new(DragSelect::default()));
|
||||||
|
|
||||||
let state_begin = state.clone();
|
let state_begin = state.clone();
|
||||||
drag.connect_drag_begin(move |_, x, y| {
|
let ui_begin = ui.clone();
|
||||||
|
drag.connect_drag_begin(move |gesture, x, y| {
|
||||||
let mut drag = state_begin.borrow_mut();
|
let mut drag = state_begin.borrow_mut();
|
||||||
if let Some(source) = drag.scroll.take() {
|
if let Some(source) = drag.scroll.take() {
|
||||||
source.remove();
|
source.remove();
|
||||||
}
|
}
|
||||||
|
let out = ui_begin.gallery.selecting.get()
|
||||||
|
&& tile_at(&ui_begin, x, y)
|
||||||
|
.is_some_and(|uid| ui_begin.gallery.selected.borrow().contains(&uid));
|
||||||
*drag = DragSelect {
|
*drag = DragSelect {
|
||||||
start: (x, y),
|
start: (x, y),
|
||||||
pointer: (x, y),
|
pointer: (x, y),
|
||||||
|
out,
|
||||||
..DragSelect::default()
|
..DragSelect::default()
|
||||||
};
|
};
|
||||||
|
// Left to the tile's own drag source.
|
||||||
|
if out {
|
||||||
|
gesture.set_state(gtk4::EventSequenceState::Denied);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_update = ui.clone();
|
let ui_update = ui.clone();
|
||||||
let state_update = state.clone();
|
let state_update = state.clone();
|
||||||
drag.connect_drag_update(move |gesture, dx, dy| {
|
drag.connect_drag_update(move |gesture, dx, dy| {
|
||||||
|
if state_update.borrow().out {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let (sx, sy) = state_update.borrow().start;
|
let (sx, sy) = state_update.borrow().start;
|
||||||
state_update.borrow_mut().pointer = (sx + dx, sy + dy);
|
state_update.borrow_mut().pointer = (sx + dx, sy + dy);
|
||||||
if !state_update.borrow().active {
|
if !state_update.borrow().active {
|
||||||
@@ -2764,6 +2923,18 @@ pub(crate) fn delete_selected(ui: &Rc<Ui>) {
|
|||||||
|
|
||||||
/// Confirm, then move the photos behind `uids` to Proton trash.
|
/// Confirm, then move the photos behind `uids` to Proton trash.
|
||||||
pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
||||||
|
ask_trash_photos(ui, uids, None, || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`confirm_trash_photos`] over `parent` rather than the main window, for
|
||||||
|
/// the photo viewer, which covers it. `before` runs once the user confirms,
|
||||||
|
/// before the photos go.
|
||||||
|
pub(crate) fn ask_trash_photos(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
uids: Vec<String>,
|
||||||
|
parent: Option<gtk4::Window>,
|
||||||
|
before: impl Fn() + 'static,
|
||||||
|
) {
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2803,10 +2974,11 @@ pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
|||||||
dialog.set_default_response(Some("cancel"));
|
dialog.set_default_response(Some("cancel"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
|
|
||||||
let win = ui_window(ui);
|
let win = parent.or_else(|| ui_window(ui));
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
dialog.connect_response(None, move |_, response| {
|
dialog.connect_response(None, move |_, response| {
|
||||||
if response == "trash" {
|
if response == "trash" {
|
||||||
|
before();
|
||||||
trash_photos(&ui, uids.clone(), false);
|
trash_photos(&ui, uids.clone(), false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2951,10 +3123,7 @@ pub(crate) fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.gallery
|
ui.gallery.await_thumb(&photo.uid, picture);
|
||||||
.thumb_wanted
|
|
||||||
.borrow_mut()
|
|
||||||
.insert(photo.uid.clone(), picture.clone());
|
|
||||||
|
|
||||||
match photo.thumb_path.as_deref() {
|
match photo.thumb_path.as_deref() {
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
@@ -3173,7 +3342,8 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
ui.store_texture(&uid, texture.clone());
|
ui.store_texture(&uid, texture.clone());
|
||||||
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
|
let waiting = ui.gallery.thumb_wanted.borrow_mut().remove(&uid);
|
||||||
|
for picture in waiting.into_iter().flatten() {
|
||||||
picture.set_paintable(Some(&texture));
|
picture.set_paintable(Some(&texture));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3927,17 +4097,32 @@ fn continue_jump(ui: &Rc<Ui>) {
|
|||||||
/// Load the next page when the timeline does not fill the window yet: with no
|
/// Load the next page when the timeline does not fill the window yet: with no
|
||||||
/// scrollbar there is no scrolling to trigger it.
|
/// scrollbar there is no scrolling to trigger it.
|
||||||
fn fill_viewport(ui: &Rc<Ui>) {
|
fn fill_viewport(ui: &Rc<Ui>) {
|
||||||
|
if !ui.gallery.has_more.get() || ui.gallery.loading.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::idle_add_local_once(move || {
|
glib::idle_add_local_once(move || {
|
||||||
let Some(adj) = ui.gallery.list.vadjustment() else {
|
let Some(adj) = ui.gallery.list.vadjustment() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if ui.gallery.has_more.get() && adj.upper() <= adj.page_size() + 1.0 {
|
let timeline = ui.gallery.content.visible_child_name().as_deref() == Some("timeline");
|
||||||
|
if timeline
|
||||||
|
&& ui.gallery.has_more.get()
|
||||||
|
&& adj.page_size() > 0.0
|
||||||
|
&& viewport_unfilled(adj.upper(), adj.page_size())
|
||||||
|
{
|
||||||
load_gallery(&ui, true);
|
load_gallery(&ui, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether content `upper` px tall leaves a viewport `page_size` px tall short
|
||||||
|
/// of a screen and a half to scroll through, the margin the scrolling pager
|
||||||
|
/// keeps. Below it the pager would never fire.
|
||||||
|
pub(crate) fn viewport_unfilled(upper: f64, page_size: f64) -> bool {
|
||||||
|
upper <= page_size * 2.5 + 1.0
|
||||||
|
}
|
||||||
|
|
||||||
/// Select every photo loaded so far (Ctrl+A).
|
/// Select every photo loaded so far (Ctrl+A).
|
||||||
pub(crate) fn select_all_photos(ui: &Rc<Ui>) {
|
pub(crate) fn select_all_photos(ui: &Rc<Ui>) {
|
||||||
set_selection_mode(ui, true);
|
set_selection_mode(ui, true);
|
||||||
@@ -3979,8 +4164,18 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: >k4::Button, x: f64
|
|||||||
menu.section();
|
menu.section();
|
||||||
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
menu.item(&gettext("Add to Album…"), move || {
|
menu.item(&gettext("Add to Album…"), move || {
|
||||||
prompt_add_to_album(&ui_c, vec![uid.clone()])
|
prompt_add_to_album(&ui_c, vec![uid.clone()], None)
|
||||||
});
|
});
|
||||||
|
if photo_shareable(ui) {
|
||||||
|
let (ui_c, uid, name) = (
|
||||||
|
ui.clone(),
|
||||||
|
photo.uid.clone(),
|
||||||
|
photo.name.clone().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
menu.item(&gettext("Share…"), move || {
|
||||||
|
share_photo(&ui_c, uid.clone(), name.clone(), None)
|
||||||
|
});
|
||||||
|
}
|
||||||
let open_album = ui.gallery.album.borrow().clone();
|
let open_album = ui.gallery.album.borrow().clone();
|
||||||
if let Some(album) = open_album.filter(|album| !album.shared) {
|
if let Some(album) = open_album.filter(|album| !album.shared) {
|
||||||
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
@@ -4032,6 +4227,27 @@ fn set_photo_favorite(ui: &Rc<Ui>, uid: String, favorite: bool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the photos on show are the user's own to share: everything but an
|
||||||
|
/// album someone else shared, whose photos live on that person's volume.
|
||||||
|
pub(crate) fn photo_shareable(ui: &Ui) -> bool {
|
||||||
|
!ui.gallery
|
||||||
|
.album
|
||||||
|
.borrow()
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|album| album.shared)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the Share dialog for one photo, addressed by uid: the photos volume
|
||||||
|
/// has no path in any mount. Over `parent` when the lightbox is open.
|
||||||
|
pub(crate) fn share_photo(ui: &Rc<Ui>, uid: String, name: String, parent: Option<gtk4::Window>) {
|
||||||
|
let entry = DirEntry {
|
||||||
|
uid,
|
||||||
|
name,
|
||||||
|
..DirEntry::default()
|
||||||
|
};
|
||||||
|
open_share_dialog_over(ui, &entry, parent);
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the photo grid is the view the Photos page shows: an album or a
|
/// Whether the photo grid is the view the Photos page shows: an album or a
|
||||||
/// place, or the timeline itself. Under Albums or Places it is out of sight.
|
/// place, or the timeline itself. Under Albums or Places it is out of sight.
|
||||||
pub(crate) fn photo_grid_shown(ui: &Ui) -> bool {
|
pub(crate) fn photo_grid_shown(ui: &Ui) -> bool {
|
||||||
@@ -4286,24 +4502,13 @@ pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
|
|||||||
service_unreachable(ui, "gallery", gallery_status, |ui| load_gallery(ui, false));
|
service_unreachable(ui, "gallery", gallery_status, |ui| load_gallery(ui, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play a video with an external player. Prefers `mpv` — it sniffs the container
|
|
||||||
/// from the bytes, so the cache's extensionless blob plays fine, and it is the
|
|
||||||
/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the
|
|
||||||
/// user's default handler when mpv isn't installed.
|
|
||||||
pub(crate) fn play_external(path: &str) {
|
|
||||||
match Command::new("mpv").arg(path).spawn() {
|
|
||||||
Ok(child) => pdfs_core::opener::reap(child),
|
|
||||||
Err(_) => open_path(path),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX,
|
Grouping, PHOTOS_PAGE, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX,
|
||||||
TILE_RATIO_MIN, active_filters, copy_without_overwrite, empty_timeline_text, grouping_for,
|
TILE_RATIO_MIN, active_filters, copy_without_overwrite, empty_timeline_text, grouping_for,
|
||||||
memory_years, month_index, month_range, plan_grid, plan_justified, section_heading,
|
memory_years, month_index, month_range, plan_grid, plan_justified, section_heading,
|
||||||
tile_ratio,
|
tile_ratio, viewport_unfilled,
|
||||||
};
|
};
|
||||||
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth};
|
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth};
|
||||||
|
|
||||||
@@ -4426,6 +4631,18 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(&dir).unwrap();
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A first page zoomed all the way out fits on one screen, with nothing
|
||||||
|
/// to scroll: the next page has to load without a scroll asking for it.
|
||||||
|
#[test]
|
||||||
|
fn a_page_zoomed_out_onto_one_screen_asks_for_more() {
|
||||||
|
let (columns, size) = plan_grid(1580, ROW_MIN);
|
||||||
|
let rows = PHOTOS_PAGE.div_ceil(columns) as i32;
|
||||||
|
let height = f64::from(rows * (size + TILE_GAP) + 40);
|
||||||
|
assert!(viewport_unfilled(height, 880.0));
|
||||||
|
assert!(viewport_unfilled(880.0, 880.0));
|
||||||
|
assert!(!viewport_unfilled(880.0 * 3.0, 880.0));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zooming_in_puts_fewer_tiles_in_a_row() {
|
fn zooming_in_puts_fewer_tiles_in_a_row() {
|
||||||
assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0);
|
assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0);
|
||||||
@@ -4490,6 +4707,7 @@ mod tests {
|
|||||||
favorite: false,
|
favorite: false,
|
||||||
group_size: 1,
|
group_size: 1,
|
||||||
has_raw: false,
|
has_raw: false,
|
||||||
|
place_id: None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Newest first, the way the daemon sends them.
|
// Newest first, the way the daemon sends them.
|
||||||
|
|||||||
@@ -267,7 +267,12 @@ fn want_map_thumb(ui: &Rc<Ui>, photo: &PhotoItem) {
|
|||||||
}
|
}
|
||||||
// A town's card may be waiting for the same photo: share its picture
|
// A town's card may be waiting for the same photo: share its picture
|
||||||
// rather than take its place in the queue.
|
// rather than take its place in the queue.
|
||||||
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
let waiting = ui
|
||||||
|
.gallery
|
||||||
|
.thumb_wanted
|
||||||
|
.borrow()
|
||||||
|
.get(&photo.uid)
|
||||||
|
.and_then(|waiting| waiting.first().cloned());
|
||||||
let picture = waiting.unwrap_or_else(|| {
|
let picture = waiting.unwrap_or_else(|| {
|
||||||
let picture = gtk4::Picture::new();
|
let picture = gtk4::Picture::new();
|
||||||
want_thumb(ui, photo, &picture);
|
want_thumb(ui, photo, &picture);
|
||||||
@@ -362,7 +367,12 @@ fn show_spot_photos(ui: &Rc<Ui>, photos: Vec<(PlaceInfo, PhotoItem)>, (x, y): (f
|
|||||||
/// Show photo's thumbnail in `picture`, sharing one another widget is
|
/// Show photo's thumbnail in `picture`, sharing one another widget is
|
||||||
/// already waiting for.
|
/// already waiting for.
|
||||||
fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
||||||
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
let waiting = ui
|
||||||
|
.gallery
|
||||||
|
.thumb_wanted
|
||||||
|
.borrow()
|
||||||
|
.get(&photo.uid)
|
||||||
|
.and_then(|waiting| waiting.first().cloned());
|
||||||
match waiting {
|
match waiting {
|
||||||
Some(waiting) => {
|
Some(waiting) => {
|
||||||
waiting
|
waiting
|
||||||
|
|||||||
@@ -101,8 +101,14 @@ impl ActionMenu {
|
|||||||
|
|
||||||
/// Pop the menu up over `anchor` at `(x, y)` in its coordinates. The
|
/// Pop the menu up over `anchor` at `(x, y)` in its coordinates. The
|
||||||
/// popover removes itself once dismissed; it waits for an idle turn, so the
|
/// popover removes itself once dismissed; it waits for an idle turn, so the
|
||||||
/// item that closed it still finds its action.
|
/// item that closed it still finds its action. Returned for a caller that
|
||||||
pub(crate) fn popup_at(self, anchor: &impl IsA<gtk4::Widget>, x: f64, y: f64) {
|
/// has to know when it closes.
|
||||||
|
pub(crate) fn popup_at(
|
||||||
|
self,
|
||||||
|
anchor: &impl IsA<gtk4::Widget>,
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
) -> gtk4::PopoverMenu {
|
||||||
let (menu, group) = self.finish();
|
let (menu, group) = self.finish();
|
||||||
let anchor = anchor.as_ref();
|
let anchor = anchor.as_ref();
|
||||||
let host = popover_host(anchor);
|
let host = popover_host(anchor);
|
||||||
@@ -122,6 +128,7 @@ impl ActionMenu {
|
|||||||
glib::idle_add_local_once(move || p.unparent());
|
glib::idle_add_local_once(move || p.unparent());
|
||||||
});
|
});
|
||||||
popover.popup();
|
popover.popup();
|
||||||
|
popover
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A flat ⋮ button that opens this menu.
|
/// A flat ⋮ button that opens this menu.
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ pub(crate) mod crumb;
|
|||||||
pub(crate) mod details;
|
pub(crate) mod details;
|
||||||
pub(crate) mod file_list;
|
pub(crate) mod file_list;
|
||||||
pub(crate) mod menu;
|
pub(crate) mod menu;
|
||||||
|
pub(crate) mod photo_drag;
|
||||||
|
pub(crate) mod player;
|
||||||
pub(crate) mod scrubber;
|
pub(crate) mod scrubber;
|
||||||
pub(crate) mod share_dialog;
|
pub(crate) mod share_dialog;
|
||||||
pub(crate) mod thumbnails;
|
pub(crate) mod thumbnails;
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
//! Dragging photos out of the timeline into a file manager or another app.
|
||||||
|
//!
|
||||||
|
//! A photo is not a file anywhere the receiver can read until it has been
|
||||||
|
//! downloaded, and a drag has to start the moment the pointer moves. So the
|
||||||
|
//! drag offers a list of files without having them: [`PhotoDrag`] downloads
|
||||||
|
//! the photos only when the receiver asks for the list, which it does once the
|
||||||
|
//! photos are dropped on it, and then hands over copies under their own names.
|
||||||
|
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
use gtk4::gdk;
|
||||||
|
use gtk4::gdk::subclass::prelude::*;
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// The one format a drag of photos offers. GTK turns it into a file list for
|
||||||
|
/// a receiver in this app, and every file manager reads it.
|
||||||
|
const URI_LIST: &str = "text/uri-list";
|
||||||
|
|
||||||
|
/// How long the copies handed to a receiver are kept. The receiver copies
|
||||||
|
/// them on drop, so a day is far more than it needs.
|
||||||
|
const KEEP_DRAGGED: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
|
||||||
|
mod imp {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct PhotoDrag {
|
||||||
|
/// The photos dragged, as uid and Drive name.
|
||||||
|
pub(super) photos: RefCell<Vec<(String, Option<String>)>>,
|
||||||
|
pub(super) socket: RefCell<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for PhotoDrag {
|
||||||
|
const NAME: &'static str = "PdfsPhotoDrag";
|
||||||
|
type Type = super::PhotoDrag;
|
||||||
|
type ParentType = gdk::ContentProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for PhotoDrag {}
|
||||||
|
|
||||||
|
impl ContentProviderImpl for PhotoDrag {
|
||||||
|
fn formats(&self) -> gdk::ContentFormats {
|
||||||
|
gdk::ContentFormats::new(&[URI_LIST])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_mime_type_future(
|
||||||
|
&self,
|
||||||
|
mime_type: &str,
|
||||||
|
stream: &gio::OutputStream,
|
||||||
|
io_priority: glib::Priority,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<(), glib::Error>> + 'static>> {
|
||||||
|
let photos = self.photos.borrow().clone();
|
||||||
|
let socket = self.socket.borrow().clone();
|
||||||
|
let stream = stream.clone();
|
||||||
|
let wanted = mime_type == URI_LIST;
|
||||||
|
Box::pin(async move {
|
||||||
|
if !wanted {
|
||||||
|
return Err(glib::Error::new(
|
||||||
|
gio::IOErrorEnum::NotSupported,
|
||||||
|
"photos are offered as a list of files only",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let files = fetch_dragged(socket, photos).await?;
|
||||||
|
let list: String = files
|
||||||
|
.iter()
|
||||||
|
.map(|path| format!("{}\r\n", gio::File::for_path(path).uri()))
|
||||||
|
.collect();
|
||||||
|
stream
|
||||||
|
.write_all_future(list.into_bytes(), io_priority)
|
||||||
|
.await
|
||||||
|
.map_err(|(_, e)| e)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
/// The content of a drag of photos: their files, fetched when dropped.
|
||||||
|
pub struct PhotoDrag(ObjectSubclass<imp::PhotoDrag>)
|
||||||
|
@extends gdk::ContentProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PhotoDrag {
|
||||||
|
pub(crate) fn new(socket: PathBuf, photos: Vec<(String, Option<String>)>) -> Self {
|
||||||
|
let drag: Self = glib::Object::new();
|
||||||
|
*drag.imp().photos.borrow_mut() = photos;
|
||||||
|
*drag.imp().socket.borrow_mut() = socket;
|
||||||
|
drag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download each dragged photo and put a copy under its Drive name where the
|
||||||
|
/// receiver can read it. A photo that fails is left out; only when every one
|
||||||
|
/// fails does the drop fail.
|
||||||
|
async fn fetch_dragged(
|
||||||
|
socket: PathBuf,
|
||||||
|
photos: Vec<(String, Option<String>)>,
|
||||||
|
) -> Result<Vec<PathBuf>, glib::Error> {
|
||||||
|
let base = glib::user_cache_dir().join("pdfs-drag");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut last_error = None;
|
||||||
|
for (uid, name) in photos {
|
||||||
|
let rx = spawn_request(socket.clone(), Request::OpenPhoto { uid: uid.clone() });
|
||||||
|
let source = match rx.recv().await {
|
||||||
|
Ok(Ok(Response::FilePath { path })) => path,
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
last_error = Some(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
last_error = Some("the Proton Drive service didn't respond".to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let base = base.clone();
|
||||||
|
let copied = gio::spawn_blocking(move || {
|
||||||
|
prune_dragged(&base, SystemTime::now());
|
||||||
|
let name = name.unwrap_or_else(|| "photo".to_string());
|
||||||
|
hand_over(&base, Path::new(&source), &uid, &name)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match copied {
|
||||||
|
Ok(Ok(path)) => files.push(path),
|
||||||
|
Ok(Err(e)) => last_error = Some(e.to_string()),
|
||||||
|
Err(_) => last_error = Some("the copy was interrupted".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if files.is_empty() {
|
||||||
|
let message = last_error.unwrap_or_else(|| "nothing to drop".to_string());
|
||||||
|
tracing::warn!("dragging photos out failed: {message}");
|
||||||
|
return Err(glib::Error::new(gio::IOErrorEnum::Failed, &message));
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put the cached photo `source` at `base/<uid>/<name>`, where a receiver
|
||||||
|
/// copies it from.
|
||||||
|
///
|
||||||
|
/// A hard link when the cache is on the same file system, so a large video is
|
||||||
|
/// not copied twice; a copy otherwise. Not a symlink: a file manager copies a
|
||||||
|
/// symlink as a link, which would leave the user a link into the cache.
|
||||||
|
pub(crate) fn hand_over(
|
||||||
|
base: &Path,
|
||||||
|
source: &Path,
|
||||||
|
uid: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> std::io::Result<PathBuf> {
|
||||||
|
let file_name = Path::new(name)
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| std::io::Error::other("the photo has no usable name"))?;
|
||||||
|
let dir = base.join(uid.replace('/', "_"));
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
let dest = dir.join(file_name);
|
||||||
|
let _ = std::fs::remove_file(&dest);
|
||||||
|
if std::fs::hard_link(source, &dest).is_err() {
|
||||||
|
std::fs::copy(source, &dest)?;
|
||||||
|
}
|
||||||
|
Ok(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the copies handed over more than [`KEEP_DRAGGED`] before `now`.
|
||||||
|
pub(crate) fn prune_dragged(base: &Path, now: SystemTime) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(base) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let old = entry
|
||||||
|
.metadata()
|
||||||
|
.and_then(|meta| meta.modified())
|
||||||
|
.is_ok_and(|modified| {
|
||||||
|
now.duration_since(modified)
|
||||||
|
.is_ok_and(|age| age > KEEP_DRAGGED)
|
||||||
|
});
|
||||||
|
if old {
|
||||||
|
let _ = std::fs::remove_dir_all(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn scratch(name: &str) -> PathBuf {
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("pdfs-drag-test-{}-{name}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dragged_photo_is_handed_over_under_its_own_name() {
|
||||||
|
let dir = scratch("name");
|
||||||
|
let source = dir.join("0f3a9c");
|
||||||
|
std::fs::write(&source, b"jpeg bytes").unwrap();
|
||||||
|
let base = dir.join("out");
|
||||||
|
|
||||||
|
let path = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
|
||||||
|
assert_eq!(path, base.join("vol~link").join("IMG_0001.JPG"));
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"jpeg bytes");
|
||||||
|
assert!(!path.is_symlink());
|
||||||
|
|
||||||
|
// Dragged again, it replaces the copy rather than failing on it.
|
||||||
|
std::fs::write(&source, b"new bytes").unwrap();
|
||||||
|
let again = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
|
||||||
|
assert_eq!(std::fs::read(again).unwrap(), b"new bytes");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_cannot_climb_out_of_its_folder() {
|
||||||
|
let dir = scratch("climb");
|
||||||
|
let source = dir.join("blob");
|
||||||
|
std::fs::write(&source, b"x").unwrap();
|
||||||
|
let base = dir.join("out");
|
||||||
|
let path = hand_over(&base, &source, "u", "../../escape.jpg").unwrap();
|
||||||
|
assert_eq!(path, base.join("u").join("escape.jpg"));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_old_handovers_are_pruned() {
|
||||||
|
let dir = scratch("prune");
|
||||||
|
std::fs::create_dir_all(dir.join("fresh")).unwrap();
|
||||||
|
prune_dragged(&dir, SystemTime::now());
|
||||||
|
assert!(dir.join("fresh").exists());
|
||||||
|
prune_dragged(&dir, SystemTime::now() + KEEP_DRAGGED * 2);
|
||||||
|
assert!(!dir.join("fresh").exists());
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
//! Playing videos and music inside the app.
|
||||||
|
//!
|
||||||
|
//! The pieces both players share live here: the one My files opens a video or
|
||||||
|
//! a song in ([`open_media_player`]) and the Photos lightbox. A GTK
|
||||||
|
//! [`gtk4::Video`] brings play, pause, a seek bar and the volume; this adds
|
||||||
|
//! fullscreen, the keys every video player answers to, a repeat switch and a
|
||||||
|
//! volume that carries over from one video to the next.
|
||||||
|
|
||||||
|
use crate::activation::{mounted_path, present_within};
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// How far `J` and `L` (or Shift+←/→) jump.
|
||||||
|
const SEEK_LONG: i64 = 10;
|
||||||
|
|
||||||
|
/// How far ← and → jump in the My files player, where they don't move to
|
||||||
|
/// another photo.
|
||||||
|
const SEEK_SHORT: i64 = 5;
|
||||||
|
|
||||||
|
/// How much ↑ and ↓ change the volume in the My files player.
|
||||||
|
const VOLUME_STEP: f64 = 0.1;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// The volume and mute the user last set, applied to the next stream so
|
||||||
|
/// muting one video doesn't leave the next one blaring. Kept for the run
|
||||||
|
/// of the app only.
|
||||||
|
static LEVEL: Cell<Option<(f64, bool)>> = const { Cell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a jump of `delta_secs` from `now` lands, in microseconds, as a
|
||||||
|
/// [`gtk4::MediaStream`] counts. Kept inside the stream; a `duration` of 0
|
||||||
|
/// means the stream doesn't know its length yet, which bounds only the start.
|
||||||
|
pub(crate) fn seek_target(now: i64, duration: i64, delta_secs: i64) -> i64 {
|
||||||
|
let target = now
|
||||||
|
.saturating_add(delta_secs.saturating_mul(1_000_000))
|
||||||
|
.max(0);
|
||||||
|
if duration > 0 {
|
||||||
|
target.min(duration)
|
||||||
|
} else {
|
||||||
|
target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jump the stream `delta_secs` forward, or back when negative.
|
||||||
|
pub(crate) fn seek_by(video: >k4::Video, delta_secs: i64) {
|
||||||
|
if let Some(stream) = video.media_stream()
|
||||||
|
&& stream.is_seekable()
|
||||||
|
{
|
||||||
|
stream.seek(seek_target(
|
||||||
|
stream.timestamp(),
|
||||||
|
stream.duration(),
|
||||||
|
delta_secs,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play a paused stream, pause a playing one.
|
||||||
|
pub(crate) fn toggle_playing(video: >k4::Video) {
|
||||||
|
if let Some(stream) = video.media_stream() {
|
||||||
|
stream.set_playing(!stream.is_playing());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_muted(video: >k4::Video) {
|
||||||
|
if let Some(stream) = video.media_stream() {
|
||||||
|
stream.set_muted(!stream.is_muted());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn change_volume(video: >k4::Video, delta: f64) {
|
||||||
|
if let Some(stream) = video.media_stream() {
|
||||||
|
stream.set_muted(false);
|
||||||
|
stream.set_volume((stream.volume() + delta).clamp(0.0, 1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter fullscreen, or leave it.
|
||||||
|
pub(crate) fn toggle_fullscreen(window: >k4::Window) {
|
||||||
|
if window.is_fullscreen() {
|
||||||
|
window.unfullscreen();
|
||||||
|
} else {
|
||||||
|
window.fullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keys both players answer to the same way: `K` plays and pauses, `J` and
|
||||||
|
/// `L` (or Shift+← and Shift+→) jump ten seconds, `M` mutes. True when `key`
|
||||||
|
/// was one of them.
|
||||||
|
pub(crate) fn player_key(video: >k4::Video, key: &str, state: gtk4::gdk::ModifierType) -> bool {
|
||||||
|
let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
|
||||||
|
let plain = !state
|
||||||
|
.intersects(gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::ALT_MASK);
|
||||||
|
if !plain {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match key {
|
||||||
|
"k" | "K" => toggle_playing(video),
|
||||||
|
"j" | "J" => seek_by(video, -SEEK_LONG),
|
||||||
|
"l" | "L" => seek_by(video, SEEK_LONG),
|
||||||
|
"Left" if shift => seek_by(video, -SEEK_LONG),
|
||||||
|
"Right" if shift => seek_by(video, SEEK_LONG),
|
||||||
|
"m" | "M" => toggle_muted(video),
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `video` carry the volume over between streams and toggle `window`'s
|
||||||
|
/// fullscreen on a double-click.
|
||||||
|
pub(crate) fn wire_player(video: >k4::Video, window: >k4::Window) {
|
||||||
|
video.connect_media_stream_notify(|video| {
|
||||||
|
let Some(stream) = video.media_stream() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some((volume, muted)) = LEVEL.get() {
|
||||||
|
stream.set_volume(volume);
|
||||||
|
stream.set_muted(muted);
|
||||||
|
}
|
||||||
|
let remember = |stream: >k4::MediaStream| {
|
||||||
|
LEVEL.set(Some((stream.volume(), stream.is_muted())));
|
||||||
|
};
|
||||||
|
stream.connect_volume_notify(remember);
|
||||||
|
stream.connect_muted_notify(remember);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The controls' own buttons and seek bar claim their clicks, so this only
|
||||||
|
// sees a double-click on the picture.
|
||||||
|
let click = gtk4::GestureClick::new();
|
||||||
|
let window = window.downgrade();
|
||||||
|
click.connect_pressed(move |_, n_press, _, _| {
|
||||||
|
if n_press == 2
|
||||||
|
&& let Some(window) = window.upgrade()
|
||||||
|
{
|
||||||
|
toggle_fullscreen(&window);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
video.add_controller(click);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A button that enters and leaves `window`'s fullscreen, showing which one
|
||||||
|
/// it will do.
|
||||||
|
pub(crate) fn fullscreen_button(window: >k4::Window) -> gtk4::Button {
|
||||||
|
let button = gtk4::Button::builder().valign(gtk4::Align::Center).build();
|
||||||
|
let paint = |button: >k4::Button, fullscreen: bool| {
|
||||||
|
if fullscreen {
|
||||||
|
button.set_icon_name("view-restore-symbolic");
|
||||||
|
button.set_tooltip_text(Some(&gettext("Leave Fullscreen (F)")));
|
||||||
|
} else {
|
||||||
|
button.set_icon_name("view-fullscreen-symbolic");
|
||||||
|
button.set_tooltip_text(Some(&gettext("Fullscreen (F)")));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
paint(&button, window.is_fullscreen());
|
||||||
|
let weak = button.downgrade();
|
||||||
|
window.connect_fullscreened_notify(move |window| {
|
||||||
|
if let Some(button) = weak.upgrade() {
|
||||||
|
paint(&button, window.is_fullscreen());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let target = window.downgrade();
|
||||||
|
button.connect_clicked(move |_| {
|
||||||
|
if let Some(window) = target.upgrade() {
|
||||||
|
toggle_fullscreen(&window);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
button
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play a video or a song from My files in a window of its own, streamed from
|
||||||
|
/// the mount: the player reads the blocks it needs as it plays and seeks, so a
|
||||||
|
/// long video starts in seconds instead of after a full download. A file the
|
||||||
|
/// mount doesn't show, or the service is down, opens in the default
|
||||||
|
/// application instead, after a download.
|
||||||
|
pub(crate) fn open_media_player(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
let rel = entry_rel(ui, entry);
|
||||||
|
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||||
|
let (ui, entry) = (ui.clone(), entry.clone());
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match present_within(mounted_path(&mountpoint, &rel)).await {
|
||||||
|
Some(path) => build_media_player(&ui, &entry, &path),
|
||||||
|
None => download_and_open(&ui, &entry),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_media_player(ui: &Rc<Ui>, entry: &DirEntry, path: &Path) {
|
||||||
|
let window = adw::Window::builder()
|
||||||
|
.title(&entry.name)
|
||||||
|
.default_width(960)
|
||||||
|
.default_height(600)
|
||||||
|
.build();
|
||||||
|
if let Some(parent) = ui_window(ui) {
|
||||||
|
window.set_transient_for(Some(&parent));
|
||||||
|
}
|
||||||
|
window.add_css_class("media-player-window");
|
||||||
|
let gtk_window: gtk4::Window = window.clone().upcast();
|
||||||
|
|
||||||
|
let video = gtk4::Video::builder()
|
||||||
|
.autoplay(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
wire_player(&video, >k_window);
|
||||||
|
|
||||||
|
// A song has no picture, which would leave a black window above the
|
||||||
|
// controls: a note stands in for it.
|
||||||
|
let note = gtk4::Image::builder()
|
||||||
|
.icon_name("audio-x-generic-symbolic")
|
||||||
|
.pixel_size(128)
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.can_target(false)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
note.add_css_class("media-player-note");
|
||||||
|
let overlay = gtk4::Overlay::new();
|
||||||
|
overlay.set_child(Some(&video));
|
||||||
|
overlay.add_overlay(¬e);
|
||||||
|
|
||||||
|
let failed = adw::StatusPage::builder()
|
||||||
|
.icon_name("dialog-warning-symbolic")
|
||||||
|
.title(gettext("Couldn't play this file"))
|
||||||
|
.build();
|
||||||
|
let open_with = gtk4::Button::builder()
|
||||||
|
.label(gettext("Open With…"))
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
open_with.add_css_class("pill");
|
||||||
|
open_with.add_css_class("suggested-action");
|
||||||
|
failed.set_child(Some(&open_with));
|
||||||
|
|
||||||
|
let stack = gtk4::Stack::new();
|
||||||
|
stack.add_named(&overlay, Some("player"));
|
||||||
|
stack.add_named(&failed, Some("failed"));
|
||||||
|
|
||||||
|
let header = adw::HeaderBar::new();
|
||||||
|
header.pack_end(&fullscreen_button(>k_window));
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&gettext("Open With…"), move || {
|
||||||
|
open_entry_with(&ui_c, &entry_c)
|
||||||
|
});
|
||||||
|
let video_c = video.clone();
|
||||||
|
// Translators: menu item of the video player that plays the file again
|
||||||
|
// and again.
|
||||||
|
menu.toggle(&gettext("Repeat"), false, move |on| video_c.set_loop(on));
|
||||||
|
header.pack_end(&menu.button());
|
||||||
|
|
||||||
|
let toolbar = adw::ToolbarView::new();
|
||||||
|
toolbar.add_top_bar(&header);
|
||||||
|
toolbar.set_content(Some(&stack));
|
||||||
|
// Fullscreen is the video alone, its own controls still at hand.
|
||||||
|
window
|
||||||
|
.bind_property("fullscreened", &toolbar, "reveal-top-bars")
|
||||||
|
.invert_boolean()
|
||||||
|
.sync_create()
|
||||||
|
.build();
|
||||||
|
window.set_content(Some(&toolbar));
|
||||||
|
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
open_with.connect_clicked(move |_| open_entry_with(&ui_c, &entry_c));
|
||||||
|
|
||||||
|
let (stack_c, failed_c, note_c) = (stack.clone(), failed.clone(), note.clone());
|
||||||
|
video.connect_media_stream_notify(move |video| {
|
||||||
|
let Some(stream) = video.media_stream() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (stack, failed) = (stack_c.clone(), failed_c.clone());
|
||||||
|
stream.connect_error_notify(move |stream| {
|
||||||
|
if let Some(error) = stream.error() {
|
||||||
|
tracing::warn!("in-app player failed: {error}");
|
||||||
|
failed.set_description(Some(&error.to_string()));
|
||||||
|
stack.set_visible_child_name("failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let note = note_c.clone();
|
||||||
|
stream.connect_prepared_notify(move |stream| {
|
||||||
|
note.set_visible(stream.is_prepared() && stream.has_audio() && !stream.has_video());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
video.set_filename(Some(path));
|
||||||
|
|
||||||
|
let keys = gtk4::EventControllerKey::new();
|
||||||
|
let (video_k, window_k) = (video.clone(), gtk_window.downgrade());
|
||||||
|
keys.connect_key_pressed(move |_, key, _code, state| {
|
||||||
|
let Some(window) = window_k.upgrade() else {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
};
|
||||||
|
let Some(name) = key.name() else {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
};
|
||||||
|
// The open menu keeps its arrows and Enter.
|
||||||
|
let in_menu = gtk4::prelude::GtkWindowExt::focus(&window)
|
||||||
|
.is_some_and(|focus| focus.ancestor(gtk4::Popover::static_type()).is_some());
|
||||||
|
if in_menu {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
}
|
||||||
|
if player_key(&video_k, &name, state) {
|
||||||
|
return glib::Propagation::Stop;
|
||||||
|
}
|
||||||
|
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||||
|
match name.as_str() {
|
||||||
|
"space" => toggle_playing(&video_k),
|
||||||
|
"Left" => seek_by(&video_k, -SEEK_SHORT),
|
||||||
|
"Right" => seek_by(&video_k, SEEK_SHORT),
|
||||||
|
"Up" => change_volume(&video_k, VOLUME_STEP),
|
||||||
|
"Down" => change_volume(&video_k, -VOLUME_STEP),
|
||||||
|
"f" | "F11" => toggle_fullscreen(&window),
|
||||||
|
// Escape leaves fullscreen before it closes anything.
|
||||||
|
"Escape" if window.is_fullscreen() => window.unfullscreen(),
|
||||||
|
"Escape" | "q" => window.close(),
|
||||||
|
"w" if ctrl => window.close(),
|
||||||
|
_ => return glib::Propagation::Proceed,
|
||||||
|
}
|
||||||
|
glib::Propagation::Stop
|
||||||
|
});
|
||||||
|
// Capture, so the arrows and Space reach the player rather than moving the
|
||||||
|
// focus between its controls.
|
||||||
|
keys.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||||
|
window.add_controller(keys);
|
||||||
|
|
||||||
|
// Stop the sound with the window, not when the window is finally freed.
|
||||||
|
let video_c = video.clone();
|
||||||
|
window.connect_close_request(move |_| {
|
||||||
|
video_c.set_media_stream(gtk4::MediaStream::NONE);
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
});
|
||||||
|
window.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SEC: i64 = 1_000_000;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_jump_stays_inside_the_video() {
|
||||||
|
assert_eq!(seek_target(30 * SEC, 60 * SEC, 10), 40 * SEC);
|
||||||
|
assert_eq!(seek_target(30 * SEC, 60 * SEC, -10), 20 * SEC);
|
||||||
|
assert_eq!(seek_target(5 * SEC, 60 * SEC, -10), 0);
|
||||||
|
assert_eq!(seek_target(55 * SEC, 60 * SEC, 10), 60 * SEC);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_jump_forward_in_a_stream_of_unknown_length_is_not_held_back() {
|
||||||
|
assert_eq!(seek_target(30 * SEC, 0, 10), 40 * SEC);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -334,6 +334,13 @@ pub(crate) struct ShareDialog {
|
|||||||
/// Open the per-node Share dialog: invite Proton/external users, manage who has
|
/// Open the per-node Share dialog: invite Proton/external users, manage who has
|
||||||
/// access and their roles, and create/copy/remove a public link.
|
/// access and their roles, and create/copy/remove a public link.
|
||||||
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
open_share_dialog_over(ui, entry, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`open_share_dialog`] over `parent` rather than the main window. The photo
|
||||||
|
/// viewer is a window of its own, and a dialog on the main window would open
|
||||||
|
/// behind it.
|
||||||
|
pub(crate) fn open_share_dialog_over(ui: &Rc<Ui>, entry: &DirEntry, parent: Option<gtk4::Window>) {
|
||||||
if !*ui.mounted.borrow() {
|
if !*ui.mounted.borrow() {
|
||||||
toast_error(
|
toast_error(
|
||||||
ui,
|
ui,
|
||||||
@@ -491,7 +498,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
share_dialog_reload(&state);
|
share_dialog_reload(&state);
|
||||||
dialog.present(ui_window(ui).as_ref());
|
dialog.present(parent.or_else(|| ui_window(ui)).as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-fetch the node's share and rebuild the people + public-link sections.
|
/// Re-fetch the node's share and rebuild the people + public-link sections.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const STOCK: &[&str] = &[
|
|||||||
// GTK's built-in icons
|
// GTK's built-in icons
|
||||||
"info-outline-symbolic",
|
"info-outline-symbolic",
|
||||||
// Adwaita
|
// Adwaita
|
||||||
|
"audio-x-generic-symbolic",
|
||||||
"checkbox-checked-symbolic",
|
"checkbox-checked-symbolic",
|
||||||
"checkbox-symbolic",
|
"checkbox-symbolic",
|
||||||
"computer-symbolic",
|
"computer-symbolic",
|
||||||
@@ -68,12 +69,14 @@ const STOCK: &[&str] = &[
|
|||||||
"text-x-generic-symbolic",
|
"text-x-generic-symbolic",
|
||||||
"user-trash-symbolic",
|
"user-trash-symbolic",
|
||||||
"video-x-generic-symbolic",
|
"video-x-generic-symbolic",
|
||||||
|
"view-fullscreen-symbolic",
|
||||||
"view-grid-symbolic",
|
"view-grid-symbolic",
|
||||||
"view-list-symbolic",
|
"view-list-symbolic",
|
||||||
"view-more-symbolic",
|
"view-more-symbolic",
|
||||||
"view-paged-symbolic",
|
"view-paged-symbolic",
|
||||||
"view-pin-symbolic",
|
"view-pin-symbolic",
|
||||||
"view-refresh-symbolic",
|
"view-refresh-symbolic",
|
||||||
|
"view-restore-symbolic",
|
||||||
"window-close-symbolic",
|
"window-close-symbolic",
|
||||||
"zoom-fit-best-symbolic",
|
"zoom-fit-best-symbolic",
|
||||||
"zoom-in-symbolic",
|
"zoom-in-symbolic",
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [B284](#b284--a-keyring-entry-reported-missing-discards-refreshed-session-tokens) | A keyring entry reported missing discards refreshed session tokens |
|
||||||
| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
|
| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
|
||||||
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
|
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
|
||||||
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
|
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
|
||||||
@@ -386,6 +387,34 @@ a live account since.
|
|||||||
|
|
||||||
Newest first.
|
Newest first.
|
||||||
|
|
||||||
|
### B284 — A keyring entry reported missing discards refreshed session tokens
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified)
|
||||||
|
**Found:** 2026-11-14, investigating [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78)
|
||||||
|
after the reporter's oo7 0.7.0 retest ruled out the original persistence fix as a sufficient
|
||||||
|
explanation. See [the investigation and isolated reproducer](ISSUE-78.md).
|
||||||
|
**Where:** `crates/pdfs-core/src/auth.rs`, `TokenSink::flush`
|
||||||
|
|
||||||
|
**Repro:** Refresh a session while the keyring lookup reports `NoEntry` for its existing blob.
|
||||||
|
oo7 0.7.0 does this for a locked collection (upstream #614), reproduced with fake tokens on a
|
||||||
|
private D-Bus connection. pdfs dropped the pending rotation and returned success, leaving the
|
||||||
|
spent refresh token in the saved blob without triggering B61's retry or kernel recovery paths.
|
||||||
|
The running session can still work, but restarting it exposes the stale saved credentials.
|
||||||
|
|
||||||
|
**Fix:** Propagate the missing-session error so the rotation stays pending and is retried.
|
||||||
|
Persistence still requires the matching existing blob; a retry never recreates a deleted
|
||||||
|
session or overwrites a new login. This does not make a locked keyring writable, and pending
|
||||||
|
tokens still need to reach it before power-off to survive a cold boot.
|
||||||
|
|
||||||
|
**Verified:** The two regression tests
|
||||||
|
`auth::tests::a_temporarily_missing_session_does_not_discard_a_rotation` and
|
||||||
|
`auth::tests::retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`
|
||||||
|
failed before the fix and pass afterward. The oo7 fixture confirms its locked lookup behavior
|
||||||
|
and repeated binary `SetSecret` persistence. The reporter's complete Fedora cold-boot sequence
|
||||||
|
has not been reproduced; issue #78's precise trigger remains unconfirmed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code
|
### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, not verified live
|
||||||
|
|||||||
@@ -79,6 +79,31 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
|
|||||||
the grid to the photo it was showing. Alt+Left and Backspace go back a level in Shared with me
|
the grid to the photo it was showing. Alt+Left and Backspace go back a level in Shared with me
|
||||||
and Computers and leave an open album or place in Photos, as Escape does too.
|
and Computers and leave an open album or place in Photos, as Escape does too.
|
||||||
|
|
||||||
|
- **Share photos, and more in the photo viewer.**
|
||||||
|
- **Share…** in a photo's menu and in the viewer's ⋮ menu opens the usual Share dialog: invite
|
||||||
|
people or make a public link. It is not offered for a photo in an album someone else shared.
|
||||||
|
- **Copy Image** (Ctrl+C) puts the photo on screen on the clipboard.
|
||||||
|
- A strip of thumbnails along the bottom of the viewer shows the photos around the one on
|
||||||
|
screen; click one to jump to it. It fades with the other controls and stays hidden during a
|
||||||
|
slideshow.
|
||||||
|
- The line under the date names the town the photo was taken in, when it is known.
|
||||||
|
|
||||||
|
- **Drag photos out of Photos.** Select photos, then drag one of them into a file manager, a chat
|
||||||
|
or any app that takes files. The photos download when dropped and arrive under their own
|
||||||
|
names. A drag that starts on a photo that is not selected still selects, as before.
|
||||||
|
|
||||||
|
- **Upload progress in Photos.** Uploading several photos at once shows "Uploading 3 of 12
|
||||||
|
items…" with a progress bar above the timeline, instead of a single toast at the start.
|
||||||
|
|
||||||
|
- **Videos and music play in the app.** Opening a video or a song in My files, by a double-click
|
||||||
|
or **Open**, plays it in a player window, streamed from the mount like before. **Open With…**
|
||||||
|
still hands it to another player, and the separate **Play** item is gone. When the file can't
|
||||||
|
be played (a codec GStreamer lacks), the window says so and offers **Open With…**. Both this
|
||||||
|
player and the one in Photos gained a fullscreen button, fullscreen on a double-click,
|
||||||
|
**Repeat** in the ⋮ menu, `K` to play and pause, `J` and `L` (or Shift+←/→) to jump
|
||||||
|
10 seconds, and `M` to mute. The volume carries over from one video to the next, and the
|
||||||
|
thumbnail strip in Photos stays off a video, where it covered the seek bar.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
|
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
|
||||||
@@ -87,6 +112,10 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **Photos zoomed all the way out shows the whole timeline.** The first 200 photos fit on one
|
||||||
|
screen there, and with nothing to scroll no more were loaded. More now load whenever the photos
|
||||||
|
don't fill the window, also after a zoom or a resize.
|
||||||
|
|
||||||
- **My files comes back after the service was killed for hanging** (B279). A killed service left
|
- **My files comes back after the service was killed for hanging** (B279). A killed service left
|
||||||
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
|
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
|
||||||
forever without opening anything, until the mounts were cleared by hand. The service now clears
|
forever without opening anything, until the mounts were cleared by hand. The service now clears
|
||||||
@@ -136,6 +165,14 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
|
|||||||
- **A search in My files stays.** It was replaced by the folder's listing whenever files
|
- **A search in My files stays.** It was replaced by the folder's listing whenever files
|
||||||
changed, on refresh and on coming back to the page, while the query stayed in the box.
|
changed, on refresh and on coming back to the page, while the query stayed in the box.
|
||||||
|
|
||||||
|
- **The photo viewer's buttons are readable on any photo.** They were white on a see-through
|
||||||
|
white, and vanished over a bright photo. The bar now keeps only the favorite star, the details
|
||||||
|
and a menu, which names Slideshow, Save a Copy, Open With, Add to Album, Show on Map and Move to
|
||||||
|
Trash in words where eight icons stood. A shot stored as more than one file shows a "RAW" or
|
||||||
|
"Live" switch, or "2 of 3" for a burst, where an unlabelled icon was. The title reads
|
||||||
|
"Wednesday, October 7, 2026, 13:42" rather than a timestamp. Delete asks first, as it does in
|
||||||
|
the grid, and Alt+Left closes the viewer.
|
||||||
|
|
||||||
- **A date jump in Photos stops when you look elsewhere.** A jump still loading the timeline
|
- **A date jump in Photos stops when you look elsewhere.** A jump still loading the timeline
|
||||||
kept going after another filter, album or place was opened.
|
kept going after another filter, album or place was opened.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Issue #78: session loss on Fedora 45
|
||||||
|
|
||||||
|
Investigation on 2026-11-14 of [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78),
|
||||||
|
using its report and comments through `gh`, pdfs 3.7.0/current source, and the oo7 0.7.0 tag.
|
||||||
|
|
||||||
|
## What is established
|
||||||
|
|
||||||
|
The reporter still saw the problem with oo7 0.7.0 on November 7. The original
|
||||||
|
[SetSecret persistence fix](https://github.com/linux-credentials/oo7/commit/7fb11dd111c4be8fec01996e3caa31c44fa962b3)
|
||||||
|
is present in that release. In an isolated test, three successive binary `SetSecret` updates
|
||||||
|
survived reopening the keyring file. Repeating the original persistence explanation is therefore
|
||||||
|
insufficient.
|
||||||
|
|
||||||
|
A different, reproducible failure exists at the boundary between oo7 and pdfs:
|
||||||
|
|
||||||
|
1. oo7 0.7.0's `Collection::search_inner_items` returns an empty list for a locked collection.
|
||||||
|
`SearchItems` consequently returns **both** the unlocked and locked lists empty even when the
|
||||||
|
matching item is on disk. The isolated test reproduces this with pdfs's exact lookup attributes.
|
||||||
|
This is upstream [oo7 #614](https://github.com/linux-credentials/oo7/issues/614).
|
||||||
|
2. `dbus-secret-service-keyring-store` 1.0.1 maps an empty search to `keyring_core::Error::NoEntry`;
|
||||||
|
pdfs maps that to `Error::NotLoggedIn`. The client cannot offer an unlock when no locked item
|
||||||
|
is reported.
|
||||||
|
3. In pdfs 3.7.0 and the code before this change, `TokenSink::flush` treated `NotLoggedIn` as a
|
||||||
|
logout, cleared the pending rotation and returned success. It skipped the error path that
|
||||||
|
retains the new tokens in the kernel keyring and retries their persistence. The code was
|
||||||
|
unchanged between v3.7.0 and this investigation.
|
||||||
|
4. If a token refresh encounters that reply, the saved blob retains a spent refresh token. The
|
||||||
|
running process can keep working with the new tokens in memory; a restart then exposes the
|
||||||
|
stale saved credentials. A regression test failed before the fix and passes afterward.
|
||||||
|
|
||||||
|
The same unsafe assumption also applies to transient empty lookups such as the race reported in
|
||||||
|
[oo7 #652](https://github.com/linux-credentials/oo7/issues/652), fixed in 0.7.1. That issue's
|
||||||
|
replacement race was not independently reproduced here; pdfs normally updates existing items
|
||||||
|
with `SetSecret`, so it must not be assumed to encounter the same-item `CreateItem` race.
|
||||||
|
|
||||||
|
## Change and limits
|
||||||
|
|
||||||
|
`TokenSink::flush` now propagates a missing-session error instead of discarding the rotation.
|
||||||
|
The existing retry, kernel-keyring recovery and unsaved-token status paths can then run. Once
|
||||||
|
the collection is unlocked and readable, the pending rotation is saved. A genuinely deleted
|
||||||
|
session is never recreated: flushing still requires a readable blob with the matching session
|
||||||
|
ID and expected refresh token. A new login causes the old rotation to be discarded as before.
|
||||||
|
|
||||||
|
This is a confirmed pdfs defect (B284), **not a confirmed complete explanation of the reporter's
|
||||||
|
cold-boot failure**. The issue has no oo7 logs or record showing that its collection was locked
|
||||||
|
when a token rotated. A locked keyring only at startup can hide a login, but does not by itself
|
||||||
|
explain `InvalidRefreshToken`. The token-loss path requires a successful refresh while the saved
|
||||||
|
entry is unreadable.
|
||||||
|
|
||||||
|
The fix does not make a locked keyring writable. If it stays unreadable until power-off, tokens
|
||||||
|
retained in process/kernel memory still cannot survive a cold boot. No new disk credential store
|
||||||
|
or credential format is introduced.
|
||||||
|
|
||||||
|
Other upstream Fedora-upgrade issues were checked: [oo7 #651](https://github.com/linux-credentials/oo7/issues/651)
|
||||||
|
creates duplicate placeholder/migrated collections and refuses writes; [oo7 #662](https://github.com/linux-credentials/oo7/issues/662)
|
||||||
|
can resolve the default alias to a locked collection. Both have fixes in
|
||||||
|
[0.7.1](https://github.com/linux-credentials/oo7/releases/tag/0.7.1), released November 10,
|
||||||
|
after the reporter's November 7 retest. Neither can be assigned as this report's cause without
|
||||||
|
matching runtime evidence.
|
||||||
|
|
||||||
|
## Reproduce without a Proton account or desktop keyring
|
||||||
|
|
||||||
|
The oo7 fixture uses fake tokens, a temporary disk keyring and a private peer-to-peer D-Bus
|
||||||
|
connection. It asserts the observed 0.7.0 behavior, including the incorrect empty locked lookup;
|
||||||
|
passing means that behavior was reproduced. It does not simulate Proton refresh or a full boot.
|
||||||
|
|
||||||
|
From the pdfs repository, choose an unused temporary checkout path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone --depth 1 --branch 0.7.0 https://github.com/linux-credentials/oo7.git /tmp/pdfs-oo7-repro
|
||||||
|
git -C /tmp/pdfs-oo7-repro apply "$PWD/docs/repro/oo7-0.7.0-issue78.patch"
|
||||||
|
cargo test --manifest-path /tmp/pdfs-oo7-repro/Cargo.toml \
|
||||||
|
-p oo7-daemon --lib --features test-util issue78 -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)
|
||||||
|
oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring
|
||||||
|
test item::tests::issue78_locked_lookup_hides_saved_tokens ... ok
|
||||||
|
```
|
||||||
|
|
||||||
|
The pdfs side is covered by `cargo test -p pdfs-core auth::tests`, specifically
|
||||||
|
`a_temporarily_missing_session_does_not_discard_a_rotation` and
|
||||||
|
`retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`.
|
||||||
|
|
||||||
|
Remaining live validation: reproduce on a Fedora 45 GNOME session across logout/shutdown,
|
||||||
|
correlating oo7 lock/write errors with pdfs refresh and retry logs, and checking the running
|
||||||
|
daemon's package version rather than just the CLI version. The investigation did not use a real
|
||||||
|
Proton account, alter a desktop keyring, or post to either GitHub issue.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
diff --git a/server/src/item/tests.rs b/server/src/item/tests.rs
|
||||||
|
index 99c25dc..2ba705d 100644
|
||||||
|
--- a/server/src/item/tests.rs
|
||||||
|
+++ b/server/src/item/tests.rs
|
||||||
|
@@ -5,6 +5,48 @@ use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
use crate::{service::PrompterType, tests::TestServiceSetup};
|
||||||
|
|
||||||
|
+// Investigation for proton-drive-linux #78. Fake tokens and a private p2p bus;
|
||||||
|
+// no desktop keyring or Proton account is used.
|
||||||
|
+#[tokio::test]
|
||||||
|
+async fn issue78_locked_lookup_hides_saved_tokens() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
+ let setup =
|
||||||
|
+ TestServiceSetup::with_login_keyring(oo7::Secret::from("test-password-long-enough"))
|
||||||
|
+ .await?;
|
||||||
|
+ let collection = setup.default_collection().await?;
|
||||||
|
+ let attributes = [("service", "proton-drive-linux"), ("username", "session")];
|
||||||
|
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(b"fake-refresh-r1"))?;
|
||||||
|
+ collection
|
||||||
|
+ .create_item("pdfs fake session", &attributes, &secret, false, None)
|
||||||
|
+ .await?;
|
||||||
|
+
|
||||||
|
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
||||||
|
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
|
||||||
|
+ setup.lock_collection(collection).await?;
|
||||||
|
+ assert!(collection.is_locked().await?);
|
||||||
|
+
|
||||||
|
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
||||||
|
+ // This is the problematic reply, not the Secret Service behavior expected
|
||||||
|
+ // by keyring-store: the saved item is neither unlocked nor reported locked.
|
||||||
|
+ assert_eq!((unlocked.len(), locked.len()), (0, 0));
|
||||||
|
+ assert_eq!(setup.login_keyring().await?.items().await?.len(), 1);
|
||||||
|
+ eprintln!("oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)");
|
||||||
|
+
|
||||||
|
+ setup.unlock_collection(collection).await?;
|
||||||
|
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
||||||
|
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
|
||||||
|
+
|
||||||
|
+ // Exercise the binary SetSecret path used by pdfs's keyring-store. Reopen
|
||||||
|
+ // the disk keyring after each update, rather than trusting a memory read.
|
||||||
|
+ for refresh in [b"fake-refresh-r2", b"fake-refresh-r3", b"fake-refresh-r4"] {
|
||||||
|
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(refresh))?;
|
||||||
|
+ unlocked[0].set_secret(&secret).await?;
|
||||||
|
+ let items = setup.login_keyring().await?.items().await?;
|
||||||
|
+ assert_eq!(items[0].secret().as_bytes(), refresh);
|
||||||
|
+ }
|
||||||
|
+ eprintln!("oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring");
|
||||||
|
+ Ok(())
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
#[tokio::test]
|
||||||
|
async fn label_property() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let setup = TestServiceSetup::plain_session(true).await?;
|
||||||
@@ -24,6 +24,7 @@ crates/pdfs-gui/src/app/widgets/compat.rs
|
|||||||
crates/pdfs-gui/src/app/widgets/details.rs
|
crates/pdfs-gui/src/app/widgets/details.rs
|
||||||
crates/pdfs-gui/src/app/widgets/file_list.rs
|
crates/pdfs-gui/src/app/widgets/file_list.rs
|
||||||
crates/pdfs-gui/src/app/widgets/menu.rs
|
crates/pdfs-gui/src/app/widgets/menu.rs
|
||||||
|
crates/pdfs-gui/src/app/widgets/player.rs
|
||||||
crates/pdfs-gui/src/app/widgets/share_dialog.rs
|
crates/pdfs-gui/src/app/widgets/share_dialog.rs
|
||||||
crates/pdfs-gui/src/app/widgets/thumbnails.rs
|
crates/pdfs-gui/src/app/widgets/thumbnails.rs
|
||||||
crates/pdfs-gui/src/app/widgets/versions_dialog.rs
|
crates/pdfs-gui/src/app/widgets/versions_dialog.rs
|
||||||
|
|||||||
+645
-570
File diff suppressed because it is too large.
Load diff
+619
-560
File diff suppressed because it is too large.
Load diff
+636
-561
File diff suppressed because it is too large.
Load diff
@@ -91,7 +91,7 @@ SECTIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Too long or too internal for the wiki; links to them go to GitHub.
|
# Too long or too internal for the wiki; links to them go to GitHub.
|
||||||
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "SDK-0.11.0.md"}
|
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "ISSUE-78.md", "SDK-0.11.0.md"}
|
||||||
|
|
||||||
# Files outside docs/ whose links are checked but not published.
|
# Files outside docs/ whose links are checked but not published.
|
||||||
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]
|
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]
|
||||||
|
|||||||
Reference in new issue
Block a user