mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(core,fuse,gui,cli): album management
New requests CreateAlbum, RenameAlbum, DeleteAlbum, AddToAlbum and RemoveFromAlbum, backed by proton-drive-rs 0.6.6. The Albums view gains a New Album button and a card menu with Rename and Delete; photos gain Add to Album (menu and selection bar) and Remove from Album inside an album. CLI: pdfs new-album, rename-album, delete-album, add-to-album, remove-from-album. Also fixes the album view state: the timeline filters are hidden on the album grid, the header counts albums, Back keeps the grid and its scroll position, and the timeline is only reloaded when an album replaced it.
This commit is contained in:
1 parent
29e5769dec
commit
43d1afe705
8 files changed
+738
-18
No files matched your search
@@ -230,6 +230,43 @@ enum Command {
|
|||||||
#[arg(long, default_value_t = 0)]
|
#[arg(long, default_value_t = 0)]
|
||||||
offset: usize,
|
offset: usize,
|
||||||
},
|
},
|
||||||
|
/// Create a photo album and print its uid.
|
||||||
|
NewAlbum {
|
||||||
|
/// Album name.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Rename a photo album.
|
||||||
|
RenameAlbum {
|
||||||
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
uid: String,
|
||||||
|
/// New name.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Delete a photo album. Its photos stay in the timeline.
|
||||||
|
DeleteAlbum {
|
||||||
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
uid: String,
|
||||||
|
/// Also delete photos that exist only in this album (saved from a
|
||||||
|
/// shared album); without it the server refuses to delete such an album.
|
||||||
|
#[arg(long)]
|
||||||
|
with_photos: bool,
|
||||||
|
},
|
||||||
|
/// Add photos to an album.
|
||||||
|
AddToAlbum {
|
||||||
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
uid: String,
|
||||||
|
/// Photo node uids (from `pdfs photos`).
|
||||||
|
#[arg(required = true)]
|
||||||
|
photos: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Take photos out of an album. They stay in the timeline.
|
||||||
|
RemoveFromAlbum {
|
||||||
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
uid: String,
|
||||||
|
/// Photo node uids (from `pdfs album <uid>`).
|
||||||
|
#[arg(required = true)]
|
||||||
|
photos: Vec<String>,
|
||||||
|
},
|
||||||
/// Download a photo by uid (`volume~link`) and print its cached path.
|
/// Download a photo by uid (`volume~link`) and print its cached path.
|
||||||
OpenPhoto {
|
OpenPhoto {
|
||||||
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
||||||
@@ -732,6 +769,22 @@ fn main() -> Result<()> {
|
|||||||
Command::ImportStatus => cmd_import_status(),
|
Command::ImportStatus => cmd_import_status(),
|
||||||
Command::CancelImport => cmd_cancel_import(),
|
Command::CancelImport => cmd_cancel_import(),
|
||||||
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
||||||
|
Command::NewAlbum { name } => cmd_new_album(name),
|
||||||
|
Command::RenameAlbum { uid, name } => {
|
||||||
|
ok_or_bail(control_request(CtlRequest::RenameAlbum { uid, name })?)
|
||||||
|
}
|
||||||
|
Command::DeleteAlbum { uid, with_photos } => {
|
||||||
|
ok_or_bail(control_request(CtlRequest::DeleteAlbum {
|
||||||
|
uid,
|
||||||
|
delete_photos: with_photos,
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
Command::AddToAlbum { uid, photos } => {
|
||||||
|
cmd_album_change(CtlRequest::AddToAlbum { uid, photos }, "added")
|
||||||
|
}
|
||||||
|
Command::RemoveFromAlbum { uid, photos } => {
|
||||||
|
cmd_album_change(CtlRequest::RemoveFromAlbum { uid, photos }, "removed")
|
||||||
|
}
|
||||||
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
||||||
Command::Search {
|
Command::Search {
|
||||||
query,
|
query,
|
||||||
@@ -2068,6 +2121,42 @@ fn cmd_albums() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_new_album(name: String) -> Result<()> {
|
||||||
|
match control_request(CtlRequest::CreateAlbum { name })? {
|
||||||
|
CtlResponse::AlbumCreated { uid } => println!("{uid}"),
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an album add or remove and report it per photo; any failed photo
|
||||||
|
/// fails the command.
|
||||||
|
fn cmd_album_change(request: CtlRequest, verb: &str) -> Result<()> {
|
||||||
|
match control_request(request)? {
|
||||||
|
CtlResponse::AlbumChanged { changed, failed } => {
|
||||||
|
println!("{} {verb}", photo_count(changed.len()));
|
||||||
|
for failure in &failed {
|
||||||
|
eprintln!("{}: {}", failure.uid, failure.message);
|
||||||
|
}
|
||||||
|
if !failed.is_empty() {
|
||||||
|
bail!("{} failed", photo_count(failed.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn photo_count(n: usize) -> String {
|
||||||
|
if n == 1 {
|
||||||
|
"1 photo".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{n} photos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -> Result<()> {
|
fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -> Result<()> {
|
||||||
// The daemon opens the archives itself off the shared filesystem, so it gets
|
// The daemon opens the archives itself off the shared filesystem, so it gets
|
||||||
// absolute paths — the same arrangement `pdfs upload` uses.
|
// absolute paths — the same arrangement `pdfs upload` uses.
|
||||||
|
|||||||
@@ -94,6 +94,25 @@ pub enum Request {
|
|||||||
offset: usize,
|
offset: usize,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
},
|
},
|
||||||
|
/// Create an album named `name`. Replies with [`Response::AlbumCreated`].
|
||||||
|
CreateAlbum { name: String },
|
||||||
|
/// Rename the album `uid`. Replies with [`Response::Ok`].
|
||||||
|
RenameAlbum { uid: String, name: String },
|
||||||
|
/// Delete the album `uid`. Its photos stay in the timeline; with
|
||||||
|
/// `delete_photos` set, photos that exist only in the album (saved from a
|
||||||
|
/// shared album) are deleted with it, which the server otherwise refuses.
|
||||||
|
/// Replies with [`Response::Ok`].
|
||||||
|
DeleteAlbum {
|
||||||
|
uid: String,
|
||||||
|
#[serde(default)]
|
||||||
|
delete_photos: bool,
|
||||||
|
},
|
||||||
|
/// Add timeline photos to the album `uid`. Replies with
|
||||||
|
/// [`Response::AlbumChanged`].
|
||||||
|
AddToAlbum { uid: String, photos: Vec<String> },
|
||||||
|
/// Take photos out of the album `uid`. They stay in the timeline: this is
|
||||||
|
/// not a trash. Replies with [`Response::AlbumChanged`].
|
||||||
|
RemoveFromAlbum { uid: String, photos: Vec<String> },
|
||||||
/// Fetch thumbnails for the given photo uids, downloading the ones not
|
/// Fetch thumbnails for the given photo uids, downloading the ones not
|
||||||
/// already cached (one batched round-trip) and replying with their on-disk
|
/// already cached (one batched round-trip) and replying with their on-disk
|
||||||
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
|
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
|
||||||
@@ -1745,6 +1764,15 @@ pub enum Response {
|
|||||||
trashed: Vec<String>,
|
trashed: Vec<String>,
|
||||||
failed: Vec<TrashFailure>,
|
failed: Vec<TrashFailure>,
|
||||||
},
|
},
|
||||||
|
/// The album a [`Request::CreateAlbum`] made.
|
||||||
|
AlbumCreated { uid: String },
|
||||||
|
/// Outcome of a [`Request::AddToAlbum`] or [`Request::RemoveFromAlbum`],
|
||||||
|
/// per photo like [`Response::Trashed`]: `changed` photos were added or
|
||||||
|
/// removed, `failed` ones were not.
|
||||||
|
AlbumChanged {
|
||||||
|
changed: Vec<String>,
|
||||||
|
failed: Vec<TrashFailure>,
|
||||||
|
},
|
||||||
/// A [`Request::FileThumbs`] generation is no longer current. This is
|
/// A [`Request::FileThumbs`] generation is no longer current. This is
|
||||||
/// retryable after reserving a fresh generation and must never be interpreted
|
/// retryable after reserving a fresh generation and must never be interpreted
|
||||||
/// as a permanent "no thumbnail" verdict.
|
/// as a permanent "no thumbnail" verdict.
|
||||||
@@ -2360,6 +2388,15 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_album_keeps_photos_unless_asked() {
|
||||||
|
let request: Request = serde_json::from_str(r#"{"DeleteAlbum":{"uid":"v~a"}}"#).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
request,
|
||||||
|
Request::DeleteAlbum { uid, delete_photos: false } if uid == "v~a"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// The trash requests carry uids rather than paths; they must survive the same
|
/// The trash requests carry uids rather than paths; they must survive the same
|
||||||
/// line-delimited round-trip, since a mangled uid would restore or destroy the
|
/// line-delimited round-trip, since a mangled uid would restore or destroy the
|
||||||
/// wrong node.
|
/// wrong node.
|
||||||
|
|||||||
@@ -221,6 +221,84 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create an album and return its uid. The listing is refreshed before
|
||||||
|
/// answering, so the next [`Core::albums`] already shows it.
|
||||||
|
pub(crate) fn create_album(&self, name: &str) -> CoreResult<String> {
|
||||||
|
let uid = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.photos().create_album(name))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "create album"))?;
|
||||||
|
self.after_album_change(None);
|
||||||
|
Ok(uid.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rename_album(&self, album: &NodeUid, name: &str) -> CoreResult<()> {
|
||||||
|
self.rt
|
||||||
|
.block_on(self.photos().rename_album(album, name))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "rename album"))?;
|
||||||
|
self.after_album_change(None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an album. Its photos stay in the timeline unless `delete_photos`,
|
||||||
|
/// which only matters for photos that exist in the album alone.
|
||||||
|
pub(crate) fn delete_album(&self, album: &NodeUid, delete_photos: bool) -> CoreResult<()> {
|
||||||
|
self.rt
|
||||||
|
.block_on(self.photos().delete_album(album, delete_photos))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "delete album"))?;
|
||||||
|
let key = album.to_string();
|
||||||
|
if let Err(error) = self.db.album_photos_replace(&key, &[]) {
|
||||||
|
warn!(%album, %error, "a deleted album's photos could not be dropped");
|
||||||
|
}
|
||||||
|
self.after_album_change(None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add photos to an album, per photo: `(added, failed)`.
|
||||||
|
pub(crate) fn add_to_album(
|
||||||
|
&self,
|
||||||
|
album: &NodeUid,
|
||||||
|
photos: &[NodeUid],
|
||||||
|
) -> CoreResult<AlbumOutcome> {
|
||||||
|
let outcomes = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.photos().add_photos_to_album(album, photos))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "add to album"))?;
|
||||||
|
self.after_album_change(Some(album));
|
||||||
|
Ok(album_outcome(outcomes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take photos out of an album, per photo: `(removed, failed)`. The photos
|
||||||
|
/// stay in the timeline.
|
||||||
|
pub(crate) fn remove_from_album(
|
||||||
|
&self,
|
||||||
|
album: &NodeUid,
|
||||||
|
photos: &[NodeUid],
|
||||||
|
) -> CoreResult<AlbumOutcome> {
|
||||||
|
let outcomes = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.photos().remove_photos_from_album(album, photos))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "remove from album"))?;
|
||||||
|
self.after_album_change(Some(album));
|
||||||
|
Ok(album_outcome(outcomes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring the persisted listing, and `album`'s contents when given, up to
|
||||||
|
/// date after a change made here, so the reply is followed by a listing
|
||||||
|
/// that shows it. A failed refresh only leaves them stale: the change
|
||||||
|
/// itself went through.
|
||||||
|
fn after_album_change(&self, album: Option<&NodeUid>) {
|
||||||
|
self.invalidate_albums();
|
||||||
|
if let Err(error) = self.rt.block_on(self.refresh_albums()) {
|
||||||
|
warn!(%error, "album listing refresh after a change failed");
|
||||||
|
}
|
||||||
|
if let Some(album) = album
|
||||||
|
&& let Err(error) = self.rt.block_on(self.refresh_album(album))
|
||||||
|
{
|
||||||
|
warn!(%album, %error, "album refresh after a change failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the album listing's and every album's freshness stamp, so the next
|
/// Drop the album listing's and every album's freshness stamp, so the next
|
||||||
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
||||||
/// to the timeline can just as easily be a change to an album.
|
/// to the timeline can just as easily be a change to an album.
|
||||||
@@ -230,6 +308,20 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Photos an album change applied to, and the ones it failed for with why.
|
||||||
|
pub(crate) type AlbumOutcome = (Vec<String>, Vec<(String, String)>);
|
||||||
|
|
||||||
|
fn album_outcome(outcomes: Vec<pdfs_core::batch::Outcome>) -> AlbumOutcome {
|
||||||
|
let (done, failed) = pdfs_core::batch::split(outcomes);
|
||||||
|
(
|
||||||
|
done.iter().map(|uid| uid.to_string()).collect(),
|
||||||
|
failed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uid, error)| (uid.to_string(), error.to_string()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Project a persisted album into the wire item a front-end paints.
|
/// Project a persisted album into the wire item a front-end paints.
|
||||||
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
||||||
AlbumInfo {
|
AlbumInfo {
|
||||||
|
|||||||
@@ -46,6 +46,32 @@ const MAX_ROW_LIMIT: usize = 1000;
|
|||||||
fn clamp_limit(limit: usize) -> usize {
|
fn clamp_limit(limit: usize) -> usize {
|
||||||
limit.min(MAX_ROW_LIMIT)
|
limit.min(MAX_ROW_LIMIT)
|
||||||
}
|
}
|
||||||
|
/// Answer an album add or remove: parse the uids, run `change`, and report
|
||||||
|
/// per photo.
|
||||||
|
fn album_change(
|
||||||
|
uid: &str,
|
||||||
|
photos: &[String],
|
||||||
|
change: impl FnOnce(&NodeUid, &[NodeUid]) -> CoreResult<super::albums::AlbumOutcome>,
|
||||||
|
) -> CtlResponse {
|
||||||
|
let Some(album) = parse_uid(uid) else {
|
||||||
|
return CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}")));
|
||||||
|
};
|
||||||
|
let photos = match Core::parse_uids(photos) {
|
||||||
|
Ok(photos) => photos,
|
||||||
|
Err(e) => return CtlResponse::error(e),
|
||||||
|
};
|
||||||
|
match change(&album, &photos) {
|
||||||
|
Ok((changed, failed)) => CtlResponse::AlbumChanged {
|
||||||
|
changed,
|
||||||
|
failed: failed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uid, message)| TrashFailure { uid, message })
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
struct ControlHandlerPermit;
|
struct ControlHandlerPermit;
|
||||||
@@ -421,6 +447,38 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::CreateAlbum { name }) => match core.create_album(&name) {
|
||||||
|
Ok(uid) => CtlResponse::AlbumCreated { uid },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::RenameAlbum { uid, name }) => match parse_uid(&uid) {
|
||||||
|
Some(album) => match core.rename_album(&album, &name) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: format!("renamed album to {name}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::DeleteAlbum { uid, delete_photos }) => match parse_uid(&uid) {
|
||||||
|
Some(album) => match core.delete_album(&album, delete_photos) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: "album deleted".to_string(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::AddToAlbum { uid, photos }) => {
|
||||||
|
album_change(&uid, &photos, |album, photos| {
|
||||||
|
core.add_to_album(album, photos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::RemoveFromAlbum { uid, photos }) => {
|
||||||
|
album_change(&uid, &photos, |album, photos| {
|
||||||
|
core.remove_from_album(album, photos)
|
||||||
|
})
|
||||||
|
}
|
||||||
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
||||||
Ok(months) => CtlResponse::PhotoMonths {
|
Ok(months) => CtlResponse::PhotoMonths {
|
||||||
months: months
|
months: months
|
||||||
|
|||||||
@@ -475,6 +475,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
view_switch: gallery_widgets.view_switch.clone(),
|
view_switch: gallery_widgets.view_switch.clone(),
|
||||||
albums_loading: Cell::new(false),
|
albums_loading: Cell::new(false),
|
||||||
album: RefCell::new(None),
|
album: RefCell::new(None),
|
||||||
|
timeline_stale: Cell::new(false),
|
||||||
|
album_list: RefCell::new(Vec::new()),
|
||||||
back: gallery_widgets.back.clone(),
|
back: gallery_widgets.back.clone(),
|
||||||
filters: gallery_widgets.filters.clone(),
|
filters: gallery_widgets.filters.clone(),
|
||||||
kind: Cell::new(None),
|
kind: Cell::new(None),
|
||||||
@@ -506,6 +508,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
select_bar: gallery_widgets.select_bar.clone(),
|
select_bar: gallery_widgets.select_bar.clone(),
|
||||||
select_label: gallery_widgets.select_label.clone(),
|
select_label: gallery_widgets.select_label.clone(),
|
||||||
select_trash: gallery_widgets.select_trash.clone(),
|
select_trash: gallery_widgets.select_trash.clone(),
|
||||||
|
select_album: gallery_widgets.select_album.clone(),
|
||||||
},
|
},
|
||||||
shared: SharedState {
|
shared: SharedState {
|
||||||
content: shared_widgets.content.clone(),
|
content: shared_widgets.content.clone(),
|
||||||
@@ -1143,6 +1146,12 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
show_import.connect_activate(move |_, _| ui_import.stack.set_visible_child_name("takeout"));
|
show_import.connect_activate(move |_, _| ui_import.stack.set_visible_child_name("takeout"));
|
||||||
window.add_action(&show_import);
|
window.add_action(&show_import);
|
||||||
|
|
||||||
|
// The Albums view's New Album button.
|
||||||
|
let new_album = gio::SimpleAction::new("new-album", None);
|
||||||
|
let ui_album = ui.clone();
|
||||||
|
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new()));
|
||||||
|
window.add_action(&new_album);
|
||||||
|
|
||||||
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
||||||
let win = window.clone();
|
let win = window.clone();
|
||||||
shortcuts.connect_activate(move |_, _| show_shortcuts(&win));
|
shortcuts.connect_activate(move |_, _| show_shortcuts(&win));
|
||||||
|
|||||||
@@ -16,23 +16,34 @@ const COVER_EDGE: i32 = 200;
|
|||||||
|
|
||||||
/// Show the Albums grid and (re)load it. Called when the Albums toggle goes on.
|
/// Show the Albums grid and (re)load it. Called when the Albums toggle goes on.
|
||||||
pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
||||||
close_album(ui);
|
show_album_grid(ui);
|
||||||
ui.gallery.content.set_visible_child_name("albums");
|
|
||||||
load_albums(ui);
|
load_albums(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Switch to the album grid as it was last filled, keeping its scroll
|
||||||
|
/// position. The timeline's filters do not apply to albums, so they go.
|
||||||
|
fn show_album_grid(ui: &Rc<Ui>) {
|
||||||
|
close_album(ui);
|
||||||
|
ui.gallery.filters.set_visible(false);
|
||||||
|
ui.gallery.content.set_visible_child_name("albums");
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask the daemon for the album listing and rebuild the grid.
|
/// Ask the daemon for the album listing and rebuild the grid.
|
||||||
pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||||
if ui.gallery.albums_loading.get() {
|
if ui.gallery.albums_loading.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.albums_loading.set(true);
|
ui.gallery.albums_loading.set(true);
|
||||||
albums_status(
|
// A grid already on screen stays there while it refreshes, rather than
|
||||||
ui,
|
// blinking to a loading page and losing its scroll position.
|
||||||
"view-grid-symbolic",
|
if ui.gallery.albums_stack.visible_child_name().as_deref() != Some("grid") {
|
||||||
"Loading albums…",
|
albums_status(
|
||||||
"Reading your Proton Drive albums.",
|
ui,
|
||||||
);
|
"view-grid-symbolic",
|
||||||
|
"Loading albums…",
|
||||||
|
"Reading your Proton Drive albums.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
@@ -52,7 +63,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
|||||||
&ui,
|
&ui,
|
||||||
"view-grid-symbolic",
|
"view-grid-symbolic",
|
||||||
"No albums",
|
"No albums",
|
||||||
"Albums you create in Proton Photos appear here.",
|
"Create one with New Album, or in Proton Photos.",
|
||||||
),
|
),
|
||||||
Ok(Ok(Response::Albums { items, .. })) => {
|
Ok(Ok(Response::Albums { items, .. })) => {
|
||||||
fill_albums(&ui, &items);
|
fill_albums(&ui, &items);
|
||||||
@@ -80,15 +91,31 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
|||||||
/// Replace the grid's cards with `albums`, in the order the daemon gave them
|
/// Replace the grid's cards with `albums`, in the order the daemon gave them
|
||||||
/// (newest activity first).
|
/// (newest activity first).
|
||||||
fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
|
fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
|
||||||
|
let scroll = ui
|
||||||
|
.gallery
|
||||||
|
.albums
|
||||||
|
.ancestor(gtk4::ScrolledWindow::static_type())
|
||||||
|
.and_downcast::<gtk4::ScrolledWindow>();
|
||||||
|
let position = scroll.as_ref().map(|s| s.vadjustment().value());
|
||||||
while let Some(child) = ui.gallery.albums.first_child() {
|
while let Some(child) = ui.gallery.albums.first_child() {
|
||||||
ui.gallery.albums.remove(&child);
|
ui.gallery.albums.remove(&child);
|
||||||
}
|
}
|
||||||
|
*ui.gallery.album_list.borrow_mut() = albums.to_vec();
|
||||||
|
if ui.gallery.album.borrow().is_none() {
|
||||||
|
ui.gallery
|
||||||
|
.title
|
||||||
|
.set_subtitle(&count_noun(albums.len(), "album", "albums"));
|
||||||
|
}
|
||||||
for album in albums {
|
for album in albums {
|
||||||
ui.gallery.albums.append(&album_card(ui, album));
|
ui.gallery.albums.append(&album_card(ui, album));
|
||||||
}
|
}
|
||||||
// The covers are ordinary photos as far as the daemon is concerned, so the
|
// The covers are ordinary photos as far as the daemon is concerned, so the
|
||||||
// batch that fills them is the gallery's own.
|
// batch that fills them is the gallery's own.
|
||||||
schedule_thumbs(ui);
|
schedule_thumbs(ui);
|
||||||
|
// A refresh rebuilds the cards; put the view back where the user was.
|
||||||
|
if let (Some(scroll), Some(position)) = (scroll, position) {
|
||||||
|
glib::idle_add_local_once(move || scroll.vadjustment().set_value(position));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One album as a clickable card: its cover, its name, and how many photos it
|
/// One album as a clickable card: its cover, its name, and how many photos it
|
||||||
@@ -150,12 +177,354 @@ fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
|
|||||||
want_cover(ui, uid, &picture);
|
want_cover(ui, uid, &picture);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let context = gtk4::GestureClick::builder().button(3).build();
|
||||||
|
let ui_context = ui.clone();
|
||||||
|
let menu_album = album.clone();
|
||||||
|
context.connect_pressed(move |gesture, _, x, y| {
|
||||||
|
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
||||||
|
if let Some(anchor) = gesture.widget() {
|
||||||
|
show_album_menu(&ui_context, &menu_album, &anchor, x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
button.add_controller(context);
|
||||||
|
|
||||||
let ui_open = ui.clone();
|
let ui_open = ui.clone();
|
||||||
let album = album.clone();
|
let album = album.clone();
|
||||||
button.connect_clicked(move |_| open_album(&ui_open, album.clone()));
|
button.connect_clicked(move |_| open_album(&ui_open, album.clone()));
|
||||||
button
|
button
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The album card's menu. Albums shared with us belong to someone else, so
|
||||||
|
/// they only open.
|
||||||
|
fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: >k4::Widget, x: f64, y: f64) {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let (ui_c, album_c) = (ui.clone(), album.clone());
|
||||||
|
menu.item("Open", move || open_album(&ui_c, album_c.clone()));
|
||||||
|
if !album.shared {
|
||||||
|
menu.section();
|
||||||
|
let (ui_c, album_c) = (ui.clone(), album.clone());
|
||||||
|
menu.item("Rename…", move || prompt_rename_album(&ui_c, &album_c));
|
||||||
|
menu.section();
|
||||||
|
let (ui_c, album_c) = (ui.clone(), album.clone());
|
||||||
|
menu.item("Delete Album…", move || {
|
||||||
|
confirm_delete_album(&ui_c, &album_c)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
menu.popup_at(anchor, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask for an album name. `on_name` gets the trimmed name; Create stays off
|
||||||
|
/// while the entry is blank.
|
||||||
|
fn prompt_album_name(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
heading: &str,
|
||||||
|
action: &str,
|
||||||
|
current: &str,
|
||||||
|
on_name: impl Fn(String) + 'static,
|
||||||
|
) {
|
||||||
|
let win = ui_window(ui);
|
||||||
|
let dialog = adw::AlertDialog::builder().heading(heading).build();
|
||||||
|
let group = adw::PreferencesGroup::new();
|
||||||
|
let row = adw::EntryRow::builder()
|
||||||
|
.title("Album name")
|
||||||
|
.activates_default(true)
|
||||||
|
.build();
|
||||||
|
row.set_text(current);
|
||||||
|
group.add(&row);
|
||||||
|
dialog.set_extra_child(Some(&group));
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
dialog.add_response("ok", action);
|
||||||
|
dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_response_enabled("ok", !current.trim().is_empty());
|
||||||
|
dialog.set_default_response(Some("ok"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let dialog_typed = dialog.clone();
|
||||||
|
row.connect_changed(move |row| {
|
||||||
|
dialog_typed.set_response_enabled("ok", !row.text().trim().is_empty());
|
||||||
|
});
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
let name = row.text().trim().to_string();
|
||||||
|
if resp == "ok" && !name.is_empty() {
|
||||||
|
on_name(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.present(win.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>) {
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
prompt_album_name(ui, "New Album", "Create", "", move |name| {
|
||||||
|
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
|
||||||
|
let ui = ui_c.clone();
|
||||||
|
let photos = photos.clone();
|
||||||
|
ui.busy_begin();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let reply = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match reply {
|
||||||
|
Ok(Ok(Response::AlbumCreated { uid })) => {
|
||||||
|
if photos.is_empty() {
|
||||||
|
toast(&ui, "Album created");
|
||||||
|
reload_album_grid(&ui);
|
||||||
|
} else {
|
||||||
|
add_to_album(&ui, uid, photos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, "Couldn't create the album", &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't create the album",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||||
|
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||||
|
prompt_album_name(ui, "Rename Album", "Rename", &album.name, move |name| {
|
||||||
|
run_album_request(
|
||||||
|
&ui_c,
|
||||||
|
Request::RenameAlbum {
|
||||||
|
uid: uid.clone(),
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
"Album renamed",
|
||||||
|
"Couldn't rename the album",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm_delete_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||||
|
let Some(win) = ui_window(ui) else { return };
|
||||||
|
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||||
|
confirm_destructive(
|
||||||
|
&win,
|
||||||
|
"Delete Album?",
|
||||||
|
&format!(
|
||||||
|
"“{}” is deleted. Its photos stay in your timeline.",
|
||||||
|
album.name
|
||||||
|
),
|
||||||
|
"Delete",
|
||||||
|
move || {
|
||||||
|
run_album_request(
|
||||||
|
&ui_c,
|
||||||
|
Request::DeleteAlbum {
|
||||||
|
uid: uid.clone(),
|
||||||
|
delete_photos: false,
|
||||||
|
},
|
||||||
|
"Album deleted",
|
||||||
|
"Couldn't delete the album",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an album rename or delete and refresh the grid once it lands.
|
||||||
|
fn run_album_request(ui: &Rc<Ui>, request: Request, done: &'static str, failed: &'static str) {
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), request);
|
||||||
|
let ui = ui.clone();
|
||||||
|
ui.busy_begin();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let reply = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match reply {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
toast(&ui, done);
|
||||||
|
reload_album_grid(&ui);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||||
|
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refill the album grid if it is on screen; otherwise it reloads when shown.
|
||||||
|
fn reload_album_grid(ui: &Rc<Ui>) {
|
||||||
|
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
|
||||||
|
load_albums(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>) {
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let albums: Vec<AlbumInfo> = match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Albums { items, .. })) => {
|
||||||
|
items.into_iter().filter(|album| !album.shared).collect()
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, "Couldn't load albums", &message, kind);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't load albums",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if albums.is_empty() {
|
||||||
|
prompt_new_album(&ui, photos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = gtk4::ListBox::builder()
|
||||||
|
.selection_mode(gtk4::SelectionMode::None)
|
||||||
|
.build();
|
||||||
|
list.add_css_class("boxed-list");
|
||||||
|
for album in &albums {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(glib::markup_escape_text(&album.name))
|
||||||
|
.subtitle(album_subtitle(album))
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
list.append(&row);
|
||||||
|
}
|
||||||
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.propagate_natural_height(true)
|
||||||
|
.max_content_height(360)
|
||||||
|
.child(&list)
|
||||||
|
.build();
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("Add to Album")
|
||||||
|
.body(match photos.len() {
|
||||||
|
1 => "Choose an album for this photo.".to_string(),
|
||||||
|
n => format!("Choose an album for {n} photos."),
|
||||||
|
})
|
||||||
|
.extra_child(&scroll)
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
dialog.add_response("new", "New Album…");
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
|
||||||
|
let (ui_c, dialog_c, photos_c) = (ui.clone(), dialog.clone(), photos.clone());
|
||||||
|
list.connect_row_activated(move |_, row| {
|
||||||
|
if let Some(album) = albums.get(row.index().max(0) as usize) {
|
||||||
|
dialog_c.close();
|
||||||
|
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
dialog.connect_response(Some("new"), move |_, _| {
|
||||||
|
prompt_new_album(&ui_c, photos.clone())
|
||||||
|
});
|
||||||
|
dialog.present(ui_window(&ui).as_ref());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_to_album(ui: &Rc<Ui>, album: String, photos: Vec<String>) {
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::AddToAlbum { uid: album, photos },
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
ui.busy_begin();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let reply = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match reply {
|
||||||
|
Ok(Ok(Response::AlbumChanged { changed, failed })) => {
|
||||||
|
if let Some(failure) = failed.first() {
|
||||||
|
toast_error(&ui, "Some photos couldn't be added", &failure.message);
|
||||||
|
} else {
|
||||||
|
toast(
|
||||||
|
&ui,
|
||||||
|
&format!(
|
||||||
|
"Added {} to the album",
|
||||||
|
count_noun(changed.len(), "photo", "photos")
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
reload_album_grid(&ui);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, "Couldn't add to the album", &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't add to the album",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take photos out of the open album. They leave the view at once and come
|
||||||
|
/// back if the server refuses; the timeline keeps them either way.
|
||||||
|
pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<String>) {
|
||||||
|
let removed = remove_photos(ui, &photos);
|
||||||
|
set_selection_mode(ui, false);
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::RemoveFromAlbum {
|
||||||
|
uid: album.uid.clone(),
|
||||||
|
photos,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
ui.busy_begin();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let reply = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match reply {
|
||||||
|
Ok(Ok(Response::AlbumChanged { changed, failed })) => {
|
||||||
|
if !failed.is_empty() {
|
||||||
|
let kept: Vec<PhotoItem> = removed
|
||||||
|
.iter()
|
||||||
|
.filter(|photo| failed.iter().any(|f| f.uid == photo.uid))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
restore_photos(&ui, kept);
|
||||||
|
toast_error(
|
||||||
|
&ui,
|
||||||
|
"Some photos couldn't be removed from the album",
|
||||||
|
&failed[0].message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if changed.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The open album's count, as the header shows it.
|
||||||
|
let mut open = ui.gallery.album.borrow_mut();
|
||||||
|
if let Some(album) = open.as_mut() {
|
||||||
|
album.photo_count = album.photo_count.saturating_sub(changed.len());
|
||||||
|
ui.gallery.title.set_subtitle(&album_subtitle(album));
|
||||||
|
}
|
||||||
|
drop(open);
|
||||||
|
toast(
|
||||||
|
&ui,
|
||||||
|
&format!(
|
||||||
|
"Removed {} from the album",
|
||||||
|
count_noun(changed.len(), "photo", "photos")
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
restore_photos(&ui, removed);
|
||||||
|
toast_failure(&ui, "Couldn't remove from the album", &message, kind)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
restore_photos(&ui, removed);
|
||||||
|
toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't remove from the album",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// "12 photos", plus where the album came from when it isn't ours.
|
/// "12 photos", plus where the album came from when it isn't ours.
|
||||||
fn album_subtitle(album: &AlbumInfo) -> String {
|
fn album_subtitle(album: &AlbumInfo) -> String {
|
||||||
let photos = match album.photo_count {
|
let photos = match album.photo_count {
|
||||||
@@ -195,6 +564,7 @@ pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
|
|||||||
ui.gallery.title.set_title(&album.name);
|
ui.gallery.title.set_title(&album.name);
|
||||||
ui.gallery.title.set_subtitle(&album_subtitle(&album));
|
ui.gallery.title.set_subtitle(&album_subtitle(&album));
|
||||||
*ui.gallery.album.borrow_mut() = Some(album);
|
*ui.gallery.album.borrow_mut() = Some(album);
|
||||||
|
ui.gallery.timeline_stale.set(true);
|
||||||
|
|
||||||
// An album page carries no kind or date filter, and Upload targets the
|
// An album page carries no kind or date filter, and Upload targets the
|
||||||
// timeline rather than an album — hide those rather than offer controls that
|
// timeline rather than an album — hide those rather than offer controls that
|
||||||
@@ -219,7 +589,6 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.title.set_title("Photos");
|
ui.gallery.title.set_title("Photos");
|
||||||
ui.gallery.filters.set_visible(true);
|
|
||||||
ui.gallery.view_switch.set_visible(true);
|
ui.gallery.view_switch.set_visible(true);
|
||||||
ui.gallery.upload.set_visible(true);
|
ui.gallery.upload.set_visible(true);
|
||||||
ui.gallery.back.set_visible(false);
|
ui.gallery.back.set_visible(false);
|
||||||
@@ -250,15 +619,28 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
|||||||
if !btn.is_active() {
|
if !btn.is_active() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The timeline is reloaded rather than restored, because an open album
|
|
||||||
// left the model holding its own photos.
|
|
||||||
close_album(&ui_photos);
|
close_album(&ui_photos);
|
||||||
|
ui_photos.gallery.filters.set_visible(true);
|
||||||
ui_photos.gallery.content.set_visible_child_name("timeline");
|
ui_photos.gallery.content.set_visible_child_name("timeline");
|
||||||
load_gallery(&ui_photos, false);
|
// The timeline comes back as it was left unless an album took over the
|
||||||
|
// model since; then it has to be reloaded.
|
||||||
|
if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.gallery.model.n_items() == 0
|
||||||
|
{
|
||||||
|
load_gallery(&ui_photos, false);
|
||||||
|
} else {
|
||||||
|
update_gallery_subtitle(&ui_photos);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Back returns to the grid the album was opened from, scrolled where it
|
||||||
|
// was, instead of reloading it.
|
||||||
let ui_back = ui.clone();
|
let ui_back = ui.clone();
|
||||||
ui.gallery.back.clone().connect_clicked(move |_| {
|
ui.gallery.back.clone().connect_clicked(move |_| {
|
||||||
show_albums(&ui_back);
|
show_album_grid(&ui_back);
|
||||||
|
let count = ui_back.gallery.album_list.borrow().len();
|
||||||
|
ui_back
|
||||||
|
.gallery
|
||||||
|
.title
|
||||||
|
.set_subtitle(&count_noun(count, "album", "albums"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,12 @@ pub(crate) struct GalleryState {
|
|||||||
/// Leaves an open album for the grid it was opened from. Visible only while
|
/// Leaves an open album for the grid it was opened from. Visible only while
|
||||||
/// an album is open.
|
/// an album is open.
|
||||||
pub(crate) back: gtk4::Button,
|
pub(crate) back: gtk4::Button,
|
||||||
|
/// Set when an album replaced the timeline in [`Self::model`], so going
|
||||||
|
/// back to the timeline has to reload it; otherwise the timeline is shown
|
||||||
|
/// as it was left, scroll position included.
|
||||||
|
pub(crate) timeline_stale: Cell<bool>,
|
||||||
|
/// The albums the grid shows, as last listed.
|
||||||
|
pub(crate) album_list: RefCell<Vec<AlbumInfo>>,
|
||||||
/// The kind toggles and the date jump, hidden while an album is open — an
|
/// The kind toggles and the date jump, hidden while an album is open — an
|
||||||
/// album page is served whole, not filtered.
|
/// album page is served whole, not filtered.
|
||||||
pub(crate) filters: gtk4::Box,
|
pub(crate) filters: gtk4::Box,
|
||||||
@@ -149,6 +155,7 @@ pub(crate) struct GalleryState {
|
|||||||
pub(crate) select_bar: gtk4::Revealer,
|
pub(crate) select_bar: gtk4::Revealer,
|
||||||
pub(crate) select_label: gtk4::Label,
|
pub(crate) select_label: gtk4::Label,
|
||||||
pub(crate) select_trash: gtk4::Button,
|
pub(crate) select_trash: gtk4::Button,
|
||||||
|
pub(crate) select_album: gtk4::Button,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||||
@@ -302,6 +309,7 @@ pub(crate) struct GalleryWidgets {
|
|||||||
pub(crate) select_bar: gtk4::Revealer,
|
pub(crate) select_bar: gtk4::Revealer,
|
||||||
pub(crate) select_label: gtk4::Label,
|
pub(crate) select_label: gtk4::Label,
|
||||||
pub(crate) select_trash: gtk4::Button,
|
pub(crate) select_trash: gtk4::Button,
|
||||||
|
pub(crate) select_album: gtk4::Button,
|
||||||
pub(crate) select_done: gtk4::Button,
|
pub(crate) select_done: gtk4::Button,
|
||||||
/// The Albums grid, its own status page and the stack between them, plus the
|
/// The Albums grid, its own status page and the stack between them, plus the
|
||||||
/// Photos/Albums switcher and the back button out of an album.
|
/// Photos/Albums switcher and the back button out of an album.
|
||||||
@@ -454,6 +462,11 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
.sensitive(false)
|
.sensitive(false)
|
||||||
.build();
|
.build();
|
||||||
select_trash.add_css_class("destructive-action");
|
select_trash.add_css_class("destructive-action");
|
||||||
|
let select_album = gtk4::Button::builder()
|
||||||
|
.label("Add to Album…")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.sensitive(false)
|
||||||
|
.build();
|
||||||
let select_done = gtk4::Button::builder()
|
let select_done = gtk4::Button::builder()
|
||||||
.icon_name("window-close-symbolic")
|
.icon_name("window-close-symbolic")
|
||||||
.tooltip_text("Leave selection (Esc)")
|
.tooltip_text("Leave selection (Esc)")
|
||||||
@@ -465,6 +478,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
select_box.add_css_class("toolbar");
|
select_box.add_css_class("toolbar");
|
||||||
select_box.add_css_class("bulk-bar");
|
select_box.add_css_class("bulk-bar");
|
||||||
select_box.append(&select_label);
|
select_box.append(&select_label);
|
||||||
|
select_box.append(&select_album);
|
||||||
select_box.append(&select_trash);
|
select_box.append(&select_trash);
|
||||||
select_box.append(&select_done);
|
select_box.append(&select_done);
|
||||||
let select_bar = gtk4::Revealer::builder()
|
let select_bar = gtk4::Revealer::builder()
|
||||||
@@ -574,13 +588,23 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
albums_stack.set_vexpand(true);
|
albums_stack.set_vexpand(true);
|
||||||
albums_stack.add_named(&albums_scroll, Some("grid"));
|
albums_stack.add_named(&albums_scroll, Some("grid"));
|
||||||
albums_stack.add_named(&albums_status, Some("status"));
|
albums_stack.add_named(&albums_status, Some("status"));
|
||||||
|
let new_album = gtk4::Button::builder()
|
||||||
|
.icon_name("list-add-symbolic")
|
||||||
|
.label("New Album…")
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.action_name("win.new-album")
|
||||||
|
.build();
|
||||||
|
new_album.add_css_class("pill");
|
||||||
|
let albums_page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
|
albums_page.append(&new_album);
|
||||||
|
albums_page.append(&albums_stack);
|
||||||
|
|
||||||
let content = gtk4::Stack::new();
|
let content = gtk4::Stack::new();
|
||||||
content.set_vexpand(true);
|
content.set_vexpand(true);
|
||||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||||
content.add_named(&timeline, Some("timeline"));
|
content.add_named(&timeline, Some("timeline"));
|
||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
content.add_named(&albums_stack, Some("albums"));
|
content.add_named(&albums_page, Some("albums"));
|
||||||
|
|
||||||
let import_banner = adw::Banner::builder()
|
let import_banner = adw::Banner::builder()
|
||||||
.title("Importing from Google Photos…")
|
.title("Importing from Google Photos…")
|
||||||
@@ -630,6 +654,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
select_bar,
|
select_bar,
|
||||||
select_label,
|
select_label,
|
||||||
select_trash,
|
select_trash,
|
||||||
|
select_album,
|
||||||
select_done,
|
select_done,
|
||||||
tabs,
|
tabs,
|
||||||
favorites_btn,
|
favorites_btn,
|
||||||
@@ -932,6 +957,11 @@ pub(crate) fn wire_gallery(
|
|||||||
set_selection_mode(&ui_select, btn.is_active());
|
set_selection_mode(&ui_select, btn.is_active());
|
||||||
});
|
});
|
||||||
let ui_trash = ui.clone();
|
let ui_trash = ui.clone();
|
||||||
|
let ui_album = ui.clone();
|
||||||
|
ui.gallery.select_album.clone().connect_clicked(move |_| {
|
||||||
|
let uids: Vec<String> = ui_album.gallery.selected.borrow().iter().cloned().collect();
|
||||||
|
prompt_add_to_album(&ui_album, uids);
|
||||||
|
});
|
||||||
ui.gallery.select_trash.clone().connect_clicked(move |_| {
|
ui.gallery.select_trash.clone().connect_clicked(move |_| {
|
||||||
delete_selected(&ui_trash);
|
delete_selected(&ui_trash);
|
||||||
});
|
});
|
||||||
@@ -1426,6 +1456,7 @@ fn sync_selection_bar(ui: &Rc<Ui>) {
|
|||||||
n => format!("{n} selected"),
|
n => format!("{n} selected"),
|
||||||
});
|
});
|
||||||
ui.gallery.select_trash.set_sensitive(count > 0);
|
ui.gallery.select_trash.set_sensitive(count > 0);
|
||||||
|
ui.gallery.select_album.set_sensitive(count > 0);
|
||||||
ui.gallery
|
ui.gallery
|
||||||
.select_bar
|
.select_bar
|
||||||
.set_reveal_child(ui.gallery.selecting.get());
|
.set_reveal_child(ui.gallery.selecting.get());
|
||||||
@@ -1555,7 +1586,7 @@ pub(crate) fn trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
|||||||
|
|
||||||
/// Take photos out of the loaded model, returning them so a failure can put them
|
/// Take photos out of the loaded model, returning them so a failure can put them
|
||||||
/// back.
|
/// back.
|
||||||
fn remove_photos(ui: &Rc<Ui>, uids: &[String]) -> Vec<PhotoItem> {
|
pub(crate) fn remove_photos(ui: &Rc<Ui>, uids: &[String]) -> Vec<PhotoItem> {
|
||||||
let model = &ui.gallery.model;
|
let model = &ui.gallery.model;
|
||||||
let mut removed = Vec::new();
|
let mut removed = Vec::new();
|
||||||
let mut index = 0;
|
let mut index = 0;
|
||||||
@@ -1577,7 +1608,7 @@ fn remove_photos(ui: &Rc<Ui>, uids: &[String]) -> Vec<PhotoItem> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Put photos back into the loaded model, at their place in the timeline.
|
/// Put photos back into the loaded model, at their place in the timeline.
|
||||||
fn restore_photos(ui: &Rc<Ui>, photos: Vec<PhotoItem>) {
|
pub(crate) fn restore_photos(ui: &Rc<Ui>, photos: Vec<PhotoItem>) {
|
||||||
let model = &ui.gallery.model;
|
let model = &ui.gallery.model;
|
||||||
for photo in photos {
|
for photo in photos {
|
||||||
// The timeline is newest first, so a photo belongs before the first
|
// The timeline is newest first, so a photo belongs before the first
|
||||||
@@ -2001,7 +2032,7 @@ fn clone_row(row: &GalleryRow) -> GalleryRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// "1,204 photos" under the page title.
|
/// "1,204 photos" under the page title.
|
||||||
fn update_gallery_subtitle(ui: &Rc<Ui>) {
|
pub(crate) fn update_gallery_subtitle(ui: &Rc<Ui>) {
|
||||||
let loaded = ui.gallery.model.n_items() as usize;
|
let loaded = ui.gallery.model.n_items() as usize;
|
||||||
if loaded == 0 {
|
if loaded == 0 {
|
||||||
ui.gallery.title.set_subtitle("");
|
ui.gallery.title.set_subtitle("");
|
||||||
@@ -2204,6 +2235,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("Add to Album…", move || {
|
||||||
|
prompt_add_to_album(&ui_c, vec![uid.clone()])
|
||||||
|
});
|
||||||
|
let open_album = ui.gallery.album.borrow().clone();
|
||||||
|
if let Some(album) = open_album.filter(|album| !album.shared) {
|
||||||
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
|
menu.item("Remove from Album", move || {
|
||||||
|
remove_from_album(&ui_c, &album, vec![uid.clone()])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
menu.section();
|
||||||
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
menu.item("Move to Trash…", move || {
|
menu.item("Move to Trash…", move || {
|
||||||
confirm_trash_photos(&ui_c, vec![uid.clone()])
|
confirm_trash_photos(&ui_c, vec![uid.clone()])
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ Schema: **31** (unchanged).
|
|||||||
In the list view a Location column shows which folder each hit is in. Pinned files listed by the
|
In the list view a Location column shows which folder each hit is in. Pinned files listed by the
|
||||||
search prompt and `pdfs-query` show whether they are on disk, instead of always showing them as
|
search prompt and `pdfs-query` show whether they are on disk, instead of always showing them as
|
||||||
not downloaded.
|
not downloaded.
|
||||||
|
- **Album management.** The Albums view has a New Album button, and an album's right-click menu
|
||||||
|
offers Rename… and Delete Album…. Deleting an album keeps its photos in the timeline. A photo's
|
||||||
|
menu and the selection bar offer Add to Album…, and inside an album, Remove from Album takes a
|
||||||
|
photo out without trashing it. Albums shared with you only open. The CLI gains `pdfs new-album`,
|
||||||
|
`rename-album`, `delete-album`, `add-to-album` and `remove-from-album`. This needs
|
||||||
|
proton-drive-rs 0.6.6.
|
||||||
|
- **Albums view fixes.** The timeline's filter row no longer shows over the album grid. The header
|
||||||
|
counts albums. Back from an album returns to the grid where you left it instead of reloading it,
|
||||||
|
and going from Albums back to Timeline keeps the timeline's scroll position unless an album was
|
||||||
|
opened in between.
|
||||||
- **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
|
- **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
|
||||||
`pdfs favorite` command.
|
`pdfs favorite` command.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user