Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61266fc9b1 | ||
|
|
d18655538e | ||
|
|
da7ea0ec56 | ||
|
|
127b71efd0 | ||
|
|
2339b17d97 | ||
|
|
bd4f797d1f | ||
|
|
e872cad058 | ||
|
|
081c8fb889 | ||
|
|
da35a05b84 | ||
|
|
a90eeecda9 | ||
|
|
c98ed0d83b | ||
|
|
c46b2c1082 | ||
|
|
fde9c2d271 |
No files matched your search
@@ -45,7 +45,7 @@ jobs:
|
|||||||
run: pacman -Syu --noconfirm --needed git openssh pacman-contrib
|
run: pacman -Syu --noconfirm --needed git openssh pacman-contrib
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
# The updated recipes are committed back to main, not to the tag.
|
# The updated recipes are committed back to main, not to the tag.
|
||||||
ref: main
|
ref: main
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
rust:
|
rust:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install native dependencies
|
- name: Install native dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -181,7 +181,7 @@ jobs:
|
|||||||
|
|
||||||
|
|
||||||
- name: Upload Workflow Artifacts
|
- name: Upload Workflow Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: proton-drive-linux-builds
|
name: proton-drive-linux-builds
|
||||||
path: |
|
path: |
|
||||||
@@ -192,7 +192,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
*.deb
|
*.deb
|
||||||
|
|||||||
@@ -3389,6 +3389,31 @@ dependencies = [
|
|||||||
"system-deps 9.0.0",
|
"system-deps 9.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pangocairo"
|
||||||
|
version = "0.22.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bd199a0fef965b3ebda91a929a1ab5e7729538882c68e8de38e1904d2e094757"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-rs",
|
||||||
|
"glib",
|
||||||
|
"pango",
|
||||||
|
"pangocairo-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pangocairo-sys"
|
||||||
|
version = "0.22.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a649a616d67ae68539bc85f00fb45d36fba742ed20fc20e6712a15d8ee52bd3f"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-sys-rs",
|
||||||
|
"glib-sys",
|
||||||
|
"libc",
|
||||||
|
"pango-sys",
|
||||||
|
"system-deps 9.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -3449,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "2.3.0"
|
version = "2.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3464,7 +3489,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "2.3.0"
|
version = "2.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dbus-secret-service-keyring-store",
|
"dbus-secret-service-keyring-store",
|
||||||
@@ -3488,7 +3513,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "2.3.0"
|
version = "2.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"fuser",
|
"fuser",
|
||||||
@@ -3508,7 +3533,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "2.3.0"
|
version = "2.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3517,7 +3542,9 @@ dependencies = [
|
|||||||
"kamadak-exif",
|
"kamadak-exif",
|
||||||
"ksni",
|
"ksni",
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
|
"pangocairo",
|
||||||
"pdfs-core",
|
"pdfs-core",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "2.3.0"
|
version = "2.5.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ pdfs-app # the desktop app
|
|||||||
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
||||||
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
||||||
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
||||||
- **Places**: The Places view of the Photos page groups photos by the town they were taken in, using the location stored with each photo, as cards or on a world map. Towns are looked up offline in [GeoNames](https://www.geonames.org/) data (CC BY 4.0), and the map is drawn offline from [Natural Earth](https://www.naturalearthdata.com/) land outlines (public domain), so no map tiles are fetched; install `iso-codes` for country names in your language.
|
- **Places**: The Places view of the Photos page groups photos by the town they were taken in, using the location stored with each photo, as cards or on a world map. Towns are looked up offline in [GeoNames](https://www.geonames.org/) data (CC BY 4.0), and the map is drawn offline from [Natural Earth](https://www.naturalearthdata.com/) land outlines and country borders (public domain). Each town is marked with one of its photos, and zoomed in, every photo sits where it was taken. A street map from [OpenFreeMap](https://openfreemap.org/) (© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors) can be turned on under Appearance; it is off by default because the tile server learns which parts of the world you look at. Install `iso-codes` for country names in your language.
|
||||||
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
||||||
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
||||||
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
||||||
|
|||||||
@@ -3364,6 +3364,8 @@ mod login_wait_tests {
|
|||||||
shared_by: String::new(),
|
shared_by: String::new(),
|
||||||
shared_at: 0,
|
shared_at: 0,
|
||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
};
|
};
|
||||||
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
||||||
entry.shared_by = "alice@proton.me".into();
|
entry.shared_by = "alice@proton.me".into();
|
||||||
|
|||||||
@@ -128,11 +128,12 @@ pub struct AppConfig {
|
|||||||
/// field.
|
/// field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prompt: Option<PromptConfig>,
|
pub prompt: Option<PromptConfig>,
|
||||||
/// Paint the desktop app in Proton purple instead of the system accent
|
/// Paint the desktop app in Proton's colours instead of the system theme
|
||||||
/// colour. `None` means "follow the system". Defaulted for configs
|
/// and accent colour. `None` means "follow the system". Defaulted for
|
||||||
/// predating the field.
|
/// configs predating the field; also read from `proton_accent`, its name
|
||||||
#[serde(default)]
|
/// while it only changed the accent.
|
||||||
pub proton_accent: Option<bool>,
|
#[serde(default, alias = "proton_accent")]
|
||||||
|
pub proton_theme: Option<bool>,
|
||||||
/// How the desktop app's My Files page lays out and orders a folder.
|
/// How the desktop app's My Files page lays out and orders a folder.
|
||||||
/// Defaulted for configs predating the field.
|
/// Defaulted for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -147,6 +148,12 @@ pub struct AppConfig {
|
|||||||
/// Defaulted for configs predating the field.
|
/// Defaulted for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
/// Draw the Places map from OpenFreeMap's street map instead of the
|
||||||
|
/// built-in outline. Off by default: the tiles come from a third party,
|
||||||
|
/// which then learns which parts of the world are looked at. Defaulted for
|
||||||
|
/// configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub online_map: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The My Files page's view choices, remembered between runs.
|
/// The My Files page's view choices, remembered between runs.
|
||||||
@@ -216,10 +223,11 @@ impl Default for AppConfig {
|
|||||||
conflict_sweep: None,
|
conflict_sweep: None,
|
||||||
open_with: None,
|
open_with: None,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
proton_accent: None,
|
proton_theme: None,
|
||||||
files_view: FilesView::default(),
|
files_view: FilesView::default(),
|
||||||
tray_hidden: false,
|
tray_hidden: false,
|
||||||
language: None,
|
language: None,
|
||||||
|
online_map: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,7 +574,7 @@ mod tests {
|
|||||||
conflict_sweep: Some(SweepMode::Enforce),
|
conflict_sweep: Some(SweepMode::Enforce),
|
||||||
open_with: None,
|
open_with: None,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
proton_accent: Some(true),
|
proton_theme: Some(true),
|
||||||
files_view: FilesView {
|
files_view: FilesView {
|
||||||
list: true,
|
list: true,
|
||||||
sort: FileSort::Modified,
|
sort: FileSort::Modified,
|
||||||
@@ -575,13 +583,15 @@ mod tests {
|
|||||||
},
|
},
|
||||||
tray_hidden: true,
|
tray_hidden: true,
|
||||||
language: Some("de".to_string()),
|
language: Some("de".to_string()),
|
||||||
|
online_map: true,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
||||||
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
||||||
assert_eq!(decoded.proton_accent, Some(true));
|
assert_eq!(decoded.proton_theme, Some(true));
|
||||||
assert!(decoded.tray_hidden);
|
assert!(decoded.tray_hidden);
|
||||||
|
assert!(decoded.online_map);
|
||||||
assert_eq!(decoded.language.as_deref(), Some("de"));
|
assert_eq!(decoded.language.as_deref(), Some("de"));
|
||||||
assert_eq!(decoded.upload_limit, Some(500_000));
|
assert_eq!(decoded.upload_limit, Some(500_000));
|
||||||
assert_eq!(decoded.download_limit, None);
|
assert_eq!(decoded.download_limit, None);
|
||||||
@@ -598,6 +608,14 @@ mod tests {
|
|||||||
assert!(partial.folders_first);
|
assert!(partial.folders_first);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_old_proton_accent_setting_turns_on_the_proton_theme() {
|
||||||
|
let decoded: AppConfig =
|
||||||
|
serde_json::from_str(r#"{"app_version":"a","user_agent":"b","proton_accent":true}"#)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decoded.proton_theme, Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_default_app_config() {
|
fn test_default_app_config() {
|
||||||
let default_config = AppConfig::default();
|
let default_config = AppConfig::default();
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
|
|||||||
/// answers.
|
/// answers.
|
||||||
const READ_TIMEOUT: Duration = Duration::from_secs(120);
|
const READ_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// Cap on the read half for the requests front ends poll ([`Request::is_poll`]).
|
||||||
|
/// The daemon answers those from memory or its database, so a reply that takes
|
||||||
|
/// longer than this means it is stuck. Waiting the full [`READ_TIMEOUT`] would
|
||||||
|
/// freeze the poller's read-out for two minutes; failing fast lets it say "not
|
||||||
|
/// responding" and ask again on its next tick.
|
||||||
|
const POLL_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
/// A command sent from the CLI to the daemon.
|
/// A command sent from the CLI to the daemon.
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub enum Request {
|
pub enum Request {
|
||||||
@@ -91,6 +98,12 @@ pub enum Request {
|
|||||||
/// Sets of photos that are byte-identical copies of each other, going by
|
/// Sets of photos that are byte-identical copies of each other, going by
|
||||||
/// the server's content hash. Replies with [`Response::PhotoDuplicates`].
|
/// the server's content hash. Replies with [`Response::PhotoDuplicates`].
|
||||||
PhotoDuplicates,
|
PhotoDuplicates,
|
||||||
|
/// Sets of shots that look alike going by their thumbnails: resized or
|
||||||
|
/// re-encoded copies, and bursts of one moment. Starts hashing the
|
||||||
|
/// thumbnails not hashed yet in the background and replies at once with
|
||||||
|
/// the sets found so far; a front-end asks again until the reply's
|
||||||
|
/// `hashing` is `None`. Replies with [`Response::PhotoSimilar`].
|
||||||
|
PhotoSimilar,
|
||||||
/// The towns the photos were taken in, the one with the most photos first.
|
/// The towns the photos were taken in, the one with the most photos first.
|
||||||
/// Only photos whose uploader recorded a location count. Replies with
|
/// Only photos whose uploader recorded a location count. Replies with
|
||||||
/// [`Response::Places`].
|
/// [`Response::Places`].
|
||||||
@@ -103,6 +116,9 @@ pub enum Request {
|
|||||||
offset: usize,
|
offset: usize,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
},
|
},
|
||||||
|
/// Every photo taken in one town with where exactly it was taken, for the
|
||||||
|
/// map's close-up. Replies with [`Response::PhotoLocations`].
|
||||||
|
PlaceLocations { id: u32 },
|
||||||
/// List the account's photo albums, newest activity first, including the
|
/// List the account's photo albums, newest activity first, including the
|
||||||
/// albums other people share with us. Metadata only — an album's cover
|
/// albums other people share with us. Metadata only — an album's cover
|
||||||
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
|
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
|
||||||
@@ -663,8 +679,88 @@ pub enum Request {
|
|||||||
/// passes, …), so a front-end can show a running "what happened" feed
|
/// passes, …), so a front-end can show a running "what happened" feed
|
||||||
/// without re-deriving it from anywhere.
|
/// without re-deriving it from anywhere.
|
||||||
ListActivity { limit: usize },
|
ListActivity { limit: usize },
|
||||||
|
|
||||||
|
// ---- push -------------------------------------------------------------
|
||||||
|
/// Keep the connection open and stream [`Event`] lines instead of one
|
||||||
|
/// [`Response`]. See [`subscribe`] for the client side.
|
||||||
|
Subscribe,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Something a front-end shows that the daemon can say has changed, so the
|
||||||
|
/// front-end reads it again. Carried by [`Event::Changed`].
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Topic {
|
||||||
|
/// Folder listings in the mount: something was created, renamed, moved,
|
||||||
|
/// trashed or pinned, here or on another device.
|
||||||
|
Files,
|
||||||
|
/// The photos timeline or albums.
|
||||||
|
Photos,
|
||||||
|
Trash,
|
||||||
|
/// Shares, links, invitations and saved links, in either direction.
|
||||||
|
Shares,
|
||||||
|
/// The account's computers.
|
||||||
|
Devices,
|
||||||
|
/// Synced folders, their mode and their live progress.
|
||||||
|
Locations,
|
||||||
|
/// The pending-op queue ([`Request::ListPendingOps`]).
|
||||||
|
Queue,
|
||||||
|
Conflicts,
|
||||||
|
/// The activity log gained entries.
|
||||||
|
Activity,
|
||||||
|
/// A topic from a newer daemon, which this front-end cannot act on.
|
||||||
|
#[serde(other)]
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Topic {
|
||||||
|
/// Every topic a front-end can act on. A new subscription is told all of
|
||||||
|
/// them have changed, so it catches up on whatever happened while it was
|
||||||
|
/// not connected.
|
||||||
|
pub const ALL: [Topic; 9] = [
|
||||||
|
Topic::Files,
|
||||||
|
Topic::Photos,
|
||||||
|
Topic::Trash,
|
||||||
|
Topic::Shares,
|
||||||
|
Topic::Devices,
|
||||||
|
Topic::Locations,
|
||||||
|
Topic::Queue,
|
||||||
|
Topic::Conflicts,
|
||||||
|
Topic::Activity,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line on a [`Request::Subscribe`] connection.
|
||||||
|
///
|
||||||
|
/// The two things front ends show continuously, status and transfers, come
|
||||||
|
/// with their contents, so following them costs no requests. Everything else is
|
||||||
|
/// a [`Topic`] to re-read.
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub enum Event {
|
||||||
|
/// A fresh [`Response::Status`]. Sent when a subscription starts and
|
||||||
|
/// whenever any of its fields changes.
|
||||||
|
Status(Box<Response>),
|
||||||
|
/// A fresh [`Response::Transfers`] snapshot. Sent when a subscription
|
||||||
|
/// starts and whenever the snapshot changes, which is continuously while
|
||||||
|
/// something moves.
|
||||||
|
Transfers {
|
||||||
|
items: Vec<TransferItem>,
|
||||||
|
jobs: Vec<JobItem>,
|
||||||
|
},
|
||||||
|
/// These topics changed. Changes close together arrive as one event.
|
||||||
|
Changed { topics: Vec<Topic> },
|
||||||
|
/// Nothing happened. Sent every [`HEARTBEAT`] so a client can tell a quiet
|
||||||
|
/// daemon from a stuck one.
|
||||||
|
Heartbeat,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How often a quiet subscription sends [`Event::Heartbeat`].
|
||||||
|
pub const HEARTBEAT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// How long [`Subscription::next_event`] waits for a line before it gives the
|
||||||
|
/// daemon up as stuck: a few missed heartbeats.
|
||||||
|
const SUBSCRIBE_READ_TIMEOUT: Duration = Duration::from_secs(25);
|
||||||
|
|
||||||
/// Which kind of share entry a [`ShareEntry`] is, and which collection an
|
/// Which kind of share entry a [`ShareEntry`] is, and which collection an
|
||||||
/// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`] targets.
|
/// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`] targets.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -730,7 +826,7 @@ pub struct DeviceInfo {
|
|||||||
|
|
||||||
/// One `(sync-conflict …)` copy and the file it is a copy of (in
|
/// One `(sync-conflict …)` copy and the file it is a copy of (in
|
||||||
/// [`Response::Conflicts`]).
|
/// [`Response::Conflicts`]).
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
pub struct ConflictInfo {
|
pub struct ConflictInfo {
|
||||||
/// Mountpoint-relative path of the copy, or its absolute local path when it
|
/// Mountpoint-relative path of the copy, or its absolute local path when it
|
||||||
/// sits in a synced folder.
|
/// sits in a synced folder.
|
||||||
@@ -889,7 +985,7 @@ pub struct ShareEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A node's public link (in [`Response::Share`] / [`Response::PublicLink`]).
|
/// A node's public link (in [`Response::Share`] / [`Response::PublicLink`]).
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
pub struct PublicLinkInfo {
|
pub struct PublicLinkInfo {
|
||||||
/// Public-link id — the handle for [`Request::RemovePublicLink`].
|
/// Public-link id — the handle for [`Request::RemovePublicLink`].
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -902,6 +998,9 @@ pub struct PublicLinkInfo {
|
|||||||
pub expires: Option<i64>,
|
pub expires: Option<i64>,
|
||||||
/// Whether a custom password additionally protects the link.
|
/// Whether a custom password additionally protects the link.
|
||||||
pub has_password: bool,
|
pub has_password: bool,
|
||||||
|
/// When the link was made, epoch seconds; 0 when unknown.
|
||||||
|
#[serde(default)]
|
||||||
|
pub created: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One invitation addressed to me (in [`Response::Invitations`]).
|
/// One invitation addressed to me (in [`Response::Invitations`]).
|
||||||
@@ -934,7 +1033,7 @@ pub struct BookmarkInfo {
|
|||||||
/// the share's state so the "Shared" view renders in one pass: how many people
|
/// the share's state so the "Shared" view renders in one pass: how many people
|
||||||
/// have access, how many invitations are still pending, and the public link if
|
/// have access, how many invitations are still pending, and the public link if
|
||||||
/// the node has one.
|
/// the node has one.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
pub struct SharedItem {
|
pub struct SharedItem {
|
||||||
/// Node uid in `volume~link` form — the handle for opening the node's share
|
/// Node uid in `volume~link` form — the handle for opening the node's share
|
||||||
/// (via its mount path) or its details.
|
/// (via its mount path) or its details.
|
||||||
@@ -1016,7 +1115,7 @@ pub enum ActivityKind {
|
|||||||
/// One line in the daemon's activity log (in [`Response::Activity`]). Newest
|
/// One line in the daemon's activity log (in [`Response::Activity`]). Newest
|
||||||
/// first. Records a mutation or transfer the daemon performed, with enough
|
/// first. Records a mutation or transfer the daemon performed, with enough
|
||||||
/// context to read as a sentence: "Uploaded report.pdf to /docs".
|
/// context to read as a sentence: "Uploaded report.pdf to /docs".
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
pub struct ActivityEntry {
|
pub struct ActivityEntry {
|
||||||
/// When it happened, epoch seconds.
|
/// When it happened, epoch seconds.
|
||||||
pub time: i64,
|
pub time: i64,
|
||||||
@@ -1074,7 +1173,7 @@ pub struct TransferItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One entry in a [`Request::ListDir`] listing.
|
/// One entry in a [`Request::ListDir`] listing.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
||||||
pub struct DirEntry {
|
pub struct DirEntry {
|
||||||
/// Decrypted node name.
|
/// Decrypted node name.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -1122,6 +1221,16 @@ pub struct DirEntry {
|
|||||||
/// warning worth showing.
|
/// warning worth showing.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub shared_by_unverified: bool,
|
pub shared_by_unverified: bool,
|
||||||
|
/// When a [`Request::ListTrash`] entry went to the trash, epoch seconds; 0
|
||||||
|
/// when unknown, and for anything not in the trash. The API does not report
|
||||||
|
/// it to this client, so the daemon notes when it first saw the node there.
|
||||||
|
#[serde(default)]
|
||||||
|
pub trashed_at: i64,
|
||||||
|
/// The folder a [`Request::ListTrash`] entry goes back to on restore, as a
|
||||||
|
/// mountpoint-relative path (`""` for the root). `None` when that folder is
|
||||||
|
/// not known, and for anything not in the trash.
|
||||||
|
#[serde(default)]
|
||||||
|
pub trashed_from: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
||||||
@@ -1468,8 +1577,18 @@ pub struct PlaceInfo {
|
|||||||
pub cover: PhotoItem,
|
pub cover: PhotoItem,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One photo and where it was taken, in decimal degrees. Part of
|
||||||
|
/// [`Response::PhotoLocations`].
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct PhotoLocation {
|
||||||
|
pub photo: PhotoItem,
|
||||||
|
pub latitude: f64,
|
||||||
|
pub longitude: f64,
|
||||||
|
}
|
||||||
|
|
||||||
/// How many of the timeline's photos a running refresh has read the metadata
|
/// How many of the timeline's photos a running refresh has read the metadata
|
||||||
/// (and so the location) of, out of how many. Part of [`Response::Places`].
|
/// (and so the location) of, out of how many. Part of [`Response::Places`],
|
||||||
|
/// and of [`Response::PhotoSimilar`] for how many shots have been hashed.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct MappingProgress {
|
pub struct MappingProgress {
|
||||||
pub done: usize,
|
pub done: usize,
|
||||||
@@ -1837,6 +1956,17 @@ pub enum Response {
|
|||||||
/// The duplicate sets (reply to [`Request::PhotoDuplicates`]), newest
|
/// The duplicate sets (reply to [`Request::PhotoDuplicates`]), newest
|
||||||
/// first. Each set lists the copy worth keeping first.
|
/// first. Each set lists the copy worth keeping first.
|
||||||
PhotoDuplicates { sets: Vec<Vec<PhotoItem>> },
|
PhotoDuplicates { sets: Vec<Vec<PhotoItem>> },
|
||||||
|
/// The sets of alike shots (reply to [`Request::PhotoSimilar`]), newest
|
||||||
|
/// first, each in capture order; and how far hashing has got, `None`
|
||||||
|
/// once no pass is running. `uncompared` counts the shots left out
|
||||||
|
/// because no thumbnail could be read for them.
|
||||||
|
PhotoSimilar {
|
||||||
|
sets: Vec<Vec<PhotoItem>>,
|
||||||
|
#[serde(default)]
|
||||||
|
hashing: Option<MappingProgress>,
|
||||||
|
#[serde(default)]
|
||||||
|
uncompared: usize,
|
||||||
|
},
|
||||||
/// The towns the photos were taken in (reply to [`Request::PhotoPlaces`]),
|
/// The towns the photos were taken in (reply to [`Request::PhotoPlaces`]),
|
||||||
/// and how far a running timeline refresh has got reading photo locations
|
/// and how far a running timeline refresh has got reading photo locations
|
||||||
/// — `None` when none is reading any.
|
/// — `None` when none is reading any.
|
||||||
@@ -1845,6 +1975,9 @@ pub enum Response {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
mapping: Option<MappingProgress>,
|
mapping: Option<MappingProgress>,
|
||||||
},
|
},
|
||||||
|
/// The photos of one town, each with its location (reply to
|
||||||
|
/// [`Request::PlaceLocations`]).
|
||||||
|
PhotoLocations { items: Vec<PhotoLocation> },
|
||||||
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
||||||
Thumbs { items: Vec<PhotoThumb> },
|
Thumbs { items: Vec<PhotoThumb> },
|
||||||
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
|
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
|
||||||
@@ -2013,13 +2146,45 @@ impl ErrorKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Request {
|
||||||
|
/// Whether this is one of the cheap state reads that front ends repeat on a
|
||||||
|
/// timer. The daemon answers these without touching the network, so they
|
||||||
|
/// get the short [`POLL_READ_TIMEOUT`].
|
||||||
|
pub fn is_poll(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Request::Status
|
||||||
|
| Request::GetQueueStatus
|
||||||
|
| Request::ListLocations
|
||||||
|
| Request::ListPendingOps
|
||||||
|
| Request::ListConflicts
|
||||||
|
| Request::ListActivity { .. }
|
||||||
|
| Request::ThumbnailBuildStatus
|
||||||
|
| Request::PhotosRefreshStatus
|
||||||
|
| Request::RedateStatus
|
||||||
|
| Request::ImportStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long to wait for the daemon's reply to this request.
|
||||||
|
pub fn read_timeout(&self) -> Duration {
|
||||||
|
if self.is_poll() {
|
||||||
|
POLL_READ_TIMEOUT
|
||||||
|
} else {
|
||||||
|
READ_TIMEOUT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Send one [`Request`] to the daemon listening on `socket` and read its
|
/// Send one [`Request`] to the daemon listening on `socket` and read its
|
||||||
/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening.
|
/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening, and
|
||||||
|
/// one for which [`crate::Error::is_timeout`] holds if the daemon took longer
|
||||||
|
/// than [`Request::read_timeout`] to answer.
|
||||||
///
|
///
|
||||||
/// Shared by the CLI and GUI so both speak the wire format identically.
|
/// Shared by the CLI and GUI so both speak the wire format identically.
|
||||||
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
||||||
let mut stream = UnixStream::connect(socket)?;
|
let mut stream = UnixStream::connect(socket)?;
|
||||||
stream.set_read_timeout(Some(READ_TIMEOUT))?;
|
stream.set_read_timeout(Some(req.read_timeout()))?;
|
||||||
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
|
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
|
||||||
let mut line = serde_json::to_vec(req)?;
|
let mut line = serde_json::to_vec(req)?;
|
||||||
line.push(b'\n');
|
line.push(b'\n');
|
||||||
@@ -2031,10 +2196,232 @@ pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
|||||||
Ok(serde_json::from_str(resp.trim())?)
|
Ok(serde_json::from_str(resp.trim())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An open [`Request::Subscribe`] connection. See [`subscribe`].
|
||||||
|
pub struct Subscription {
|
||||||
|
reader: BufReader<UnixStream>,
|
||||||
|
line: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start following the daemon listening on `socket`. Fails if no daemon is
|
||||||
|
/// listening. A daemon too old to know [`Request::Subscribe`] answers it with an
|
||||||
|
/// error, which the first [`Subscription::next_event`] returns as
|
||||||
|
/// [`crate::Error::Other`].
|
||||||
|
pub fn subscribe(socket: &Path) -> Result<Subscription> {
|
||||||
|
let mut stream = UnixStream::connect(socket)?;
|
||||||
|
stream.set_read_timeout(Some(SUBSCRIBE_READ_TIMEOUT))?;
|
||||||
|
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
|
||||||
|
let mut line = serde_json::to_vec(&Request::Subscribe)?;
|
||||||
|
line.push(b'\n');
|
||||||
|
stream.write_all(&line)?;
|
||||||
|
stream.flush()?;
|
||||||
|
Ok(Subscription {
|
||||||
|
reader: BufReader::new(stream),
|
||||||
|
line: String::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Subscription {
|
||||||
|
/// Wait for the next event. `Ok(None)` means the daemon closed the
|
||||||
|
/// connection; a timeout means it went [`SUBSCRIBE_READ_TIMEOUT`] without
|
||||||
|
/// even a heartbeat. A line from a newer daemon that this client cannot
|
||||||
|
/// read is skipped rather than ending the subscription.
|
||||||
|
pub fn next_event(&mut self) -> Result<Option<Event>> {
|
||||||
|
loop {
|
||||||
|
self.line.clear();
|
||||||
|
if self.reader.read_line(&mut self.line)? == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let line = self.line.trim();
|
||||||
|
if let Ok(event) = serde_json::from_str::<Event>(line) {
|
||||||
|
return Ok(Some(event));
|
||||||
|
}
|
||||||
|
if let Ok(Response::Error { message, .. }) = serde_json::from_str::<Response>(line) {
|
||||||
|
return Err(crate::Error::Other(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`follow`] reports.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Feed {
|
||||||
|
/// A subscription started. The events after it describe the daemon's
|
||||||
|
/// current state, so a front-end catches up from them.
|
||||||
|
Connected,
|
||||||
|
Event(Event),
|
||||||
|
/// The subscription ended: the daemon stopped, or stopped answering.
|
||||||
|
Disconnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How often [`follow`] polls a daemon that cannot push events.
|
||||||
|
const LEGACY_POLL: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// The longest [`follow`] waits between attempts to reach a daemon.
|
||||||
|
const RECONNECT_MAX: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Follow the daemon on `socket` for as long as `on` returns true, across
|
||||||
|
/// daemon restarts. Blocks, so run it on a thread of its own.
|
||||||
|
///
|
||||||
|
/// A daemon that predates [`Request::Subscribe`] (still running after an
|
||||||
|
/// upgrade, until its service restarts) is polled for its status and transfers
|
||||||
|
/// instead, so a front-end sees the same events either way, only later.
|
||||||
|
pub fn follow(socket: &Path, mut on: impl FnMut(Feed) -> bool) {
|
||||||
|
let mut connected = false;
|
||||||
|
let mut backoff = Duration::from_secs(1);
|
||||||
|
loop {
|
||||||
|
let ended = match subscribe(socket) {
|
||||||
|
Ok(mut subscription) => {
|
||||||
|
let mut first = true;
|
||||||
|
loop {
|
||||||
|
match subscription.next_event() {
|
||||||
|
Ok(Some(event)) => {
|
||||||
|
if first {
|
||||||
|
first = false;
|
||||||
|
connected = true;
|
||||||
|
backoff = Duration::from_secs(1);
|
||||||
|
if !on(Feed::Connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !on(Feed::Event(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => break Ok(()),
|
||||||
|
Err(e) => break Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
};
|
||||||
|
if let Err(crate::Error::Other(_)) = ended {
|
||||||
|
if !connected {
|
||||||
|
connected = true;
|
||||||
|
if !on(Feed::Connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !poll_legacy(socket, &mut on) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(LEGACY_POLL);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if connected {
|
||||||
|
connected = false;
|
||||||
|
if !on(Feed::Disconnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::thread::sleep(backoff);
|
||||||
|
backoff = (backoff * 2).min(RECONNECT_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One round of [`follow`]'s fallback: the status and transfers a subscription
|
||||||
|
/// would have pushed. Returns what `on` returned.
|
||||||
|
fn poll_legacy(socket: &Path, on: &mut impl FnMut(Feed) -> bool) -> bool {
|
||||||
|
match send(socket, &Request::Status) {
|
||||||
|
Ok(status @ Response::Status { .. }) => {
|
||||||
|
if !on(Feed::Event(Event::Status(Box::new(status)))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return true,
|
||||||
|
}
|
||||||
|
if let Ok(Response::Transfers { items, jobs }) = send(socket, &Request::GetQueueStatus) {
|
||||||
|
return on(Feed::Event(Event::Transfers { items, jobs }));
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn polls_wait_briefly_and_real_work_waits_long() {
|
||||||
|
assert_eq!(Request::Status.read_timeout(), POLL_READ_TIMEOUT);
|
||||||
|
assert_eq!(
|
||||||
|
Request::ListActivity { limit: 200 }.read_timeout(),
|
||||||
|
POLL_READ_TIMEOUT
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Request::OpenFile {
|
||||||
|
path: "a.txt".into(),
|
||||||
|
uid: None,
|
||||||
|
}
|
||||||
|
.read_timeout(),
|
||||||
|
READ_TIMEOUT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A daemon on a fresh socket that reads the subscription request and
|
||||||
|
/// answers with `lines`, then hangs up.
|
||||||
|
fn fake_daemon(name: &str, lines: &'static str) -> std::path::PathBuf {
|
||||||
|
let socket = std::env::temp_dir().join(format!("pdfs-{name}-{}.sock", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&socket);
|
||||||
|
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let (stream, _) = listener.accept().unwrap();
|
||||||
|
let mut reader = BufReader::new(stream);
|
||||||
|
let mut request = String::new();
|
||||||
|
reader.read_line(&mut request).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
serde_json::from_str::<Request>(request.trim()),
|
||||||
|
Ok(Request::Subscribe)
|
||||||
|
));
|
||||||
|
reader.get_mut().write_all(lines.as_bytes()).unwrap();
|
||||||
|
});
|
||||||
|
socket
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subscription_skips_what_it_cannot_read() {
|
||||||
|
let socket = fake_daemon(
|
||||||
|
"subscribe",
|
||||||
|
"not json\n\
|
||||||
|
{\"Changed\":{\"topics\":[\"files\",\"from_a_newer_daemon\"]}}\n\
|
||||||
|
{\"NewEvent\":{}}\n\
|
||||||
|
\"Heartbeat\"\n",
|
||||||
|
);
|
||||||
|
let mut subscription = subscribe(&socket).unwrap();
|
||||||
|
let first = subscription.next_event().unwrap();
|
||||||
|
let second = subscription.next_event().unwrap();
|
||||||
|
let end = subscription.next_event().unwrap();
|
||||||
|
let _ = std::fs::remove_file(&socket);
|
||||||
|
match first {
|
||||||
|
Some(Event::Changed { topics }) => {
|
||||||
|
assert_eq!(topics, vec![Topic::Files, Topic::Unknown]);
|
||||||
|
}
|
||||||
|
other => panic!("expected a change, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(matches!(second, Some(Event::Heartbeat)), "{second:?}");
|
||||||
|
assert!(end.is_none(), "{end:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_daemon_without_subscriptions_is_told_apart_from_a_dead_one() {
|
||||||
|
let socket = fake_daemon("legacy", "{\"Error\":{\"message\":\"unknown request\"}}\n");
|
||||||
|
let mut subscription = subscribe(&socket).unwrap();
|
||||||
|
let answer = subscription.next_event();
|
||||||
|
let _ = std::fs::remove_file(&socket);
|
||||||
|
assert!(matches!(answer, Err(crate::Error::Other(_))), "{answer:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_daemon_that_never_answers_times_out_as_not_responding() {
|
||||||
|
let socket = std::env::temp_dir().join(format!("pdfs-silent-{}.sock", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&socket);
|
||||||
|
// Bound but never accepted: connect succeeds, no reply ever comes.
|
||||||
|
let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let err = send(&socket, &Request::Status).unwrap_err();
|
||||||
|
let _ = std::fs::remove_file(&socket);
|
||||||
|
assert!(err.is_timeout(), "{err}");
|
||||||
|
assert!(started.elapsed() < READ_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn photo_kind_classifies_by_extension_then_mime() {
|
fn photo_kind_classifies_by_extension_then_mime() {
|
||||||
use PhotoKind::*;
|
use PhotoKind::*;
|
||||||
@@ -2549,6 +2936,7 @@ mod tests {
|
|||||||
offset: 60,
|
offset: 60,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
},
|
},
|
||||||
|
Request::PlaceLocations { id: 2950159 },
|
||||||
] {
|
] {
|
||||||
let line = serde_json::to_string(&req).unwrap();
|
let line = serde_json::to_string(&req).unwrap();
|
||||||
let back: Request = serde_json::from_str(&line).unwrap();
|
let back: Request = serde_json::from_str(&line).unwrap();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 33;
|
pub(super) const SCHEMA_VERSION: i64 = 35;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -300,6 +300,38 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V33)?;
|
tx.execute_batch(MIGRATION_V33)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 34 {
|
||||||
|
// Same guards as V26-V33.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'similar_hash'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V34)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 35 {
|
||||||
|
// Same guards as V26-V34.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'trashed_at'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_trash: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_trash && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V35)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -1006,3 +1038,24 @@ ALTER TABLE photos ADD COLUMN place_id INTEGER;
|
|||||||
CREATE INDEX IF NOT EXISTS idx_photos_place ON photos(place_id);
|
CREATE INDEX IF NOT EXISTS idx_photos_place ON photos(place_id);
|
||||||
UPDATE photos SET resolved_at = NULL;
|
UPDATE photos SET resolved_at = NULL;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// Schema v34: what each photo looks like, for finding similar ones.
|
||||||
|
///
|
||||||
|
/// `similar_hash` is a 64-bit difference hash of the photo's thumbnail (see
|
||||||
|
/// `crate::similar`): resized and re-encoded copies of one picture land a few
|
||||||
|
/// bits apart, where the server's `content_hash` only matches byte-identical
|
||||||
|
/// files. `NULL` until the daemon has hashed the thumbnail, which it does as
|
||||||
|
/// thumbnails arrive and when the similar-photo finder asks for the rest.
|
||||||
|
const MIGRATION_V34: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN similar_hash INTEGER;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v35: when each trashed node went to the trash.
|
||||||
|
///
|
||||||
|
/// The API does not tell this client, so `trashed_at` is when the daemon first
|
||||||
|
/// saw the node in the trash, in epoch seconds. `NULL` when it cannot say: rows
|
||||||
|
/// from before this version, and nodes found by a refresh that did not follow a
|
||||||
|
/// complete one, which may have been in the trash for a long time already.
|
||||||
|
const MIGRATION_V35: &str = "
|
||||||
|
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
||||||
|
";
|
||||||
@@ -98,6 +98,8 @@ struct Learned {
|
|||||||
content_hash: Option<String>,
|
content_hash: Option<String>,
|
||||||
main_uid: Option<String>,
|
main_uid: Option<String>,
|
||||||
location: Option<(f64, f64)>,
|
location: Option<(f64, f64)>,
|
||||||
|
/// What its thumbnail looks like (see [`crate::similar`]).
|
||||||
|
similar_hash: Option<i64>,
|
||||||
/// When this photo's node was last resolved, kept so a refresh that skips it
|
/// When this photo's node was last resolved, kept so a refresh that skips it
|
||||||
/// does not make it look unresolved again.
|
/// does not make it look unresolved again.
|
||||||
resolved_at: Option<i64>,
|
resolved_at: Option<i64>,
|
||||||
@@ -293,7 +295,7 @@ impl Db {
|
|||||||
let learned: HashMap<String, Learned> = {
|
let learned: HashMap<String, Learned> = {
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
||||||
main_uid, resolved_at, latitude, longitude FROM photos",
|
main_uid, resolved_at, latitude, longitude, similar_hash FROM photos",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
Ok((
|
Ok((
|
||||||
@@ -310,6 +312,7 @@ impl Db {
|
|||||||
location: r
|
location: r
|
||||||
.get::<_, Option<f64>>(9)?
|
.get::<_, Option<f64>>(9)?
|
||||||
.zip(r.get::<_, Option<f64>>(10)?),
|
.zip(r.get::<_, Option<f64>>(10)?),
|
||||||
|
similar_hash: r.get(11)?,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
@@ -332,6 +335,10 @@ impl Db {
|
|||||||
carried.name = row.name.clone();
|
carried.name = row.name.clone();
|
||||||
carried.media_type = row.media_type.clone();
|
carried.media_type = row.media_type.clone();
|
||||||
carried.favorite = row.favorite.unwrap_or(false);
|
carried.favorite = row.favorite.unwrap_or(false);
|
||||||
|
// New bytes may show another picture: hash it again.
|
||||||
|
if row.content_hash != carried.content_hash {
|
||||||
|
carried.similar_hash = None;
|
||||||
|
}
|
||||||
carried.content_hash = row.content_hash.clone();
|
carried.content_hash = row.content_hash.clone();
|
||||||
carried.main_uid = row.main_uid.clone();
|
carried.main_uid = row.main_uid.clone();
|
||||||
carried.location = row.location;
|
carried.location = row.location;
|
||||||
@@ -373,9 +380,9 @@ impl Db {
|
|||||||
"INSERT INTO photos
|
"INSERT INTO photos
|
||||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
||||||
content_hash, main_uid, group_key, resolved_at, latitude, longitude,
|
content_hash, main_uid, group_key, resolved_at, latitude, longitude,
|
||||||
place_id)
|
place_id, similar_hash)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
||||||
?16)",
|
?16, ?17)",
|
||||||
)?;
|
)?;
|
||||||
for (seq, ((row, carried, kind), group_key)) in
|
for (seq, ((row, carried, kind), group_key)) in
|
||||||
resolved.iter().zip(group_keys.iter()).enumerate()
|
resolved.iter().zip(group_keys.iter()).enumerate()
|
||||||
@@ -400,6 +407,7 @@ impl Db {
|
|||||||
.location
|
.location
|
||||||
.and_then(|(lat, lon)| crate::places::nearest(lat, lon))
|
.and_then(|(lat, lon)| crate::places::nearest(lat, lon))
|
||||||
.map(|city| city.id),
|
.map(|city| city.id),
|
||||||
|
carried.similar_hash,
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -697,6 +705,29 @@ impl Db {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every photo taken in a town, one per group as in [`Self::photos_at_place`],
|
||||||
|
/// with where exactly it was taken. A group's own location is that of the
|
||||||
|
/// photo standing for it.
|
||||||
|
pub fn photo_locations_at_place(&self, id: u32) -> Result<Vec<(StoredPhoto, (f64, f64))>> {
|
||||||
|
let photos = self.photos_at_place(id, 0, usize::MAX >> 1)?;
|
||||||
|
let located: HashMap<String, (f64, f64)> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT uid, latitude, longitude FROM photos \
|
||||||
|
WHERE place_id = ?1 AND latitude IS NOT NULL AND longitude IS NOT NULL",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(params![id], |r| Ok((r.get(0)?, (r.get(1)?, r.get(2)?))))?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
Ok(photos
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|photo| {
|
||||||
|
let at = *located.get(&photo.uid)?;
|
||||||
|
Some((photo, at))
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets of photos with the same content hash, each set ordered with the
|
/// Sets of photos with the same content hash, each set ordered with the
|
||||||
/// copy worth keeping first, and the sets newest first.
|
/// copy worth keeping first, and the sets newest first.
|
||||||
///
|
///
|
||||||
@@ -748,6 +779,106 @@ impl Db {
|
|||||||
Ok(sets)
|
Ok(sets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shots whose look hasn't been hashed yet, by the uid of the photo the
|
||||||
|
/// grid shows for each, newest first, at most `limit`. Videos, and photos
|
||||||
|
/// with no thumbnail to hash, are left out. A shot counts as hashed once
|
||||||
|
/// any of its files is, since they all show one picture.
|
||||||
|
pub fn photos_unhashed(&self, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||||
|
let uids: Vec<String> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT COALESCE(group_key, uid) AS k FROM photos \
|
||||||
|
GROUP BY k \
|
||||||
|
HAVING MAX(similar_hash) IS NULL \
|
||||||
|
AND MAX(kind != ?1 AND thumb_state != ?2) \
|
||||||
|
ORDER BY MAX(capture_time) DESC LIMIT ?3",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(
|
||||||
|
params![
|
||||||
|
crate::control::PhotoKind::Video.as_i64(),
|
||||||
|
THUMB_NONE,
|
||||||
|
limit as i64
|
||||||
|
],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
self.photos_by_uid(&uids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the look of photo `uid`: its difference hash, or
|
||||||
|
/// [`crate::similar::NO_HASH`] when its thumbnail gave none.
|
||||||
|
pub fn photo_set_similar_hash(&self, uid: &str, hash: u64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE photos SET similar_hash = ?2 WHERE uid = ?1",
|
||||||
|
params![uid, hash as i64],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many shots have been hashed, out of how many could be.
|
||||||
|
pub fn similar_progress(&self) -> Result<(usize, usize)> {
|
||||||
|
let conn = self.read();
|
||||||
|
let (hashed, total): (i64, i64) = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FILTER (WHERE hashed), COUNT(*) FROM \
|
||||||
|
(SELECT MAX(similar_hash) IS NOT NULL AS hashed FROM photos \
|
||||||
|
GROUP BY COALESCE(group_key, uid) \
|
||||||
|
HAVING MAX(similar_hash) IS NOT NULL \
|
||||||
|
OR MAX(kind != ?1 AND thumb_state != ?2))",
|
||||||
|
params![crate::control::PhotoKind::Video.as_i64(), THUMB_NONE],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)?;
|
||||||
|
Ok((hashed.max(0) as usize, total.max(0) as usize))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets of shots that look alike (see [`crate::similar`]), one photo per
|
||||||
|
/// shot in capture order, and the sets newest first. A set whose photos
|
||||||
|
/// are all byte-identical copies is left to [`Self::photo_duplicates`].
|
||||||
|
pub fn photo_similar(&self) -> Result<Vec<Vec<StoredPhoto>>> {
|
||||||
|
let rows: Vec<(String, u64, Option<String>)> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT COALESCE(group_key, uid) AS k, MAX(similar_hash), \
|
||||||
|
MAX(content_hash) FILTER (WHERE uid = COALESCE(group_key, uid)) \
|
||||||
|
FROM photos GROUP BY k \
|
||||||
|
HAVING MAX(similar_hash) IS NOT NULL AND MAX(similar_hash) != ?1",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(params![crate::similar::NO_HASH as i64], |r| {
|
||||||
|
Ok((r.get(0)?, r.get::<_, i64>(1)? as u64, r.get(2)?))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
let hashes: Vec<u64> = rows.iter().map(|(_, hash, _)| *hash).collect();
|
||||||
|
let found: Vec<Vec<usize>> = crate::similar::sets(&hashes)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|set| {
|
||||||
|
let first = &rows[set[0]].2;
|
||||||
|
first.is_none() || set.iter().any(|&i| rows[i].2 != *first)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let uids: Vec<String> = found.iter().flatten().map(|&i| rows[i].0.clone()).collect();
|
||||||
|
let mut stored: HashMap<String, StoredPhoto> = self
|
||||||
|
.photos_by_uid(&uids)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|photo| (photo.uid.clone(), photo))
|
||||||
|
.collect();
|
||||||
|
let mut sets: Vec<Vec<StoredPhoto>> = found
|
||||||
|
.iter()
|
||||||
|
.map(|set| {
|
||||||
|
let mut photos: Vec<StoredPhoto> = set
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&i| stored.remove(&rows[i].0))
|
||||||
|
.collect();
|
||||||
|
photos.sort_by_key(|p| p.capture_time);
|
||||||
|
photos
|
||||||
|
})
|
||||||
|
.filter(|set| set.len() > 1)
|
||||||
|
.collect();
|
||||||
|
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
|
||||||
|
Ok(sets)
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||||
use crate::control::PhotoKind;
|
use crate::control::PhotoKind;
|
||||||
|
|||||||
@@ -339,6 +339,30 @@ fn places_group_photos_by_the_nearest_town() {
|
|||||||
assert_eq!(berlin, ["b2", "b1-jpeg"]);
|
assert_eq!(berlin, ["b2", "b1-jpeg"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A town's photos come with where each was taken, one per RAW+JPEG shot.
|
||||||
|
#[test]
|
||||||
|
fn place_photos_carry_their_own_location() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let kreuzberg = (52.497, 13.411);
|
||||||
|
db.photos_replace(&[
|
||||||
|
located("b2", 500, "IMG_5.JPG", BERLIN),
|
||||||
|
located("b1-jpeg", 300, "IMG_3.JPG", kreuzberg),
|
||||||
|
located("b1-raw", 300, "IMG_3.CR2", kreuzberg),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let id = db.photo_places().unwrap()[0].id;
|
||||||
|
let located: Vec<(String, (f64, f64))> = db
|
||||||
|
.photo_locations_at_place(id)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(photo, at)| (photo.uid, at))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
located,
|
||||||
|
[("b2".to_owned(), BERLIN), ("b1-jpeg".to_owned(), kreuzberg)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 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]
|
||||||
@@ -382,6 +406,76 @@ fn duplicates_list_the_copy_to_keep_first() {
|
|||||||
assert_eq!(uids, [vec!["filed", "copy"], vec!["jpeg", "stray"]]);
|
assert_eq!(uids, [vec!["filed", "copy"], vec!["jpeg", "stray"]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shots that look alike form a set, one photo per shot, while a set of
|
||||||
|
/// byte-identical copies is left to the duplicate finder.
|
||||||
|
#[test]
|
||||||
|
fn similar_photos_group_by_look_and_skip_exact_copies() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
hashed("big", 500, "IMG_1.JPG", "h1"),
|
||||||
|
hashed("small", 400, "IMG_1-small.JPG", "h2"),
|
||||||
|
hashed("copy", 300, "IMG_2.JPG", "h3"),
|
||||||
|
hashed("copy2", 200, "IMG_2(1).JPG", "h3"),
|
||||||
|
hashed("other", 100, "IMG_3.JPG", "h4"),
|
||||||
|
hashed("flat", 50, "IMG_4.JPG", "h5"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let look = 0x0f0f_3c3c_5a5a_9696_u64;
|
||||||
|
db.photo_set_similar_hash("big", look).unwrap();
|
||||||
|
db.photo_set_similar_hash("small", look ^ 0b101).unwrap();
|
||||||
|
db.photo_set_similar_hash("copy", !look).unwrap();
|
||||||
|
db.photo_set_similar_hash("copy2", !look).unwrap();
|
||||||
|
db.photo_set_similar_hash("other", look ^ 0xffff_ffff)
|
||||||
|
.unwrap();
|
||||||
|
db.photo_set_similar_hash("flat", crate::similar::NO_HASH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sets = db.photo_similar().unwrap();
|
||||||
|
let uids: Vec<Vec<&str>> = sets
|
||||||
|
.iter()
|
||||||
|
.map(|set| set.iter().map(|p| p.uid.as_str()).collect())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(uids, [vec!["small", "big"]]);
|
||||||
|
assert_eq!(db.similar_progress().unwrap(), (6, 6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is left to hash skips videos and hashed shots, and a hash survives a
|
||||||
|
/// refresh unless the photo's bytes changed.
|
||||||
|
#[test]
|
||||||
|
fn similar_hashes_are_kept_until_the_photo_changes() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let resolved = |uid: &str, hash: &str| TimelineRow {
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..hashed(uid, 100, &format!("{uid}.JPG"), hash)
|
||||||
|
};
|
||||||
|
db.photos_replace(&[
|
||||||
|
resolved("a", "h1"),
|
||||||
|
resolved("b", "h2"),
|
||||||
|
hashed("clip", 50, "clip.mp4", "h3"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
db.photo_set_similar_hash("a", 7).unwrap();
|
||||||
|
db.photo_set_similar_hash("b", 9).unwrap();
|
||||||
|
let left = |db: &Db| -> Vec<String> {
|
||||||
|
db.photos_unhashed(10)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| p.uid)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
assert!(left(&db).is_empty());
|
||||||
|
assert_eq!(db.similar_progress().unwrap(), (2, 2));
|
||||||
|
|
||||||
|
db.photos_replace(&[
|
||||||
|
resolved("a", "h1"),
|
||||||
|
resolved("b", "h2-edited"),
|
||||||
|
hashed("clip", 50, "clip.mp4", "h3"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(left(&db), ["b"]);
|
||||||
|
assert_eq!(db.similar_progress().unwrap(), (1, 2));
|
||||||
|
}
|
||||||
|
|
||||||
/// Trashing the file a group is shown as leaves the rest of the group on the
|
/// Trashing the file a group is shown as leaves the rest of the group on the
|
||||||
/// timeline instead of pointing at a photo that is gone.
|
/// timeline instead of pointing at a photo that is gone.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -489,6 +583,7 @@ fn trash_replace_lists_folders_first() {
|
|||||||
size: 10,
|
size: 10,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
parent_uid: Some("t2".into()),
|
parent_uid: Some("t2".into()),
|
||||||
|
trashed_at: Some(100),
|
||||||
},
|
},
|
||||||
StoredTrash {
|
StoredTrash {
|
||||||
uid: "t2".into(),
|
uid: "t2".into(),
|
||||||
@@ -497,6 +592,7 @@ fn trash_replace_lists_folders_first() {
|
|||||||
size: 0,
|
size: 0,
|
||||||
mtime: 2,
|
mtime: 2,
|
||||||
parent_uid: None,
|
parent_uid: None,
|
||||||
|
trashed_at: None,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -519,6 +615,15 @@ fn trash_replace_lists_folders_first() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The first-seen times round-trip, unknown ones included.
|
||||||
|
let mut seen: Vec<_> = db.trash_seen().unwrap().into_iter().collect();
|
||||||
|
seen.sort();
|
||||||
|
assert_eq!(
|
||||||
|
seen,
|
||||||
|
[("t1".to_string(), Some(100)), ("t2".to_string(), None)]
|
||||||
|
);
|
||||||
|
assert_eq!(items[1].trashed_at, Some(100));
|
||||||
|
|
||||||
// A replace is a replace: emptying the trash on the server empties it here.
|
// A replace is a replace: emptying the trash on the server empties it here.
|
||||||
db.trash_replace(&[]).unwrap();
|
db.trash_replace(&[]).unwrap();
|
||||||
assert!(db.trash_list().unwrap().is_empty());
|
assert!(db.trash_list().unwrap().is_empty());
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
@@ -15,6 +17,9 @@ pub struct StoredTrash {
|
|||||||
/// row written before schema v29, and for a node whose parent the server
|
/// row written before schema v29, and for a node whose parent the server
|
||||||
/// did not report.
|
/// did not report.
|
||||||
pub parent_uid: Option<String>,
|
pub parent_uid: Option<String>,
|
||||||
|
/// When the daemon first saw the node in the trash, epoch seconds. `None`
|
||||||
|
/// when it cannot say (see schema v35).
|
||||||
|
pub trashed_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
@@ -24,8 +29,8 @@ impl Db {
|
|||||||
tx.execute("DELETE FROM trash", [])?;
|
tx.execute("DELETE FROM trash", [])?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid) \
|
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid, trashed_at) \
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
)?;
|
)?;
|
||||||
for item in items {
|
for item in items {
|
||||||
stmt.execute(params![
|
stmt.execute(params![
|
||||||
@@ -34,7 +39,8 @@ impl Db {
|
|||||||
item.is_dir as i64,
|
item.is_dir as i64,
|
||||||
item.size,
|
item.size,
|
||||||
item.mtime,
|
item.mtime,
|
||||||
item.parent_uid
|
item.parent_uid,
|
||||||
|
item.trashed_at
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,7 +53,7 @@ impl Db {
|
|||||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, name, is_dir, size, mtime, parent_uid FROM trash
|
"SELECT uid, name, is_dir, size, mtime, parent_uid, trashed_at FROM trash
|
||||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
@@ -58,6 +64,7 @@ impl Db {
|
|||||||
size: r.get(3)?,
|
size: r.get(3)?,
|
||||||
mtime: r.get(4)?,
|
mtime: r.get(4)?,
|
||||||
parent_uid: r.get(5)?,
|
parent_uid: r.get(5)?,
|
||||||
|
trashed_at: r.get(6)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
@@ -81,5 +88,19 @@ impl Db {
|
|||||||
Ok(pairs)
|
Ok(pairs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When each node now in the trash was first seen there, keyed by uid, so
|
||||||
|
/// a refresh can carry the times over into the listing it writes.
|
||||||
|
pub fn trash_seen(&self) -> Result<HashMap<String, Option<i64>>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare("SELECT uid, trashed_at FROM trash")?;
|
||||||
|
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
|
let mut seen = HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
let (uid, at) = row?;
|
||||||
|
seen.insert(uid, at);
|
||||||
|
}
|
||||||
|
Ok(seen)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- device sync (devices.md) -----------------------------------------
|
// ---- device sync (devices.md) -----------------------------------------
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,20 @@ impl Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
/// Whether a control-socket round-trip gave up waiting for the reply: the
|
||||||
|
/// daemon is running but stuck, as opposed to not running at all.
|
||||||
|
pub fn is_timeout(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Error::Io(e) if matches!(
|
||||||
|
e.kind(),
|
||||||
|
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
/// A failure on its way to a front-end: prose for the user, plus the
|
/// A failure on its way to a front-end: prose for the user, plus the
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub mod sdkcache;
|
|||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
pub mod similar;
|
||||||
pub mod syncignore;
|
pub mod syncignore;
|
||||||
pub mod takeout;
|
pub mod takeout;
|
||||||
pub mod tray;
|
pub mod tray;
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
//! Telling photos apart by what they show rather than by their bytes.
|
||||||
|
//!
|
||||||
|
//! The server's content hash only matches byte-identical files. A photo that
|
||||||
|
//! was resized, re-encoded or sent through a messenger is another file, so the
|
||||||
|
//! similar-photo finder hashes each thumbnail instead: a 64-bit difference
|
||||||
|
//! hash ("dHash"), which compares neighbouring brightness in a 9×8 grid. Two
|
||||||
|
//! copies of one picture land a few bits apart, and different pictures about
|
||||||
|
//! half of the bits apart.
|
||||||
|
|
||||||
|
/// Hashes this many bits apart or fewer show the same picture.
|
||||||
|
pub const SIMILAR_BITS: u32 = 6;
|
||||||
|
|
||||||
|
/// Stored for a photo whose thumbnail was looked at and gave no usable hash
|
||||||
|
/// (it couldn't be decoded, or is one flat colour), so it isn't looked at
|
||||||
|
/// again. A real hash is never 0: a picture that would hash to 0 has no
|
||||||
|
/// brightness changes, which [`difference_hash`] turns down as flat.
|
||||||
|
pub const NO_HASH: u64 = 0;
|
||||||
|
|
||||||
|
/// Samples of the grid that differ by less than this in brightness, all of
|
||||||
|
/// them, make a flat picture — a black frame, a blank page — whose hash is
|
||||||
|
/// noise and would match every other flat picture.
|
||||||
|
const FLAT: u8 = 8;
|
||||||
|
|
||||||
|
/// The difference hash of an 8-bit greyscale image `width` px wide, rows
|
||||||
|
/// stored one after another in `luma`. `None` for an empty or flat image.
|
||||||
|
pub fn difference_hash(width: usize, height: usize, luma: &[u8]) -> Option<u64> {
|
||||||
|
if width == 0 || height == 0 || luma.len() < width * height {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Box-average down to 9×8, so every source pixel counts once and the hash
|
||||||
|
// doesn't depend on which pixels a nearest-neighbour pick happens to hit.
|
||||||
|
let mut grid = [[0u8; 9]; 8];
|
||||||
|
for (gy, row) in grid.iter_mut().enumerate() {
|
||||||
|
let (y0, y1) = span(gy, 8, height);
|
||||||
|
for (gx, cell) in row.iter_mut().enumerate() {
|
||||||
|
let (x0, x1) = span(gx, 9, width);
|
||||||
|
let mut sum = 0u64;
|
||||||
|
for y in y0..y1 {
|
||||||
|
sum += luma[y * width + x0..y * width + x1]
|
||||||
|
.iter()
|
||||||
|
.map(|&v| u64::from(v))
|
||||||
|
.sum::<u64>();
|
||||||
|
}
|
||||||
|
*cell = (sum / ((y1 - y0) * (x1 - x0)) as u64) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (min, max) = grid
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.fold((u8::MAX, 0), |(min, max), &v| (min.min(v), max.max(v)));
|
||||||
|
if max - min < FLAT {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut hash = 0u64;
|
||||||
|
for row in &grid {
|
||||||
|
for pair in row.windows(2) {
|
||||||
|
hash = hash << 1 | u64::from(pair[0] < pair[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(hash != NO_HASH).then_some(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source pixels `from..to` that cell `i` of `cells` covers along an edge
|
||||||
|
/// of `len` px; never empty, even for an edge shorter than the grid.
|
||||||
|
fn span(i: usize, cells: usize, len: usize) -> (usize, usize) {
|
||||||
|
let from = (i * len / cells).min(len - 1);
|
||||||
|
let to = ((i + 1) * len / cells).max(from + 1);
|
||||||
|
(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two hashes show the same picture.
|
||||||
|
pub fn alike(a: u64, b: u64) -> bool {
|
||||||
|
(a ^ b).count_ones() <= SIMILAR_BITS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group `hashes` into sets of alike ones, by index, each set in index order
|
||||||
|
/// and the sets by their first index. A hash alike to none other is in no
|
||||||
|
/// set. Alike is taken as catching: a burst of shots each a little different
|
||||||
|
/// from the last is one set.
|
||||||
|
pub fn sets(hashes: &[u64]) -> Vec<Vec<usize>> {
|
||||||
|
// Two hashes at most SIMILAR_BITS apart agree on at least one of their
|
||||||
|
// eight bytes, as long as SIMILAR_BITS < 8. So only hashes sharing some
|
||||||
|
// byte are compared, not every pair of the library.
|
||||||
|
const _: () = assert!(SIMILAR_BITS < 8);
|
||||||
|
let mut parent: Vec<usize> = (0..hashes.len()).collect();
|
||||||
|
fn root(parent: &mut [usize], mut i: usize) -> usize {
|
||||||
|
while parent[i] != i {
|
||||||
|
parent[i] = parent[parent[i]];
|
||||||
|
i = parent[i];
|
||||||
|
}
|
||||||
|
i
|
||||||
|
}
|
||||||
|
for byte in 0..8 {
|
||||||
|
let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); 256];
|
||||||
|
for (i, &hash) in hashes.iter().enumerate() {
|
||||||
|
buckets[(hash >> (byte * 8)) as u8 as usize].push(i);
|
||||||
|
}
|
||||||
|
for bucket in &buckets {
|
||||||
|
for (n, &a) in bucket.iter().enumerate() {
|
||||||
|
for &b in &bucket[n + 1..] {
|
||||||
|
if alike(hashes[a], hashes[b]) {
|
||||||
|
let (ra, rb) = (root(&mut parent, a), root(&mut parent, b));
|
||||||
|
if ra != rb {
|
||||||
|
parent[ra.max(rb)] = ra.min(rb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut by_root: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
|
||||||
|
for i in 0..hashes.len() {
|
||||||
|
let r = root(&mut parent, i);
|
||||||
|
by_root.entry(r).or_default().push(i);
|
||||||
|
}
|
||||||
|
by_root.into_values().filter(|set| set.len() > 1).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A 90×80 picture: a bright disc on a gradient.
|
||||||
|
fn picture(width: usize, height: usize) -> Vec<u8> {
|
||||||
|
let mut luma = Vec::with_capacity(width * height);
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let (fx, fy) = (x as f64 / width as f64, y as f64 / height as f64);
|
||||||
|
let disc = ((fx - 0.3).powi(2) + (fy - 0.6).powi(2)).sqrt() < 0.2;
|
||||||
|
let v = if disc { 240.0 } else { 40.0 + 120.0 * fx * fy };
|
||||||
|
luma.push(v as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
luma
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resized_copy_hashes_alike() {
|
||||||
|
let big = difference_hash(900, 800, &picture(900, 800)).unwrap();
|
||||||
|
let small = difference_hash(91, 79, &picture(91, 79)).unwrap();
|
||||||
|
assert!(
|
||||||
|
alike(big, small),
|
||||||
|
"{} bits apart",
|
||||||
|
(big ^ small).count_ones()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_different_picture_hashes_apart() {
|
||||||
|
let one = difference_hash(90, 80, &picture(90, 80)).unwrap();
|
||||||
|
let mirrored: Vec<u8> = picture(90, 80)
|
||||||
|
.chunks(90)
|
||||||
|
.flat_map(|row| row.iter().rev().copied().collect::<Vec<_>>())
|
||||||
|
.collect();
|
||||||
|
let other = difference_hash(90, 80, &mirrored).unwrap();
|
||||||
|
assert!(!alike(one, other));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_flat_picture_has_no_hash() {
|
||||||
|
assert_eq!(difference_hash(64, 64, &[17; 64 * 64]), None);
|
||||||
|
assert_eq!(difference_hash(0, 0, &[]), None);
|
||||||
|
assert_eq!(difference_hash(4, 4, &[0; 3]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_picture_smaller_than_the_grid_still_hashes() {
|
||||||
|
let luma: Vec<u8> = (0..4 * 3).map(|i| (i * 20) as u8).collect();
|
||||||
|
assert!(difference_hash(4, 3, &luma).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alike_hashes_form_sets_and_catch_on() {
|
||||||
|
let a = 0x0123_4567_89ab_cdef_u64;
|
||||||
|
let hashes = [
|
||||||
|
a,
|
||||||
|
!a,
|
||||||
|
a ^ 0b111,
|
||||||
|
a ^ 0b111 ^ (0b1111 << 40),
|
||||||
|
0x5555_0000_ffff_aaaa,
|
||||||
|
];
|
||||||
|
assert_eq!(sets(&hashes), vec![vec![0, 2, 3]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashes_apart_in_most_bytes_are_still_found() {
|
||||||
|
// One bit apart in each of six bytes: six bits in all, and only the
|
||||||
|
// top two bytes agree.
|
||||||
|
let a = 1u64 << 63;
|
||||||
|
let b = (0..6).fold(a, |h, byte| h | 1 << (byte * 8));
|
||||||
|
assert_eq!(sets(&[a, b]), vec![vec![0, 1]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,26 @@ pub fn is_transient_name(name: &str) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `name` is scratch another program writes and throws away: the
|
||||||
|
/// [`is_transient_name`] set, plus the save-by-rename temporaries of GTK, Qt,
|
||||||
|
/// Chromium and Electron, generic `.tmp` files and Emacs lock files. None of
|
||||||
|
/// them is a file the user made, so a front-end can hide them from a history
|
||||||
|
/// of what happened to the user's files.
|
||||||
|
pub fn is_scratch_name(name: &str) -> bool {
|
||||||
|
if is_transient_name(name) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
lower.ends_with(".tmp")
|
||||||
|
|| lower.ends_with(".temp")
|
||||||
|
|| name.starts_with(".goutputstream-")
|
||||||
|
|| name.starts_with(".org.chromium.")
|
||||||
|
|| name.starts_with(".com.google.chrome.")
|
||||||
|
|| name.starts_with(".#")
|
||||||
|
|| (name.starts_with('#') && name.ends_with('#') && name.len() > 1)
|
||||||
|
|| (name.starts_with('.') && name.contains(".tmp."))
|
||||||
|
}
|
||||||
|
|
||||||
/// Filenames accepted as a folder's ignore file, in precedence order.
|
/// Filenames accepted as a folder's ignore file, in precedence order.
|
||||||
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
||||||
|
|
||||||
@@ -341,6 +361,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_by_rename_temporaries_are_scratch() {
|
||||||
|
for name in [
|
||||||
|
".goutputstream-4XK2B2",
|
||||||
|
".org.chromium.Chromium.aBc123",
|
||||||
|
"~$report.docx",
|
||||||
|
".~lock.budget.ods#",
|
||||||
|
".#notes.org",
|
||||||
|
"#notes.org#",
|
||||||
|
"cache.tmp",
|
||||||
|
"page.html.crdownload",
|
||||||
|
] {
|
||||||
|
assert!(is_scratch_name(name), "{name}");
|
||||||
|
}
|
||||||
|
for name in ["report.docx", "notes.org", "#1 hits.txt", ".bashrc"] {
|
||||||
|
assert!(!is_scratch_name(name), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finished_names_are_not_transient() {
|
fn finished_names_are_not_transient() {
|
||||||
for name in [
|
for name in [
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use pdfs_core::control::{AlbumInfo, PhotoItem};
|
use pdfs_core::control::{AlbumInfo, PhotoItem, Topic};
|
||||||
use pdfs_core::db::StoredAlbum;
|
use pdfs_core::db::StoredAlbum;
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
|
||||||
@@ -137,10 +137,12 @@ impl Core {
|
|||||||
}
|
}
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
self.rt.spawn(async move {
|
self.rt.spawn(async move {
|
||||||
if let Err(e) = core.refresh_albums().await {
|
let refreshed = core.refresh_albums().await;
|
||||||
warn!(error = %e, "background album refresh failed");
|
|
||||||
}
|
|
||||||
core.albums_refreshing.store(false, Ordering::SeqCst);
|
core.albums_refreshing.store(false, Ordering::SeqCst);
|
||||||
|
match refreshed {
|
||||||
|
Ok(_) => core.events.publish(&[Topic::Photos]),
|
||||||
|
Err(e) => warn!(error = %e, "background album refresh failed"),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -422,6 +422,10 @@ pub(super) async fn run_event_sync(
|
|||||||
cursor = Some(applied);
|
cursor = Some(applied);
|
||||||
}
|
}
|
||||||
flush_dirty_parents(&core, &dirty);
|
flush_dirty_parents(&core, &dirty);
|
||||||
|
// Another device changed the tree: whatever a front-end lists may have
|
||||||
|
// moved, including what is shared and what is in the trash.
|
||||||
|
core.events
|
||||||
|
.publish(&[Topic::Files, Topic::Trash, Topic::Shares, Topic::Conflicts]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +483,7 @@ pub(super) async fn run_photos_event_sync(core: Core) {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
apply_photo_events(&core, &events);
|
apply_photo_events(&core, &events);
|
||||||
|
core.events.publish(&[Topic::Photos]);
|
||||||
// One cursor write per batch, after the batch is applied: a crash in
|
// One cursor write per batch, after the batch is applied: a crash in
|
||||||
// between replays events whose effects are idempotent.
|
// between replays events whose effects are idempotent.
|
||||||
if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) {
|
if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
ActivityKind, Event, HEARTBEAT, PhotoMonth, RefreshScope, Request as CtlRequest,
|
||||||
TransferDirection, TrashFailure,
|
Response as CtlResponse, Topic, TransferDirection, TrashFailure,
|
||||||
};
|
};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
@@ -286,6 +286,155 @@ impl Drop for RefreshGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The reply to [`CtlRequest::Status`], which subscriptions are also sent
|
||||||
|
/// whenever it changes.
|
||||||
|
pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) -> CtlResponse {
|
||||||
|
let pins = core.cache.list_pins();
|
||||||
|
let queued = core.db.pending_op_counts().unwrap_or_default();
|
||||||
|
let staging = core.cache.staging_usage();
|
||||||
|
CtlResponse::Status {
|
||||||
|
parked_uploads: queued.parked.max(0) as u64,
|
||||||
|
failing_ops: queued.failing.max(0) as u64,
|
||||||
|
failing_error: queued.last_error.clone(),
|
||||||
|
staged_bytes: staging.bytes,
|
||||||
|
staged_oldest_secs: staging.oldest_secs,
|
||||||
|
paused: core.sync_pause().is_some(),
|
||||||
|
paused_until: core.sync_pause().flatten(),
|
||||||
|
username: username.to_string(),
|
||||||
|
mountpoint: mountpoint.display().to_string(),
|
||||||
|
pinned: pins.len(),
|
||||||
|
used: core.cache.usage(),
|
||||||
|
budget: core.cache.budget(),
|
||||||
|
pins,
|
||||||
|
online: core.online.load(Ordering::Relaxed),
|
||||||
|
pending_uploads: queued.uploads.max(0) as u64,
|
||||||
|
pending_changes: queued.changes.max(0) as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a front-end shows that `request` changes when it succeeds. Requests
|
||||||
|
/// from the CLI publish the same way as the app's, so an open window follows
|
||||||
|
/// a change made in a terminal.
|
||||||
|
fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||||
|
use CtlRequest as R;
|
||||||
|
match request {
|
||||||
|
R::Pin { .. } | R::Unpin { .. } => &[Topic::Files],
|
||||||
|
R::CreateAlbum { .. }
|
||||||
|
| R::RenameAlbum { .. }
|
||||||
|
| R::DeleteAlbum { .. }
|
||||||
|
| R::AddToAlbum { .. }
|
||||||
|
| R::RemoveFromAlbum { .. }
|
||||||
|
| R::SetPhotoFavorite { .. }
|
||||||
|
| R::UploadPhoto { .. } => &[Topic::Photos],
|
||||||
|
R::TrashNodes { .. } => &[Topic::Files, Topic::Photos, Topic::Trash, Topic::Conflicts],
|
||||||
|
R::Rename { .. } | R::Move { .. } | R::CreateFolder { .. } | R::UploadPaths { .. } => {
|
||||||
|
&[Topic::Files, Topic::Conflicts]
|
||||||
|
}
|
||||||
|
R::Delete { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
||||||
|
R::Restore { .. } | R::DeleteForever { .. } | R::EmptyTrash => {
|
||||||
|
&[Topic::Files, Topic::Photos, Topic::Trash, Topic::Conflicts]
|
||||||
|
}
|
||||||
|
R::Refresh { scope } => match scope {
|
||||||
|
RefreshScope::Dir { .. } => &[Topic::Files],
|
||||||
|
RefreshScope::Trash => &[Topic::Trash],
|
||||||
|
// A photos refresh answers once it has started; the timeline
|
||||||
|
// publishes again when it lands.
|
||||||
|
RefreshScope::Photos { .. } => &[],
|
||||||
|
},
|
||||||
|
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||||
|
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||||
|
R::AddSyncFolder { .. }
|
||||||
|
| R::RemoveSyncFolder { .. }
|
||||||
|
| R::SetSyncFolderMode { .. }
|
||||||
|
| R::SetSyncFolderPaused { .. }
|
||||||
|
| R::RestoreSyncFolders { .. }
|
||||||
|
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
||||||
|
R::RetryPendingOp { .. } => &[Topic::Queue],
|
||||||
|
R::ResolveConflict { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
||||||
|
R::ShareNode { .. }
|
||||||
|
| R::ShareNodeByUid { .. }
|
||||||
|
| R::UpdateShareRole { .. }
|
||||||
|
| R::UpdateShareRoleByUid { .. }
|
||||||
|
| R::RemoveShareEntry { .. }
|
||||||
|
| R::RemoveShareEntryByUid { .. }
|
||||||
|
| R::CreatePublicLink { .. }
|
||||||
|
| R::CreatePublicLinkByUid { .. }
|
||||||
|
| R::RemovePublicLink { .. }
|
||||||
|
| R::RemovePublicLinkByUid { .. }
|
||||||
|
| R::StopSharing { .. }
|
||||||
|
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
|
||||||
|
R::LeaveShared { .. }
|
||||||
|
| R::AcceptInvitation { .. }
|
||||||
|
| R::RejectInvitation { .. }
|
||||||
|
| R::CreateBookmark { .. }
|
||||||
|
| R::DeleteBookmark { .. } => &[Topic::Shares],
|
||||||
|
R::RestoreRevision { .. }
|
||||||
|
| R::RestoreRevisionByUid { .. }
|
||||||
|
| R::SaveRevisionAs { .. }
|
||||||
|
| R::SaveRevisionAsByUid { .. } => &[Topic::Files],
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a subscription's writes may block. A client that stops reading
|
||||||
|
/// is dropped after this rather than holding its handler forever.
|
||||||
|
const SUBSCRIPTION_WRITE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Serve a [`CtlRequest::Subscribe`]: send the current status and transfers
|
||||||
|
/// and say every topic changed, so the client catches up, then send whatever
|
||||||
|
/// the [`EventHub`](super::events::EventHub) collects, with a heartbeat when
|
||||||
|
/// nothing does. Ends when the client goes away or the daemon stops.
|
||||||
|
fn serve_subscription(core: &Core, username: &str, mountpoint: &Path, mut stream: UnixStream) {
|
||||||
|
let Some(mailbox) = core.events.subscribe() else {
|
||||||
|
warn!("control: refusing a subscription at the limit");
|
||||||
|
if let Ok(mut out) = serde_json::to_vec(&CtlResponse::error(CoreError::remote(
|
||||||
|
"too many subscriptions",
|
||||||
|
))) {
|
||||||
|
out.push(b'\n');
|
||||||
|
let _ = stream.write_all(&out);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
struct Unsubscribe<'a>(&'a Core, std::sync::Arc<super::events::Mailbox>);
|
||||||
|
impl Drop for Unsubscribe<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.events.unsubscribe(&self.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _unsubscribe = Unsubscribe(core, mailbox.clone());
|
||||||
|
if let Err(e) = stream.set_write_timeout(Some(SUBSCRIPTION_WRITE_TIMEOUT)) {
|
||||||
|
warn!(error = %e, "control: could not set subscription timeout");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let heartbeat = super::events::event_line(&Event::Heartbeat);
|
||||||
|
let mut lines: Vec<_> = [
|
||||||
|
super::events::event_line(&Event::Status(Box::new(status_response(
|
||||||
|
core, username, mountpoint,
|
||||||
|
)))),
|
||||||
|
super::events::transfers_line(core.transfers.snapshot(), core.jobs_snapshot()),
|
||||||
|
super::events::event_line(&Event::Changed {
|
||||||
|
topics: Topic::ALL.to_vec(),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
loop {
|
||||||
|
for line in &lines {
|
||||||
|
if stream.write_all(line.as_bytes()).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if core.shutdown.is_stopping() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lines = mailbox.take(HEARTBEAT);
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.extend(heartbeat.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
||||||
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
||||||
warn!(error = %e, "control: could not set request timeout");
|
warn!(error = %e, "control: could not set request timeout");
|
||||||
@@ -310,32 +459,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let request = serde_json::from_str::<CtlRequest>(line.trim());
|
||||||
|
// A subscription lasts as long as its client, so it is not a request in
|
||||||
|
// flight and stays out of the diagnostics' list of them.
|
||||||
|
if let Ok(CtlRequest::Subscribe) = request {
|
||||||
|
serve_subscription(core, username, mountpoint, stream);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let touched = request.as_ref().map_or(&[][..], touched_topics);
|
||||||
// Registered before the request is even parsed, so a handler that wedges
|
// Registered before the request is even parsed, so a handler that wedges
|
||||||
// inside the daemon shows up by name in `pdfs diagnostics`.
|
// inside the daemon shows up by name in `pdfs diagnostics`.
|
||||||
let _inflight = super::diagnostics::InflightGuard::new(super::diagnostics::request_kind(&line));
|
let _inflight = super::diagnostics::InflightGuard::new(super::diagnostics::request_kind(&line));
|
||||||
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
|
let response = match request {
|
||||||
Ok(CtlRequest::Status) => {
|
Ok(CtlRequest::Status) => status_response(core, username, mountpoint),
|
||||||
let pins = core.cache.list_pins();
|
Ok(CtlRequest::Subscribe) => {
|
||||||
let queued = core.db.pending_op_counts().unwrap_or_default();
|
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
|
||||||
let staging = core.cache.staging_usage();
|
|
||||||
CtlResponse::Status {
|
|
||||||
parked_uploads: queued.parked.max(0) as u64,
|
|
||||||
failing_ops: queued.failing.max(0) as u64,
|
|
||||||
failing_error: queued.last_error.clone(),
|
|
||||||
staged_bytes: staging.bytes,
|
|
||||||
staged_oldest_secs: staging.oldest_secs,
|
|
||||||
paused: core.sync_pause().is_some(),
|
|
||||||
paused_until: core.sync_pause().flatten(),
|
|
||||||
username: username.to_string(),
|
|
||||||
mountpoint: mountpoint.display().to_string(),
|
|
||||||
pinned: pins.len(),
|
|
||||||
used: core.cache.usage(),
|
|
||||||
budget: core.cache.budget(),
|
|
||||||
pins,
|
|
||||||
online: core.online.load(Ordering::Relaxed),
|
|
||||||
pending_uploads: queued.uploads.max(0) as u64,
|
|
||||||
pending_changes: queued.changes.max(0) as u64,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.pin(&rel) {
|
Ok(rel) => match core.pin(&rel) {
|
||||||
@@ -465,6 +603,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
|
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::PhotoSimilar) => match core.photo_similar() {
|
||||||
|
Ok((sets, hashing, uncompared)) => CtlResponse::PhotoSimilar {
|
||||||
|
sets,
|
||||||
|
hashing,
|
||||||
|
uncompared,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::PhotoPlaces) => match core.photo_places() {
|
Ok(CtlRequest::PhotoPlaces) => match core.photo_places() {
|
||||||
Ok(items) => CtlResponse::Places {
|
Ok(items) => CtlResponse::Places {
|
||||||
items,
|
items,
|
||||||
@@ -482,6 +628,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::PlaceLocations { id }) => match core.place_locations(id) {
|
||||||
|
Ok(items) => CtlResponse::PhotoLocations { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::PhotosOnThisDay { limit }) => {
|
Ok(CtlRequest::PhotosOnThisDay { limit }) => {
|
||||||
match core.photos_on_this_day(clamp_limit(limit)) {
|
match core.photos_on_this_day(clamp_limit(limit)) {
|
||||||
Ok(items) => CtlResponse::Photos {
|
Ok(items) => CtlResponse::Photos {
|
||||||
@@ -1572,6 +1722,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
// The request did not parse: the caller sent something malformed.
|
// The request did not parse: the caller sent something malformed.
|
||||||
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
||||||
};
|
};
|
||||||
|
if !touched.is_empty() && !matches!(response, CtlResponse::Error { .. }) {
|
||||||
|
core.events.publish(touched);
|
||||||
|
}
|
||||||
let mut out = match serde_json::to_vec(&response) {
|
let mut out = match serde_json::to_vec(&response) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -1184,6 +1184,10 @@ impl Core {
|
|||||||
format!("{reason}; local changes uploaded as {alt}"),
|
format!("{reason}; local changes uploaded as {alt}"),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
self.events.publish(&[
|
||||||
|
pdfs_core::control::Topic::Files,
|
||||||
|
pdfs_core::control::Topic::Conflicts,
|
||||||
|
]);
|
||||||
info!(%uid, name, alt, "queued write landed as a conflict copy");
|
info!(%uid, name, alt, "queued write landed as a conflict copy");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
//! Push notifications for front-ends: the daemon's side of
|
||||||
|
//! [`Request::Subscribe`](pdfs_core::control::Request::Subscribe).
|
||||||
|
//!
|
||||||
|
//! Front-ends used to poll. The app asked for the status every two seconds, the
|
||||||
|
//! tray every three, and pages re-read themselves on a timer, whether or not
|
||||||
|
//! anything had changed. Now each subscription owns a [`Mailbox`] in the
|
||||||
|
//! [`EventHub`]. Code that changes something publishes a [`Topic`], and
|
||||||
|
//! [`run_event_sampler`] watches the few things that change without passing
|
||||||
|
//! through one place (the status, transfers, sync progress) and publishes them
|
||||||
|
//! when they differ.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use parking_lot::{Condvar, Mutex, MutexGuard};
|
||||||
|
use pdfs_core::control::{Event, JobItem, Response as CtlResponse, Topic, TransferItem};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
|
||||||
|
/// Most subscriptions served at once. Each holds a control handler for as long
|
||||||
|
/// as it lasts; the app and the tray need one apiece, and the cap keeps a
|
||||||
|
/// misbehaving client from spending the handlers that requests need.
|
||||||
|
const MAX_SUBSCRIBERS: usize = 8;
|
||||||
|
|
||||||
|
/// How long a subscription waits after a change before it sends, so a burst
|
||||||
|
/// (a folder of files trashed at once) goes out as one event.
|
||||||
|
const COALESCE: Duration = Duration::from_millis(150);
|
||||||
|
|
||||||
|
/// How often [`run_event_sampler`] looks at transfers. Status and sync
|
||||||
|
/// progress are looked at every other time.
|
||||||
|
const SAMPLE_INTERVAL: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
/// Every open subscription's mailbox. Shared by every clone of [`Core`],
|
||||||
|
/// on-demand forks included, so a change in any mount reaches every client.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct EventHub {
|
||||||
|
mailboxes: Mutex<Vec<Arc<Mailbox>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one subscription has yet to send.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Mailbox {
|
||||||
|
inbox: Mutex<Inbox>,
|
||||||
|
ready: Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Inbox {
|
||||||
|
topics: BTreeSet<Topic>,
|
||||||
|
/// The newest [`Event::Status`] line. A newer one replaces it unsent: a
|
||||||
|
/// client wants the current status, not every status in between.
|
||||||
|
status: Option<Arc<str>>,
|
||||||
|
/// The newest [`Event::Transfers`] line, replaced the same way.
|
||||||
|
transfers: Option<Arc<str>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Inbox {
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.topics.is_empty() && self.status.is_none() && self.transfers.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `event` as one line of the wire format, newline included.
|
||||||
|
pub(crate) fn event_line(event: &Event) -> Option<Arc<str>> {
|
||||||
|
match serde_json::to_string(event) {
|
||||||
|
Ok(mut line) => {
|
||||||
|
line.push('\n');
|
||||||
|
Some(line.into())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "events: serialize event failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventHub {
|
||||||
|
/// Tell every subscriber that `topics` changed.
|
||||||
|
pub(crate) fn publish(&self, topics: &[Topic]) {
|
||||||
|
self.deliver(|inbox| inbox.topics.extend(topics.iter().copied()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand every subscriber a fresh status.
|
||||||
|
fn publish_status(&self, line: Arc<str>) {
|
||||||
|
self.deliver(|inbox| inbox.status = Some(line.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand every subscriber a fresh transfers snapshot.
|
||||||
|
fn publish_transfers(&self, line: Arc<str>) {
|
||||||
|
self.deliver(|inbox| inbox.transfers = Some(line.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deliver(&self, put: impl Fn(&mut Inbox)) {
|
||||||
|
for mailbox in self.mailboxes.lock().iter() {
|
||||||
|
put(&mut mailbox.inbox.lock());
|
||||||
|
mailbox.ready.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a mailbox, or `None` at [`MAX_SUBSCRIBERS`].
|
||||||
|
pub(crate) fn subscribe(&self) -> Option<Arc<Mailbox>> {
|
||||||
|
let mut mailboxes = self.mailboxes.lock();
|
||||||
|
if mailboxes.len() >= MAX_SUBSCRIBERS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mailbox = Arc::new(Mailbox::default());
|
||||||
|
mailboxes.push(mailbox.clone());
|
||||||
|
Some(mailbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unsubscribe(&self, mailbox: &Arc<Mailbox>) {
|
||||||
|
self.mailboxes
|
||||||
|
.lock()
|
||||||
|
.retain(|other| !Arc::ptr_eq(other, mailbox));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_subscribers(&self) -> bool {
|
||||||
|
!self.mailboxes.lock().is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mailbox {
|
||||||
|
/// Wait up to `timeout` for something to send, then take it as wire lines:
|
||||||
|
/// the status first, then transfers, then the changed topics. Empty when
|
||||||
|
/// nothing arrived in time.
|
||||||
|
pub(crate) fn take(&self, timeout: Duration) -> Vec<Arc<str>> {
|
||||||
|
let mut inbox = self.inbox.lock();
|
||||||
|
if inbox.is_empty() {
|
||||||
|
self.ready.wait_for(&mut inbox, timeout);
|
||||||
|
if inbox.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// Let the rest of a burst arrive before sending the first of it.
|
||||||
|
MutexGuard::unlocked(&mut inbox, || std::thread::sleep(COALESCE));
|
||||||
|
}
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
lines.extend(inbox.status.take());
|
||||||
|
lines.extend(inbox.transfers.take());
|
||||||
|
if !inbox.topics.is_empty() {
|
||||||
|
let topics = std::mem::take(&mut inbox.topics).into_iter().collect();
|
||||||
|
lines.extend(event_line(&Event::Changed { topics }));
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`Event::Transfers`] line for a snapshot.
|
||||||
|
pub(crate) fn transfers_line(items: Vec<TransferItem>, jobs: Vec<JobItem>) -> Option<Arc<str>> {
|
||||||
|
event_line(&Event::Transfers { items, jobs })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch what changes without a single place to publish it from, and publish it
|
||||||
|
/// when it differs from the last look: the status (queue counts, cache use,
|
||||||
|
/// online, pause), the transfers snapshot, and the synced folders with their
|
||||||
|
/// live progress. Looks only while someone is subscribed.
|
||||||
|
pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::path::PathBuf) {
|
||||||
|
let mut transfers: Option<Arc<str>> = None;
|
||||||
|
let mut status: Option<String> = None;
|
||||||
|
let mut queue: Option<String> = None;
|
||||||
|
let mut locations: Option<String> = None;
|
||||||
|
let mut tick = 0u32;
|
||||||
|
while core.shutdown.sleep(SAMPLE_INTERVAL) {
|
||||||
|
if !core.events.has_subscribers() {
|
||||||
|
// A new subscription starts from a snapshot of its own, so there is
|
||||||
|
// nothing to compare against until then.
|
||||||
|
transfers = None;
|
||||||
|
status = None;
|
||||||
|
queue = None;
|
||||||
|
locations = None;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let line = transfers_line(core.transfers.snapshot(), core.jobs_snapshot());
|
||||||
|
if line.is_some() && line != transfers {
|
||||||
|
transfers = line.clone();
|
||||||
|
if let Some(line) = line {
|
||||||
|
core.events.publish_transfers(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick = tick.wrapping_add(1);
|
||||||
|
if !tick.is_multiple_of(2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let now = super::control::status_response(&core, &username, &mountpoint);
|
||||||
|
let (key, queue_key) = status_keys(&now);
|
||||||
|
if status.as_ref().is_some_and(|last| *last != key)
|
||||||
|
&& let Some(line) = event_line(&Event::Status(Box::new(now)))
|
||||||
|
{
|
||||||
|
core.events.publish_status(line);
|
||||||
|
}
|
||||||
|
status = Some(key);
|
||||||
|
if queue.as_ref().is_some_and(|last| *last != queue_key) {
|
||||||
|
core.events.publish(&[Topic::Queue]);
|
||||||
|
}
|
||||||
|
queue = Some(queue_key);
|
||||||
|
|
||||||
|
if let Ok(now) = core.list_locations() {
|
||||||
|
let key = serde_json::to_string(&now).unwrap_or_default();
|
||||||
|
if locations.as_ref().is_some_and(|last| *last != key) {
|
||||||
|
core.events.publish(&[Topic::Locations]);
|
||||||
|
}
|
||||||
|
locations = Some(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What makes two statuses different enough to send, and what makes their
|
||||||
|
/// queues different. The age of the oldest staged write is left out of both:
|
||||||
|
/// it grows every second while anything is staged, which is not news.
|
||||||
|
fn status_keys(status: &CtlResponse) -> (String, String) {
|
||||||
|
let CtlResponse::Status {
|
||||||
|
parked_uploads,
|
||||||
|
failing_ops,
|
||||||
|
failing_error,
|
||||||
|
staged_bytes,
|
||||||
|
paused,
|
||||||
|
paused_until,
|
||||||
|
username,
|
||||||
|
mountpoint,
|
||||||
|
pinned,
|
||||||
|
used,
|
||||||
|
budget,
|
||||||
|
online,
|
||||||
|
pending_uploads,
|
||||||
|
pending_changes,
|
||||||
|
..
|
||||||
|
} = status
|
||||||
|
else {
|
||||||
|
return (String::new(), String::new());
|
||||||
|
};
|
||||||
|
let queue = format!(
|
||||||
|
"{pending_uploads}/{pending_changes}/{parked_uploads}/{failing_ops}/{failing_error:?}"
|
||||||
|
);
|
||||||
|
let key = format!(
|
||||||
|
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
|
||||||
|
{used}/{budget}/{online}"
|
||||||
|
);
|
||||||
|
(key, queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn changed(line: &str) -> Vec<Topic> {
|
||||||
|
match serde_json::from_str::<Event>(line.trim()) {
|
||||||
|
Ok(Event::Changed { topics }) => topics,
|
||||||
|
other => panic!("not a change event: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_burst_of_changes_arrives_as_one_event() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||||
|
hub.publish(&[Topic::Trash, Topic::Files]);
|
||||||
|
hub.publish(&[Topic::Files]);
|
||||||
|
let lines = mailbox.take(Duration::from_secs(1));
|
||||||
|
assert_eq!(lines.len(), 1);
|
||||||
|
assert_eq!(changed(&lines[0]), vec![Topic::Files, Topic::Trash]);
|
||||||
|
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_newer_status_replaces_one_not_yet_sent() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||||
|
hub.publish_status("first\n".into());
|
||||||
|
hub.publish_status("second\n".into());
|
||||||
|
assert_eq!(
|
||||||
|
mailbox.take(Duration::from_secs(1)),
|
||||||
|
vec![Arc::<str>::from("second\n")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_closed_subscription_gets_nothing_more() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||||
|
hub.unsubscribe(&mailbox);
|
||||||
|
hub.publish(&[Topic::Files]);
|
||||||
|
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
|
||||||
|
assert!(!hub.has_subscribers());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subscriptions_are_capped() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let open: Vec<_> = (0..MAX_SUBSCRIBERS)
|
||||||
|
.map(|_| hub.subscribe().expect("room for a subscriber"))
|
||||||
|
.collect();
|
||||||
|
assert!(hub.subscribe().is_none());
|
||||||
|
hub.unsubscribe(&open[0]);
|
||||||
|
assert!(hub.subscribe().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_age_of_a_staged_write_is_not_a_change() {
|
||||||
|
let status = |staged_oldest_secs| CtlResponse::Status {
|
||||||
|
parked_uploads: 0,
|
||||||
|
failing_ops: 0,
|
||||||
|
failing_error: None,
|
||||||
|
staged_bytes: 10,
|
||||||
|
staged_oldest_secs,
|
||||||
|
paused: false,
|
||||||
|
paused_until: None,
|
||||||
|
username: "u".into(),
|
||||||
|
mountpoint: "/m".into(),
|
||||||
|
pinned: 0,
|
||||||
|
used: 0,
|
||||||
|
budget: 0,
|
||||||
|
pins: Vec::new(),
|
||||||
|
online: true,
|
||||||
|
pending_uploads: 1,
|
||||||
|
pending_changes: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(status_keys(&status(5)), status_keys(&status(6)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode};
|
|||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
||||||
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||||
ThumbnailBuildStatus, TransferDirection,
|
ThumbnailBuildStatus, Topic, TransferDirection,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::{
|
use pdfs_core::db::{
|
||||||
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
|
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
|
||||||
@@ -86,6 +86,7 @@ use control::run_control_socket;
|
|||||||
mod devices;
|
mod devices;
|
||||||
mod diagnostics;
|
mod diagnostics;
|
||||||
mod drain;
|
mod drain;
|
||||||
|
mod events;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
pub use filesystem::ProtonFs;
|
pub use filesystem::ProtonFs;
|
||||||
mod mount;
|
mod mount;
|
||||||
@@ -271,6 +272,12 @@ const PHOTOS_AVAILABLE: &str = "photos_available";
|
|||||||
const ALBUMS_SYNCED_MS: &str = "albums_synced_ms";
|
const ALBUMS_SYNCED_MS: &str = "albums_synced_ms";
|
||||||
const ALBUM_SYNCED_PREFIX: &str = "album_synced_ms:";
|
const ALBUM_SYNCED_PREFIX: &str = "album_synced_ms:";
|
||||||
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
|
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
|
||||||
|
/// Set when a trash refresh finishes and cleared when one starts, so it is only
|
||||||
|
/// present while the stored listing is the whole trash as of some moment. A node
|
||||||
|
/// that a refresh finds and that listing lacks went to the trash since then; one
|
||||||
|
/// found after an interrupted refresh may have been there all along. Unlike
|
||||||
|
/// [`TRASH_SYNCED_MS`], our own mutations leave it alone.
|
||||||
|
const TRASH_COMPLETE_MS: &str = "trash_complete_ms";
|
||||||
const SHARED_WITH_ME_NAME: &str = "shared_with_me_name";
|
const SHARED_WITH_ME_NAME: &str = "shared_with_me_name";
|
||||||
const SHARED_WITH_ME_SYNCED_MS: &str = "shared_with_me_synced_ms";
|
const SHARED_WITH_ME_SYNCED_MS: &str = "shared_with_me_synced_ms";
|
||||||
const SHARED_FOLDER_SYNCED_PREFIX: &str = "shared_folder_synced_ms:";
|
const SHARED_FOLDER_SYNCED_PREFIX: &str = "shared_folder_synced_ms:";
|
||||||
@@ -557,6 +564,9 @@ struct Core {
|
|||||||
/// The same, for the album listing.
|
/// The same, for the album listing.
|
||||||
albums_refreshing: Arc<AtomicBool>,
|
albums_refreshing: Arc<AtomicBool>,
|
||||||
trash_refreshing: Arc<AtomicBool>,
|
trash_refreshing: Arc<AtomicBool>,
|
||||||
|
/// True while the similar-photo pass hashes thumbnails (see
|
||||||
|
/// [`Core::photo_similar`]), so asking again doesn't start a second one.
|
||||||
|
similar_hashing: Arc<AtomicBool>,
|
||||||
/// Fires whenever a trash refresh persists a batch or finishes, so a
|
/// Fires whenever a trash refresh persists a batch or finishes, so a
|
||||||
/// `ListTrash` request waiting on a first-ever refresh wakes on progress
|
/// `ListTrash` request waiting on a first-ever refresh wakes on progress
|
||||||
/// instead of polling or blocking for the whole run. See
|
/// instead of polling or blocking for the whole run. See
|
||||||
@@ -656,6 +666,9 @@ struct Core {
|
|||||||
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared
|
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared
|
||||||
/// across the FUSE session and the control-socket task.
|
/// across the FUSE session and the control-socket task.
|
||||||
transfers: Arc<TransferRegistry>,
|
transfers: Arc<TransferRegistry>,
|
||||||
|
/// Open [`Request::Subscribe`](pdfs_core::control::Request::Subscribe)
|
||||||
|
/// connections, which every change a front-end shows is published to.
|
||||||
|
events: Arc<events::EventHub>,
|
||||||
/// True while the background scanner is rebuilding the local-file index, so
|
/// True while the background scanner is rebuilding the local-file index, so
|
||||||
/// `SearchLocal` can tell a front-end "still indexing" apart from "no match".
|
/// `SearchLocal` can tell a front-end "still indexing" apart from "no match".
|
||||||
indexing: Arc<AtomicBool>,
|
indexing: Arc<AtomicBool>,
|
||||||
@@ -3812,6 +3825,8 @@ impl Core {
|
|||||||
shared_by: String::new(),
|
shared_by: String::new(),
|
||||||
shared_at: 0,
|
shared_at: 0,
|
||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -4403,12 +4418,21 @@ impl Core {
|
|||||||
self.await_trash_refresh(TRASH_FIRST_WAIT);
|
self.await_trash_refresh(TRASH_FIRST_WAIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Most of a trash comes from a handful of folders.
|
||||||
|
let mut origins: HashMap<String, Option<String>> = HashMap::new();
|
||||||
Ok(self
|
Ok(self
|
||||||
.db
|
.db
|
||||||
.trash_list()
|
.trash_list()
|
||||||
.map_err(CoreError::from)?
|
.map_err(CoreError::from)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|item| DirEntry {
|
.map(|item| DirEntry {
|
||||||
|
trashed_from: item.parent_uid.as_ref().and_then(|parent| {
|
||||||
|
origins
|
||||||
|
.entry(parent.clone())
|
||||||
|
.or_insert_with(|| self.db.node_path(parent).ok().flatten())
|
||||||
|
.clone()
|
||||||
|
}),
|
||||||
|
trashed_at: item.trashed_at.unwrap_or(0),
|
||||||
name: item.name,
|
name: item.name,
|
||||||
is_dir: item.is_dir,
|
is_dir: item.is_dir,
|
||||||
size: item.size.max(0) as u64,
|
size: item.size.max(0) as u64,
|
||||||
@@ -4447,6 +4471,18 @@ impl Core {
|
|||||||
"trash refresh: enumerated"
|
"trash refresh: enumerated"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// What the listing held before, and whether it was the whole trash: the
|
||||||
|
// chunks below overwrite it, so both are read up front.
|
||||||
|
let seen = self.db.trash_seen().unwrap_or_default();
|
||||||
|
let complete = self
|
||||||
|
.db
|
||||||
|
.state_i64(TRASH_COMPLETE_MS)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
let _ = self.db.clear_state(TRASH_COMPLETE_MS);
|
||||||
|
let now = now_secs();
|
||||||
|
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
self.db.trash_replace(&[]).map_err(CoreError::from)?;
|
self.db.trash_replace(&[]).map_err(CoreError::from)?;
|
||||||
}
|
}
|
||||||
@@ -4467,6 +4503,7 @@ impl Core {
|
|||||||
// The folder the node will go back to, which is also what tells
|
// The folder the node will go back to, which is also what tells
|
||||||
// a restore whether that folder is itself in the trash.
|
// a restore whether that folder is itself in the trash.
|
||||||
parent_uid: node.parent_uid.as_ref().map(|uid| uid.to_string()),
|
parent_uid: node.parent_uid.as_ref().map(|uid| uid.to_string()),
|
||||||
|
trashed_at: first_seen_in_trash(&seen, &node.uid.to_string(), complete, now),
|
||||||
}));
|
}));
|
||||||
// Cumulative, so the table is always a prefix of the real trash
|
// Cumulative, so the table is always a prefix of the real trash
|
||||||
// rather than a mix of this refresh and the last one.
|
// rather than a mix of this refresh and the last one.
|
||||||
@@ -4481,6 +4518,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms());
|
let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms());
|
||||||
|
let _ = self.db.set_state_i64(TRASH_COMPLETE_MS, now_ms());
|
||||||
info!(
|
info!(
|
||||||
count = items.len(),
|
count = items.len(),
|
||||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||||
@@ -4499,10 +4537,12 @@ impl Core {
|
|||||||
}
|
}
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
self.rt.spawn(async move {
|
self.rt.spawn(async move {
|
||||||
if let Err(e) = core.refresh_trash().await {
|
let refreshed = core.refresh_trash().await;
|
||||||
warn!(error = %e, "background trash refresh failed");
|
|
||||||
}
|
|
||||||
core.trash_refreshing.store(false, Ordering::SeqCst);
|
core.trash_refreshing.store(false, Ordering::SeqCst);
|
||||||
|
match refreshed {
|
||||||
|
Ok(_) => core.events.publish(&[Topic::Trash]),
|
||||||
|
Err(e) => warn!(error = %e, "background trash refresh failed"),
|
||||||
|
}
|
||||||
// After the flag, so a waiter that wakes on this sees it cleared.
|
// After the flag, so a waiter that wakes on this sees it cleared.
|
||||||
core.trash_progress.notify_waiters();
|
core.trash_progress.notify_waiters();
|
||||||
});
|
});
|
||||||
@@ -4793,6 +4833,14 @@ impl Core {
|
|||||||
};
|
};
|
||||||
if let Err(e) = self.db.activity_add(&entry) {
|
if let Err(e) = self.db.activity_add(&entry) {
|
||||||
warn!(error = ?e, "could not record activity");
|
warn!(error = ?e, "could not record activity");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A sync pass is how a conflict copy reaches the listing, and the sweep
|
||||||
|
// reports the ones it finds as conflicts.
|
||||||
|
if matches!(entry.kind, ActivityKind::Conflict | ActivityKind::Sync) {
|
||||||
|
self.events.publish(&[Topic::Activity, Topic::Conflicts]);
|
||||||
|
} else {
|
||||||
|
self.events.publish(&[Topic::Activity]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4994,6 +5042,7 @@ fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo {
|
|||||||
role: role_to_str(link.role).to_string(),
|
role: role_to_str(link.role).to_string(),
|
||||||
expires: link.expiration_time,
|
expires: link.expiration_time,
|
||||||
has_password: link.has_custom_password,
|
has_password: link.has_custom_password,
|
||||||
|
created: link.creation_time,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5426,6 +5475,25 @@ fn node_size(node: &Node) -> u64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When a node a trash refresh found went to the trash, as far as the daemon
|
||||||
|
/// can tell. A node the stored listing already had keeps the time it had. A new
|
||||||
|
/// one went there since the last refresh, so now is close, but only when that
|
||||||
|
/// refresh saw the whole trash (`complete`): after a first or interrupted one,
|
||||||
|
/// a node missing from the listing may be years old, and no date beats a wrong
|
||||||
|
/// one.
|
||||||
|
fn first_seen_in_trash(
|
||||||
|
seen: &HashMap<String, Option<i64>>,
|
||||||
|
uid: &str,
|
||||||
|
complete: bool,
|
||||||
|
now: i64,
|
||||||
|
) -> Option<i64> {
|
||||||
|
match seen.get(uid) {
|
||||||
|
Some(at) => *at,
|
||||||
|
None if complete => Some(now),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Expand a restore request across the trashed tree, and order it so a parent is
|
/// Expand a restore request across the trashed tree, and order it so a parent is
|
||||||
/// always restored before its children.
|
/// always restored before its children.
|
||||||
///
|
///
|
||||||
@@ -6217,8 +6285,8 @@ mod tests {
|
|||||||
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
||||||
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
||||||
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore,
|
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore,
|
||||||
fuse_name, is_stale_mount, node_visible, note_self_change, orphan_past_retention,
|
first_seen_in_trash, fuse_name, is_stale_mount, node_visible, note_self_change,
|
||||||
parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
|
orphan_past_retention, parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
|
||||||
publish_virtual_root_in_listing, reconcile_virtual_root_in_listing,
|
publish_virtual_root_in_listing, reconcile_virtual_root_in_listing,
|
||||||
release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry,
|
release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry,
|
||||||
rename_needs_queue, require_node_parent_access, require_rename_access,
|
rename_needs_queue, require_node_parent_access, require_rename_access,
|
||||||
@@ -6361,6 +6429,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A node keeps the time it was first seen in the trash; a new one is dated
|
||||||
|
/// now only when the previous refresh saw the whole trash.
|
||||||
|
#[test]
|
||||||
|
fn trash_dates_are_kept_and_only_guessed_after_a_complete_refresh() {
|
||||||
|
let seen = HashMap::from([("old".to_string(), Some(5)), ("unknown".to_string(), None)]);
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "old", true, 9), Some(5));
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "old", false, 9), Some(5));
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "unknown", true, 9), None);
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "new", true, 9), Some(9));
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "new", false, 9), None);
|
||||||
|
}
|
||||||
|
|
||||||
/// A parent chain that loops back on itself must terminate.
|
/// A parent chain that loops back on itself must terminate.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_cyclic_listing_terminates() {
|
fn a_cyclic_listing_terminates() {
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ pub fn mount(
|
|||||||
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
timeline_progress: Arc::new(Mutex::new(None)),
|
timeline_progress: Arc::new(Mutex::new(None)),
|
||||||
|
similar_hashing: Arc::new(AtomicBool::new(false)),
|
||||||
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||||
@@ -415,6 +416,7 @@ pub fn mount(
|
|||||||
notifier: Arc::new(OnceLock::new()),
|
notifier: Arc::new(OnceLock::new()),
|
||||||
session_live: Arc::new(AtomicBool::new(false)),
|
session_live: Arc::new(AtomicBool::new(false)),
|
||||||
transfers: TransferRegistry::new(),
|
transfers: TransferRegistry::new(),
|
||||||
|
events: Arc::new(Default::default()),
|
||||||
indexing: Arc::new(AtomicBool::new(false)),
|
indexing: Arc::new(AtomicBool::new(false)),
|
||||||
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
||||||
sync_tx,
|
sync_tx,
|
||||||
@@ -567,6 +569,22 @@ pub fn mount(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Publishes what changes without one place to publish it from (status,
|
||||||
|
// transfers, sync progress) to the front-ends subscribed on that socket.
|
||||||
|
{
|
||||||
|
let sampler_core = core.clone();
|
||||||
|
let username = username.clone();
|
||||||
|
let mountpoint = mountpoint.to_path_buf();
|
||||||
|
match std::thread::Builder::new()
|
||||||
|
.name("pdfs-events".into())
|
||||||
|
.spawn(move || events::run_event_sampler(sampler_core, username, mountpoint))
|
||||||
|
{
|
||||||
|
Ok(handle) => workers.push(handle),
|
||||||
|
// Not fatal: front-ends still get every published change, only
|
||||||
|
// not the sampled ones.
|
||||||
|
Err(error) => warn!(error = %error, "could not start the event sampler"),
|
||||||
|
}
|
||||||
|
}
|
||||||
// The supervisor watches the pool and answers the systemd watchdog. Started
|
// The supervisor watches the pool and answers the systemd watchdog. Started
|
||||||
// after the listener, because its liveness probe is a round trip over that
|
// after the listener, because its liveness probe is a round trip over that
|
||||||
// socket.
|
// socket.
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoThumb, PlaceInfo,
|
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
|
||||||
ThumbnailBuildStatus, is_raw_image_name, is_thumbnail_image_name,
|
ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::{self, StoredPhoto};
|
use pdfs_core::db::{self, StoredPhoto};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
@@ -43,6 +43,8 @@ const THUMB_QUALITY: u8 = 82;
|
|||||||
/// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise
|
/// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise
|
||||||
/// saturate the link and starve the rest of the daemon.
|
/// saturate the link and starve the rest of the daemon.
|
||||||
pub(crate) const THUMB_GEN_CONCURRENCY: usize = 4;
|
pub(crate) const THUMB_GEN_CONCURRENCY: usize = 4;
|
||||||
|
/// Shots the similar-photo pass fetches thumbnails for at a time.
|
||||||
|
const SIMILAR_CHUNK: usize = 64;
|
||||||
/// Images processed between recursive-build progress updates.
|
/// Images processed between recursive-build progress updates.
|
||||||
const THUMB_BUILD_CHUNK: usize = 16;
|
const THUMB_BUILD_CHUNK: usize = 16;
|
||||||
/// A recursive build may wait briefly for an opportunistic tile job that
|
/// A recursive build may wait briefly for an opportunistic tile job that
|
||||||
@@ -81,6 +83,46 @@ fn ratio_of(bytes: &[u8]) -> Option<f64> {
|
|||||||
(height > 0).then(|| f64::from(width) / f64::from(height))
|
(height > 0).then(|| f64::from(width) / f64::from(height))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The difference hash of each cached thumbnail in `paths`, keyed like the
|
||||||
|
/// input, or [`pdfs_core::similar::NO_HASH`] for one that can't be decoded or
|
||||||
|
/// shows no picture. Decoding is spread over the machine's cores.
|
||||||
|
fn similar_hashes(paths: Vec<(String, PathBuf)>) -> Vec<(String, u64)> {
|
||||||
|
fn hash(path: &Path) -> u64 {
|
||||||
|
image::ImageReader::open(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|reader| reader.with_guessed_format().ok())
|
||||||
|
.and_then(|reader| reader.decode().ok())
|
||||||
|
.and_then(|image| {
|
||||||
|
let luma = image.to_luma8();
|
||||||
|
pdfs_core::similar::difference_hash(
|
||||||
|
luma.width() as usize,
|
||||||
|
luma.height() as usize,
|
||||||
|
luma.as_raw(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(pdfs_core::similar::NO_HASH)
|
||||||
|
}
|
||||||
|
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||||
|
let per_thread = paths.len().div_ceil(threads).max(1);
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
let workers: Vec<_> = paths
|
||||||
|
.chunks(per_thread)
|
||||||
|
.map(|chunk| {
|
||||||
|
scope.spawn(move || {
|
||||||
|
chunk
|
||||||
|
.iter()
|
||||||
|
.map(|(uid, path)| (uid.clone(), hash(path)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
workers
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|worker| worker.join().unwrap_or_default())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// A thumbnail the daemon made itself, and the aspect ratio of the photo it was
|
/// A thumbnail the daemon made itself, and the aspect ratio of the photo it was
|
||||||
/// made from (free at that point — the full image had to be decoded anyway).
|
/// made from (free at that point — the full image had to be decoded anyway).
|
||||||
struct GeneratedThumb {
|
struct GeneratedThumb {
|
||||||
@@ -751,6 +793,83 @@ impl Core {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The sets of shots that look alike, as wire items (see
|
||||||
|
/// [`db::Db::photo_similar`]), how far hashing has got while it runs, and
|
||||||
|
/// how many shots couldn't be compared once it's done. Starts a hashing
|
||||||
|
/// pass for the shots not hashed yet unless one is running.
|
||||||
|
pub(crate) fn photo_similar(
|
||||||
|
&self,
|
||||||
|
) -> CoreResult<(Vec<Vec<PhotoItem>>, Option<MappingProgress>, usize)> {
|
||||||
|
self.spawn_similar_hashing();
|
||||||
|
let sets = self.db.photo_similar().map_err(CoreError::from)?;
|
||||||
|
let (done, total) = self.db.similar_progress().map_err(CoreError::from)?;
|
||||||
|
let hashing = self
|
||||||
|
.similar_hashing
|
||||||
|
.load(Ordering::SeqCst)
|
||||||
|
.then_some(MappingProgress { done, total });
|
||||||
|
let uncompared = if hashing.is_some() { 0 } else { total - done };
|
||||||
|
let sets = sets
|
||||||
|
.into_iter()
|
||||||
|
.map(|set| set.into_iter().map(|p| self.photo_item(p)).collect())
|
||||||
|
.collect();
|
||||||
|
Ok((sets, hashing, uncompared))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_similar_hashing(&self) {
|
||||||
|
if self.similar_hashing.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let core = self.clone();
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("pdfs-similar".into())
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(e) = core.hash_similar() {
|
||||||
|
warn!(error = %e, "similar-photo hashing failed");
|
||||||
|
}
|
||||||
|
core.similar_hashing.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
if let Err(e) = spawned {
|
||||||
|
warn!(error = %e, "could not start similar-photo hashing");
|
||||||
|
self.similar_hashing.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash the look of every shot not hashed yet, a chunk at a time. Only
|
||||||
|
/// thumbnails the server has are used: a photo without one is skipped
|
||||||
|
/// rather than downloaded whole, and tried again on the next pass.
|
||||||
|
fn hash_similar(&self) -> CoreResult<()> {
|
||||||
|
let mut tried: HashSet<String> = HashSet::new();
|
||||||
|
loop {
|
||||||
|
if self.shutdown.is_stopping() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Shots tried this pass without a thumbnail are still unhashed and
|
||||||
|
// come back first, so ask past them.
|
||||||
|
let chunk: Vec<NodeUid> = self
|
||||||
|
.db
|
||||||
|
.photos_unhashed(tried.len() + SIMILAR_CHUNK)
|
||||||
|
.map_err(CoreError::from)?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|photo| tried.insert(photo.uid.clone()))
|
||||||
|
.take(SIMILAR_CHUNK)
|
||||||
|
.filter_map(|photo| parse_uid(&photo.uid))
|
||||||
|
.collect();
|
||||||
|
if chunk.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let paths: Vec<(String, PathBuf)> = self
|
||||||
|
.photo_thumbs_with(&chunk, false)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|thumb| Some((thumb.uid, PathBuf::from(thumb.path?))))
|
||||||
|
.collect();
|
||||||
|
for (uid, hash) in similar_hashes(paths) {
|
||||||
|
self.db
|
||||||
|
.photo_set_similar_hash(&uid, hash)
|
||||||
|
.map_err(CoreError::from)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The towns the photos were taken in, as wire items. A town the bundled
|
/// The towns the photos were taken in, as wire items. A town the bundled
|
||||||
/// table no longer knows (the stored id came from an older table) is left
|
/// table no longer knows (the stored id came from an older table) is left
|
||||||
/// out rather than shown without a name.
|
/// out rather than shown without a name.
|
||||||
@@ -787,6 +906,22 @@ impl Core {
|
|||||||
Ok(photos.into_iter().map(|p| self.photo_item(p)).collect())
|
Ok(photos.into_iter().map(|p| self.photo_item(p)).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every photo taken in town `id` with its own location, as wire items.
|
||||||
|
pub(crate) fn place_locations(&self, id: u32) -> CoreResult<Vec<PhotoLocation>> {
|
||||||
|
let photos = self
|
||||||
|
.db
|
||||||
|
.photo_locations_at_place(id)
|
||||||
|
.map_err(CoreError::from)?;
|
||||||
|
Ok(photos
|
||||||
|
.into_iter()
|
||||||
|
.map(|(photo, (latitude, longitude))| PhotoLocation {
|
||||||
|
photo: self.photo_item(photo),
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Photos taken on today's date in earlier years, as wire items. Served
|
/// Photos taken on today's date in earlier years, as wire items. Served
|
||||||
/// from the stored timeline only: the strip is a nicety, not worth a wait
|
/// from the stored timeline only: the strip is a nicety, not worth a wait
|
||||||
/// on the network, and the timeline's own load keeps that store fresh.
|
/// on the network, and the timeline's own load keeps that store fresh.
|
||||||
@@ -945,6 +1080,13 @@ impl Core {
|
|||||||
/// milliseconds where the raw costs a full download and a raw decode. The
|
/// milliseconds where the raw costs a full download and a raw decode. The
|
||||||
/// tile still answers under the uid that was asked for.
|
/// tile still answers under the uid that was asked for.
|
||||||
pub(crate) fn photo_thumbs(&self, requested: &[NodeUid]) -> Vec<PhotoThumb> {
|
pub(crate) fn photo_thumbs(&self, requested: &[NodeUid]) -> Vec<PhotoThumb> {
|
||||||
|
self.photo_thumbs_with(requested, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::photo_thumbs`], with local generation only when `generate` is
|
||||||
|
/// set. Without it, a photo the server has no thumbnail for is left
|
||||||
|
/// without one rather than downloaded whole.
|
||||||
|
fn photo_thumbs_with(&self, requested: &[NodeUid], generate: bool) -> Vec<PhotoThumb> {
|
||||||
let ttype = ThumbnailType::Thumbnail.as_i32();
|
let ttype = ThumbnailType::Thumbnail.as_i32();
|
||||||
// Only a raw can be stood in for, and that is one batched read; the
|
// Only a raw can be stood in for, and that is one batched read; the
|
||||||
// per-shot group lookup is then paid for raws alone, not for a page of
|
// per-shot group lookup is then paid for raws alone, not for a page of
|
||||||
@@ -1045,7 +1187,7 @@ impl Core {
|
|||||||
// that never generated one. Off the request path: a full-size photo
|
// that never generated one. Off the request path: a full-size photo
|
||||||
// takes far longer to fetch than the whole rest of the batch, and the
|
// takes far longer to fetch than the whole rest of the batch, and the
|
||||||
// thumbnails that *are* ready must not wait behind it.
|
// thumbnails that *are* ready must not wait behind it.
|
||||||
if !missing.is_empty() {
|
if generate && !missing.is_empty() {
|
||||||
self.spawn_generate_thumbs(missing, &tags, &names, ThumbJob::Photos);
|
self.spawn_generate_thumbs(missing, &tags, &names, ThumbJob::Photos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1899,10 +2041,14 @@ impl Core {
|
|||||||
}
|
}
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
self.rt.spawn(async move {
|
self.rt.spawn(async move {
|
||||||
if let Err(e) = core.refresh_timeline().await {
|
let refreshed = core.refresh_timeline().await;
|
||||||
warn!(error = %e, "background timeline refresh failed");
|
|
||||||
}
|
|
||||||
core.timeline_refreshing.store(false, Ordering::SeqCst);
|
core.timeline_refreshing.store(false, Ordering::SeqCst);
|
||||||
|
// Only a refresh that landed: a front-end re-reading after a failed
|
||||||
|
// one would find the timeline still stale and start another.
|
||||||
|
match refreshed {
|
||||||
|
Ok(_) => core.events.publish(&[Topic::Photos]),
|
||||||
|
Err(e) => warn!(error = %e, "background timeline refresh failed"),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1948,10 +2094,11 @@ mod thumb_tests {
|
|||||||
use super::{
|
use super::{
|
||||||
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
|
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
|
||||||
fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool,
|
fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool,
|
||||||
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
|
similar_hashes, thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
|
||||||
};
|
};
|
||||||
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
||||||
use proton_drive_rs::ThumbnailType;
|
use proton_drive_rs::ThumbnailType;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::os::unix::fs::PermissionsExt as _;
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -2225,6 +2372,46 @@ mod thumb_tests {
|
|||||||
assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap());
|
assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thumbnails_hash_alike_across_sizes_and_undecodable_ones_get_no_hash() {
|
||||||
|
let tmp = scratch("similar-hashes");
|
||||||
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
|
// One picture at two sizes: a diagonal gradient with a bright corner.
|
||||||
|
let picture = |width: u32, height: u32| {
|
||||||
|
let image = image::GrayImage::from_fn(width, height, |x, y| {
|
||||||
|
let (fx, fy) = (x * 4 / width, y * 4 / height);
|
||||||
|
image::Luma([if fx + fy == 0 {
|
||||||
|
250
|
||||||
|
} else {
|
||||||
|
(fx * 40 + fy * 20) as u8
|
||||||
|
}])
|
||||||
|
});
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
image::codecs::jpeg::JpegEncoder::new(&mut bytes)
|
||||||
|
.encode_image(&image)
|
||||||
|
.unwrap();
|
||||||
|
bytes
|
||||||
|
};
|
||||||
|
std::fs::write(tmp.join("big"), picture(512, 384)).unwrap();
|
||||||
|
std::fs::write(tmp.join("small"), picture(256, 192)).unwrap();
|
||||||
|
std::fs::write(tmp.join("torn"), b"not a picture").unwrap();
|
||||||
|
|
||||||
|
let hashes: HashMap<String, u64> = similar_hashes(
|
||||||
|
["big", "small", "torn", "gone"]
|
||||||
|
.iter()
|
||||||
|
.map(|name| (name.to_string(), tmp.join(name)))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
|
assert_eq!(hashes.len(), 4);
|
||||||
|
assert_ne!(hashes["big"], pdfs_core::similar::NO_HASH);
|
||||||
|
assert!(pdfs_core::similar::alike(hashes["big"], hashes["small"]));
|
||||||
|
assert_eq!(hashes["torn"], pdfs_core::similar::NO_HASH);
|
||||||
|
assert_eq!(hashes["gone"], pdfs_core::similar::NO_HASH);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_uploaded_photo_carries_its_thumbnail() {
|
fn an_uploaded_photo_carries_its_thumbnail() {
|
||||||
let tmp = scratch("upload-photo");
|
let tmp = scratch("upload-photo");
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
shared_by,
|
shared_by,
|
||||||
shared_at,
|
shared_at,
|
||||||
shared_by_unverified,
|
shared_by_unverified,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,5 +35,9 @@ adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
|||||||
async-channel = "2"
|
async-channel = "2"
|
||||||
exif = { version = "0.6", package = "kamadak-exif" }
|
exif = { version = "0.6", package = "kamadak-exif" }
|
||||||
webkit6 = "0.6"
|
webkit6 = "0.6"
|
||||||
|
# Town and street names on the Places map, with font fallback for every script.
|
||||||
|
pangocairo = "0.22"
|
||||||
|
# Map tiles for the opt-in online map. The same build proton-sdk already pulls in.
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["rustls", "webpki-roots"] }
|
||||||
# The system libintl (glibc) rather than a bundled gettext build.
|
# The system libintl (glibc) rather than a bundled gettext build.
|
||||||
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
||||||
@@ -26,5 +26,6 @@ fn main() {
|
|||||||
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
||||||
println!("cargo:rerun-if-changed=resources/icons");
|
println!("cargo:rerun-if-changed=resources/icons");
|
||||||
println!("cargo:rerun-if-changed=resources/style.css");
|
println!("cargo:rerun-if-changed=resources/style.css");
|
||||||
|
println!("cargo:rerun-if-changed=resources/proton-theme.css");
|
||||||
println!("cargo:rerun-if-changed=../../packaging/io.narl.proton-drive-linux.svg");
|
println!("cargo:rerun-if-changed=../../packaging/io.narl.proton-drive-linux.svg");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
47.98,8.00 48.94,9.45 48.94,11.26
|
||||||
|
47.98,8.00 46.42,6.50 44.94,4.91 43.99,4.95 43.58,4.85 43.13,4.64 42.86,4.32 42.02,4.14 41.88,3.98
|
||||||
|
34.25,31.21 34.53,31.53 34.48,31.58
|
||||||
|
35.55,32.40 35.19,32.53 35.07,32.46 34.96,32.16 34.95,31.84 35.20,31.75 34.95,31.60 34.87,31.40 34.91,31.35 35.10,31.37 35.45,31.48
|
||||||
|
35.11,33.08 35.41,33.08 35.49,33.12 35.53,33.25 35.60,33.24
|
||||||
|
35.79,32.73 35.89,32.94 35.79,33.29 35.84,33.42
|
||||||
|
34.00,35.07 33.87,35.09 33.48,35.00 33.38,35.16 33.19,35.17 32.92,35.09 32.71,35.17
|
||||||
|
77.80,35.50 77.89,35.45 78.04,35.48 78.01,35.25 78.33,34.61 78.67,34.52 78.97,34.30 78.97,34.23 78.73,34.01 78.80,33.50 78.92,33.39
|
||||||
|
77.05,35.11 77.00,34.99 76.59,34.74 76.04,34.67 75.71,34.50 75.32,34.58
|
||||||
|
91.63,27.76 91.98,27.73 92.10,27.81 92.55,27.88 92.69,27.99 92.64,28.06 92.70,28.15 93.17,28.36 93.36,28.65 93.90,28.74 93.97,28.86 94.27,28.97 94.29,29.14 94.62,29.31 94.77,29.18 95.39,29.04 95.52,29.21 95.71,29.24 95.77,29.34 96.13,29.38 96.23,29.25 96.36,29.25 96.18,29.12 96.20,28.96 96.44,29.05 96.58,28.76 96.28,28.41 96.39,28.37 96.60,28.46 96.78,28.37 97.08,28.37 97.32,28.22
|
||||||
|
128.37,38.62 128.17,38.36 128.04,38.31 127.09,38.28 126.67,37.92 126.63,37.78
|
||||||
|
-8.68,27.66 -8.82,27.66 -8.75,27.19 -8.79,27.12 -9.41,27.09 -9.82,26.85 -10.03,26.91 -10.25,26.86 -10.76,27.02 -11.39,26.88 -11.32,26.74 -11.34,26.63 -11.51,26.47 -11.72,26.10 -12.06,25.99 -12.43,24.83 -12.99,24.47 -13.31,23.98 -13.89,23.69 -14.10,23.10 -14.22,22.31 -14.63,21.86 -14.61,21.75 -14.75,21.50 -14.97,21.44 -16.04,21.50 -17.00,21.42
|
||||||
|
35.92,4.62 35.76,4.81 35.80,5.16 35.75,5.34 35.47,5.42 35.33,5.36 35.27,5.49
|
||||||
|
-124.76,48.49 -123.86,48.26 -123.39,48.25 -123.14,48.40 -123.24,48.64 -123.01,48.79 -123.31,48.99
|
||||||
|
91.63,27.76 91.64,27.92 91.27,28.08 91.02,27.97 90.33,28.12
|
||||||
|
23.61,51.52 23.61,51.61 23.71,51.64 23.98,51.59 24.36,51.87 25.79,51.92 27.14,51.75 27.30,51.60 27.69,51.57 27.70,51.48 27.86,51.59 28.18,51.61 28.60,51.54 28.73,51.43 29.10,51.63 29.35,51.38 30.16,51.48 30.33,51.33 30.54,51.27 30.63,51.36 30.53,51.60 30.76,51.90 31.08,52.08 31.76,52.10
|
||||||
|
35.84,33.42 35.60,33.24
|
||||||
|
35.84,33.42 36.03,33.59 35.94,33.67 35.99,33.75 36.09,33.83 36.37,33.84 36.28,33.93 36.58,34.22 36.50,34.43 36.33,34.50 36.43,34.61 36.38,34.66 35.98,34.63
|
||||||
|
34.68,-12.01 34.76,-12.03 34.68,-12.12 34.68,-12.01
|
||||||
|
80.21,42.19 80.26,42.24 80.16,42.63 80.20,42.73 80.54,42.87 80.38,42.97 80.39,43.04 80.62,43.13 80.75,43.10 80.79,43.16 80.67,43.31 80.70,43.43 80.65,43.56 80.36,44.10 80.36,44.55 80.38,44.66 80.48,44.71 80.26,44.81 80.00,44.80 79.87,44.88 80.06,45.01 80.85,45.13 81.69,45.35 81.79,45.23 81.94,45.16 82.27,45.22 82.52,45.13 82.62,45.29 82.61,45.42 82.33,45.52 82.32,45.59 82.97,46.97 83.03,47.19 84.02,46.97 84.67,46.97 84.79,46.83 85.23,47.04 85.48,47.06 85.66,47.25 85.53,47.92 85.75,48.39 86.55,48.53 86.72,48.70 86.73,48.94 86.81,49.05 87.05,49.11 87.32,49.09
|
||||||
|
23.48,53.94 24.19,53.95 24.32,53.89 24.77,53.97 24.87,54.15 25.05,54.13 25.46,54.29 25.51,54.16 25.75,54.16 25.75,54.26 25.55,54.33 25.72,54.56 25.72,54.72 25.86,54.92 26.18,55.00 26.29,55.14 26.60,55.13 26.78,55.27 26.46,55.34 26.59,55.67
|
||||||
|
-53.37,-33.74 -53.53,-33.66 -53.53,-33.17 -53.13,-32.74 -53.16,-32.68 -53.60,-32.40 -53.76,-32.06 -54.22,-31.86 -54.59,-31.49 -54.90,-31.39 -55.04,-31.28 -55.17,-31.28 -55.56,-30.88 -55.63,-30.86 -55.81,-31.03
|
||||||
|
116.68,49.82 116.03,48.78 115.82,48.58 115.79,48.25 115.53,48.13 115.62,47.87 115.90,47.69 116.23,47.86 116.76,47.87 117.07,47.81 117.35,47.65 117.77,47.99 118.15,48.03 118.50,47.98 118.76,47.76 119.08,47.65 119.16,47.53 119.71,47.15 119.90,46.86 119.87,46.67 119.71,46.61 119.33,46.61 118.84,46.76 118.72,46.69 118.31,46.72 118.07,46.67 117.74,46.52 117.44,46.59 117.33,46.36 116.86,46.39 116.56,46.29 116.21,45.89 116.20,45.74 115.68,45.46 114.56,45.39 114.42,45.20 114.08,44.97 113.59,44.75 112.71,44.88 112.41,45.06 111.90,45.06 111.62,44.83 111.40,44.37 111.60,44.11 111.84,43.93 111.93,43.71 111.55,43.50 111.01,43.34 110.40,42.77 109.34,42.44 108.17,42.45 106.77,42.29 105.20,41.74 104.98,41.60 104.50,41.66 104.50,41.88 103.71,41.75 103.07,42.01 102.16,42.16 101.97,42.22 101.66,42.50 101.50,42.54 99.98,42.68 99.47,42.57 97.21,42.79 96.39,42.72 96.30,42.93 95.86,43.28 95.53,43.95 95.33,44.04 95.35,44.28 95.05,44.26 94.71,44.35 94.20,44.65 93.96,44.67 93.52,44.94 92.79,45.04 91.58,45.08 91.05,45.22 90.88,45.20 90.66,45.53 90.71,45.73 91.00,46.04 90.91,46.27 91.03,46.57 90.87,46.95 90.72,47.00 90.50,47.29 90.33,47.66 90.10,47.75 90.03,47.88 89.78,47.83 89.48,48.03 89.05,48.00 88.58,48.22 88.52,48.38 87.98,48.56 88.06,48.71 87.74,48.88 87.87,49.00 87.81,49.16
|
||||||
|
13.81,48.77 13.55,48.96 13.40,48.98 12.92,49.33 12.68,49.41 12.39,49.74 12.51,49.90 12.21,50.10 12.09,50.30 12.28,50.18 12.55,50.39 12.94,50.41 13.02,50.49 13.44,50.60 13.56,50.70 14.37,50.90 14.26,51.00 14.32,51.04 14.55,50.99 14.66,50.83 14.81,50.86
|
||||||
|
27.35,57.53 26.97,57.61 26.46,57.54 25.99,57.84 25.28,58.05 25.26,58.00 25.11,58.06 24.32,57.87
|
||||||
|
27.35,57.53 27.51,57.51 27.54,57.43 27.83,57.29 27.64,56.85 27.85,56.85 28.10,56.55 28.20,56.26 28.15,56.14
|
||||||
|
21.05,56.07 21.65,56.31 22.08,56.41 22.88,56.40 23.04,56.32 23.20,56.37 24.12,56.26 24.47,56.28 24.84,56.41 25.07,56.20 25.66,56.10 26.28,55.75 26.59,55.67
|
||||||
|
20.62,69.04 20.12,69.02 20.35,68.85 20.24,68.67 19.97,68.54 20.24,68.48 19.97,68.36 18.30,68.56 18.16,68.53 18.18,68.20 17.92,67.96 17.32,68.10 16.78,67.90 16.57,67.62 16.19,67.51 16.13,67.43 16.43,67.16 16.40,67.05 15.42,66.49 15.48,66.31 15.04,66.17 14.54,66.13 14.63,65.79 14.48,65.30 13.65,64.58 14.08,64.46 14.14,64.17 13.96,64.01 13.20,64.08 12.79,64.00 12.18,63.60 12.21,63.49 12.00,63.29 12.22,63.00 12.11,62.92 12.11,62.59 12.30,62.29 12.16,61.72 12.88,61.35 12.71,61.06 12.29,61.00 12.59,60.45 12.49,60.11 12.17,59.91 11.93,59.86 11.83,59.70 11.68,59.59 11.80,59.29 11.64,58.93 11.47,58.91 11.34,59.09 11.24,59.10 11.14,59.09 11.06,58.96 10.91,58.95
|
||||||
|
20.62,69.04 21.07,69.04 21.13,69.08 21.07,69.21 21.59,69.27 22.30,68.86 22.41,68.72 23.32,68.65 23.71,68.71 23.85,68.81 24.94,68.59 25.09,68.64 25.25,68.82 25.58,68.89 25.75,68.99 25.77,69.28 26.07,69.69 26.53,69.92 27.13,69.91 27.75,70.06 27.89,70.06 28.41,69.82 29.14,69.67 29.33,69.47 28.85,69.18 28.83,69.12 28.97,69.02
|
||||||
|
104.43,10.41 104.56,10.52 104.85,10.53 105.05,10.70 105.05,10.91 105.31,10.85 105.41,10.95 105.76,10.99 105.85,10.86 106.16,10.79 106.16,11.04 105.86,11.29 105.85,11.64 106.01,11.76 106.40,11.69 106.41,11.95 106.70,11.98 106.76,12.05 106.93,12.08 107.21,12.30 107.33,12.32 107.39,12.26 107.51,12.36 107.56,12.54 107.48,13.03 107.61,13.44 107.33,14.13 107.36,14.37 107.49,14.55 107.52,14.71
|
||||||
|
-69.51,-17.50 -69.31,-17.94 -69.09,-18.05 -69.15,-18.14 -68.97,-18.97 -68.46,-19.43 -68.70,-19.72 -68.56,-19.97 -68.60,-20.04 -68.76,-20.09 -68.69,-20.31 -68.76,-20.42 -68.48,-20.63 -68.56,-20.72 -68.56,-20.90 -68.44,-20.95 -68.20,-21.30 -68.19,-21.62 -68.08,-21.98 -67.99,-22.06 -67.88,-22.49 -67.88,-22.82 -67.58,-22.89 -67.19,-22.82
|
||||||
|
-69.51,-17.51 -69.85,-17.70 -69.80,-17.99 -69.93,-18.21 -70.18,-18.33 -70.42,-18.35
|
||||||
|
6.34,49.45 6.49,49.80 6.20,49.92 6.11,50.03 6.12,50.12
|
||||||
|
2.52,51.10 2.60,50.88 2.76,50.75 2.84,50.71 3.11,50.78 3.23,50.66 3.27,50.53 3.60,50.48 3.69,50.31 3.95,50.34 4.17,50.25 4.15,49.97 4.55,49.96 4.82,50.15 4.86,50.14 4.79,49.96 4.87,49.79 5.28,49.68 5.51,49.51 5.79,49.54
|
||||||
|
6.12,50.12 5.98,50.17 5.74,49.92 5.72,49.81 5.88,49.64 5.79,49.54
|
||||||
|
46.43,41.89 45.95,42.04 45.64,42.21 45.71,42.50 45.34,42.53 45.16,42.68 44.87,42.76 44.77,42.62 44.64,42.73 44.51,42.75 43.83,42.57 43.74,42.62 43.78,42.75 43.09,42.99 42.99,43.09 42.76,43.17 42.57,43.16 42.42,43.22 41.58,43.22 40.65,43.53 40.15,43.57 39.98,43.42
|
||||||
|
20.96,40.85 20.93,40.90 20.71,40.93 20.49,41.27 20.45,41.52 20.52,41.57 20.57,41.87
|
||||||
|
20.57,41.87 20.58,42.01 20.49,42.22 20.24,42.34 20.06,42.55
|
||||||
|
41.51,41.52 41.82,41.43 41.93,41.50 42.47,41.44 42.61,41.58 42.75,41.58 43.15,41.31 43.21,41.20 43.40,41.18 43.44,41.11
|
||||||
|
101.14,21.57 101.20,21.52 101.25,21.20 101.54,21.23 101.70,21.15 101.80,21.21 101.72,21.31 101.74,21.83 101.60,21.99 101.52,22.25 101.74,22.50 101.84,22.39 102.02,22.44 102.13,22.38
|
||||||
|
73.63,39.45 73.82,39.49 73.91,39.58 73.84,39.80 73.99,40.04 74.41,40.14 74.68,40.31 74.83,40.33 74.84,40.48 75.11,40.45 75.56,40.63 75.68,40.31 75.87,40.30 76.26,40.43 76.32,40.35 76.48,40.45 76.66,40.78 76.91,41.02 77.58,40.99 78.12,41.08 78.44,41.42 79.29,41.78 79.77,41.90 79.84,42.00 80.22,42.03 80.21,42.19
|
||||||
|
43.44,41.11 44.23,41.21 44.84,41.21 44.81,41.26 45.00,41.29
|
||||||
|
80.21,42.19 79.92,42.41 79.43,42.48 79.13,42.78 76.99,42.97 75.84,42.94 75.64,42.81 75.37,42.84 74.82,42.98 74.21,43.24 73.95,43.20 73.56,43.00 73.42,42.59 73.49,42.41 72.28,42.76 71.76,42.82 71.26,42.73 70.89,42.34 70.95,42.25
|
||||||
|
-56.48,1.94 -56.84,1.88 -57.12,2.01 -57.32,1.96 -57.55,1.73 -57.98,1.65 -58.03,1.52 -58.34,1.59 -58.40,1.48 -58.51,1.44 -58.51,1.28 -58.68,1.28 -58.82,1.20 -59.23,1.38 -59.54,1.70 -59.67,1.75 -59.67,1.84 -59.76,1.90 -59.76,2.27 -59.89,2.36 -59.99,2.69 -59.97,2.99 -59.83,3.35 -59.85,3.59 -59.68,3.70 -59.55,3.93 -59.74,4.23 -59.70,4.38 -59.83,4.48 -60.15,4.53 -60.03,4.74 -59.99,5.08 -60.14,5.24 -60.74,5.20
|
||||||
|
8.39,55.10 8.59,54.92 9.25,54.81 9.74,54.83
|
||||||
|
9.52,30.23 9.90,30.39 10.22,30.78 10.26,30.94 10.11,31.46 10.27,31.68 10.48,31.74 10.61,31.93 10.83,32.08 11.51,32.41 11.53,32.52 11.45,32.64 11.50,33.18
|
||||||
|
45.00,41.29 45.19,41.15 45.07,41.08 45.42,40.99 45.59,40.85 45.38,40.67 45.45,40.53 45.57,40.42 45.96,40.23 45.89,40.02 45.58,39.98 46.20,39.59 46.48,39.56 46.48,39.48 46.37,39.40 46.58,39.22 46.40,39.19 46.49,39.07 46.49,38.91
|
||||||
|
44.77,39.70 45.03,39.77 45.17,39.57 45.25,39.60 45.46,39.49 45.78,39.55 45.77,39.38 45.98,39.24 45.95,39.18 46.11,38.88
|
||||||
|
20.24,46.11 20.51,46.17 20.61,46.13 20.76,46.25 21.12,46.28 21.26,46.41 21.32,46.61 21.50,46.70 21.66,47.04 22.00,47.40 22.00,47.51 22.29,47.73 22.61,47.77 22.88,47.95
|
||||||
|
70.96,40.24 71.30,40.29 71.63,40.22 71.69,40.15 71.96,40.26 72.13,40.44 72.36,40.40 72.40,40.58 72.60,40.53 73.14,40.81 72.66,40.87 72.36,41.04 72.19,41.03 72.16,41.17 71.88,41.20 71.86,41.31 71.66,41.54 71.60,41.50 71.59,41.33 71.42,41.34 71.39,41.12 70.86,41.22 70.69,41.45 70.47,41.41 70.20,41.51 70.18,41.57 70.63,41.88 70.80,41.92 70.86,42.03 71.23,42.16 71.04,42.28 70.95,42.25
|
||||||
|
18.83,49.51 18.94,49.50 18.97,49.40 19.15,49.40 19.44,49.60 19.63,49.41 19.77,49.37 19.76,49.20 20.06,49.18 20.16,49.32 20.36,49.39 20.62,49.39 20.87,49.31 21.08,49.42 21.64,49.41 21.89,49.34 22.02,49.21 22.54,49.07
|
||||||
|
-7.22,55.09 -7.38,55.03 -7.55,54.77 -7.91,54.70 -7.75,54.59 -8.12,54.48 -8.12,54.41 -7.85,54.22 -7.61,54.14 -7.32,54.13 -7.16,54.24 -7.20,54.30 -7.01,54.41 -6.80,54.21 -6.65,54.16 -6.65,54.06 -6.22,54.09
|
||||||
|
18.83,49.51 18.81,49.61 18.59,49.76 18.56,49.88 18.03,50.04 17.87,49.97 17.63,50.12 17.59,50.16 17.74,50.23 17.70,50.31 17.42,50.25 16.88,50.43 16.99,50.24 16.64,50.10 16.21,50.42 16.42,50.57 16.28,50.66 16.01,50.61 15.73,50.74 15.35,50.81 15.26,50.96 14.99,51.01 14.98,50.89 14.81,50.86
|
||||||
|
70.96,40.24 70.95,40.19 70.64,40.08 70.62,40.00 70.52,39.95 70.45,40.05 69.97,40.20 69.53,40.10 69.48,39.92 69.31,39.97 69.28,39.92 69.23,39.76 69.30,39.52 70.50,39.59 70.80,39.39 71.00,39.41 71.07,39.49 71.47,39.60 71.50,39.48 71.73,39.42 71.73,39.31 71.78,39.28 72.04,39.35 72.23,39.21 72.36,39.34 72.56,39.38 73.11,39.36 73.63,39.45
|
||||||
|
20.00,39.71 20.25,39.68 20.31,39.80 20.38,39.80 20.31,39.98 20.66,40.12 20.81,40.45 20.95,40.49 21.03,40.62 20.96,40.85
|
||||||
|
33.20,-14.01 33.15,-13.94 32.99,-14.02 32.87,-13.82 32.77,-13.76 32.80,-13.69 32.67,-13.59 32.85,-13.46 32.97,-13.22 33.00,-12.90 32.95,-12.80 33.02,-12.63 33.40,-12.49 33.51,-12.35 33.34,-12.31 33.25,-12.11 33.30,-11.69 33.23,-11.42 33.38,-11.16 33.26,-10.89 33.46,-10.78 33.66,-10.55 33.53,-10.23 33.31,-10.04 33.35,-9.86 33.20,-9.63 33.00,-9.62 32.92,-9.41
|
||||||
|
22.13,48.41 22.14,48.57 22.30,48.69 22.54,49.07
|
||||||
|
-11.51,6.91 -11.27,7.23 -10.65,7.76 -10.57,8.07 -10.36,8.19 -10.28,8.49
|
||||||
|
24.15,8.67 24.22,8.61 24.21,8.37 24.29,8.29 24.85,8.14 25.20,7.81 25.25,7.72 25.18,7.56 25.28,7.43 25.89,7.06 26.36,6.64 26.31,6.46 26.51,6.07 27.14,5.72 27.33,5.19
|
||||||
|
42.92,11.00 43.25,11.50
|
||||||
|
42.92,11.00 42.56,11.08 41.96,10.94 41.80,10.98 41.79,11.69 42.38,12.47
|
||||||
|
29.40,-4.45 29.32,-4.90 29.61,-5.72 29.48,-6.03 29.54,-6.31 29.71,-6.62 30.21,-7.04 30.75,-8.19
|
||||||
|
42.38,12.47 42.48,12.51 42.70,12.38 42.87,12.62 43.12,12.71
|
||||||
|
13.81,48.77 14.05,48.60 14.69,48.60 14.79,48.75 14.92,48.77 14.97,48.98 15.07,49.00 15.83,48.86 16.06,48.75 16.37,48.74 16.54,48.80 16.88,48.70 16.95,48.60
|
||||||
|
42.36,37.11 42.77,37.37 42.94,37.32 43.09,37.37 43.68,37.23 44.11,37.30 44.19,37.25 44.20,37.05 44.28,36.98 44.61,37.18 44.77,37.14
|
||||||
|
16.09,46.86 16.25,46.97 16.45,47.01 16.49,47.12 16.42,47.22 16.44,47.40 16.62,47.45 16.68,47.54 16.64,47.61 16.42,47.67 16.59,47.75 16.79,47.68 17.07,47.71 17.03,47.84 17.15,48.01
|
||||||
|
7.02,45.93 7.13,45.88 7.54,45.98 7.79,45.92 7.99,46.02 8.13,46.16 8.10,46.27 8.42,46.45 8.46,46.25 8.64,46.11 8.82,46.08 8.78,46.00 8.95,45.83 9.02,45.85 9.00,46.01 9.25,46.29 9.26,46.48 9.43,46.48 9.53,46.31 9.94,46.36 10.04,46.24 10.13,46.24 10.04,46.48 10.09,46.60 10.43,46.55 10.40,46.67 10.45,46.86
|
||||||
|
10.45,46.86 10.35,46.98 10.13,46.85 9.88,46.94 9.85,47.01 9.58,47.06
|
||||||
|
44.77,37.14 44.79,37.29 44.57,37.44 44.55,37.66 44.59,37.71 44.21,37.91 44.45,38.33 44.30,38.39 44.27,38.84 44.17,38.93 44.18,39.14 44.08,39.22 44.02,39.38 44.39,39.42 44.46,39.67 44.59,39.77 44.82,39.65
|
||||||
|
23.48,53.94 23.45,54.14 23.37,54.20 22.89,54.39 22.77,54.36
|
||||||
|
30.75,-8.19 30.89,-8.47 31.03,-8.60 31.35,-8.61 31.53,-8.71 31.67,-8.91 31.92,-8.94 31.94,-9.05 32.43,-9.16 32.92,-9.41
|
||||||
|
7.20,53.28 7.19,53.00 7.03,52.65 6.71,52.62 6.75,52.46 6.97,52.44 7.04,52.38 6.98,52.21 6.72,52.08 6.80,51.97 6.52,51.85 6.36,51.82 6.17,51.88 5.95,51.80 6.20,51.45 6.08,51.22 6.13,51.15 5.86,51.03 6.05,50.90 5.99,50.75
|
||||||
|
9.58,47.06 9.61,47.11 9.53,47.27
|
||||||
|
-8.49,7.56 -8.43,7.60 -8.23,7.56 -8.01,8.08 -8.05,8.17 -8.26,8.25 -8.24,8.46 -8.05,8.50 -7.82,8.47 -7.74,8.38 -7.68,8.41 -7.72,8.64 -7.95,8.79 -7.94,8.98 -7.78,9.08 -7.92,9.19 -7.90,9.42 -8.03,9.40 -8.14,9.50 -8.16,9.97 -7.99,10.16
|
||||||
|
7.49,43.77 7.48,43.86 7.68,44.08 7.64,44.16 7.32,44.14 6.90,44.34 6.84,44.51 7.03,44.72 6.99,44.83 6.74,44.92 6.63,45.07 6.69,45.14 6.84,45.14 7.08,45.24 7.15,45.38 6.79,45.74 6.80,45.81 7.02,45.93
|
||||||
|
28.97,69.02 28.41,68.90 28.77,68.84 28.48,68.54 28.56,68.35 28.69,68.19 29.34,68.06 29.99,67.67 29.94,67.55 29.24,67.10 29.07,66.89 29.90,66.09 30.09,65.79 30.10,65.68 29.72,65.62 29.82,65.57 29.73,65.47 29.71,65.34 29.61,65.25 29.81,65.20 29.83,65.15 29.62,65.04 29.60,64.97 29.78,64.80 30.11,64.73 30.12,64.64 29.99,64.52 30.11,64.37 30.49,64.24 30.53,64.08 30.21,63.80 29.99,63.74 31.18,63.21 31.53,62.89 31.19,62.48 29.25,61.29 28.41,60.90 27.73,60.46
|
||||||
|
30.87,69.78 30.92,69.61 30.86,69.54 30.62,69.53 30.18,69.64 30.19,69.54 30.09,69.43 29.39,69.30 29.21,69.10 28.97,69.02
|
||||||
|
55.19,22.70 55.20,23.03 55.53,23.82 55.47,23.94 55.99,24.06 55.93,24.22 55.76,24.24 55.80,24.87 55.92,24.97 56.00,24.95 55.97,24.86 56.06,24.74 56.39,24.98
|
||||||
|
48.57,41.84 48.39,41.60 48.06,41.46 47.79,41.20 47.32,41.28 47.14,41.52 46.75,41.81 46.57,41.80 46.54,41.87 46.43,41.89
|
||||||
|
-54.62,2.33 -54.72,2.44 -54.88,2.45 -54.98,2.60 -55.39,2.44 -55.73,2.41 -55.96,2.52 -56.14,2.26 -55.92,2.04 -55.93,1.89 -56.02,1.84 -56.48,1.94
|
||||||
|
46.43,41.89 46.20,41.74 46.18,41.66 46.31,41.51 46.67,41.29 46.53,41.09 46.43,41.08 46.17,41.20 45.92,41.19 45.73,41.26 45.72,41.34 45.28,41.45 45.00,41.29
|
||||||
|
-11.39,12.40 -11.45,12.56 -11.39,12.94 -11.63,13.37 -11.76,13.39 -11.83,13.32 -12.05,13.63 -11.96,13.88 -12.02,13.97 -12.02,14.21 -12.23,14.46 -12.19,14.65 -12.28,14.81
|
||||||
|
-54.62,2.33 -54.13,2.12 -53.77,2.35 -53.51,2.25 -53.33,2.34 -53.23,2.20 -52.90,2.21 -52.58,2.53 -52.33,3.18 -51.99,3.70 -51.65,4.06
|
||||||
|
2.71,6.37 2.77,6.71 2.72,6.98 2.79,7.48 2.69,7.87 2.77,9.05 3.04,9.08 3.15,9.32 3.14,9.45 3.33,9.67 3.35,9.81 3.56,9.91 3.65,10.16 3.58,10.29 3.65,10.41 3.77,10.42 3.83,10.61 3.72,11.08 3.49,11.40 3.60,11.70
|
||||||
|
13.07,-4.63 12.80,-4.43 12.38,-4.62 12.31,-4.77 12.20,-4.78 12.02,-5.00
|
||||||
|
16.52,46.50 16.32,46.53 16.24,46.48 16.23,46.37 16.07,46.37 15.64,46.20 15.59,46.14 15.67,46.05 15.65,45.86 15.28,45.73 15.35,45.66 15.28,45.58 15.34,45.47 14.79,45.48 14.57,45.66 14.37,45.48 13.99,45.51 13.88,45.43 13.58,45.52
|
||||||
|
18.91,45.93 18.67,45.91 18.53,45.80 18.36,45.75 17.81,45.79 17.31,46.00 16.87,46.34 16.52,46.50
|
||||||
|
20.24,46.11 19.61,46.17 19.28,46.00 19.09,46.02 18.91,45.93
|
||||||
|
16.52,46.50 16.28,46.86 16.09,46.86
|
||||||
|
51.27,24.61 50.97,24.57 50.80,24.79
|
||||||
|
25.26,-17.79 25.24,-17.97 25.94,-18.94 25.95,-19.08 26.17,-19.54 26.68,-19.89 27.18,-20.10 27.26,-20.23 27.28,-20.48 27.68,-20.50 27.67,-21.06 28.01,-21.55 29.03,-21.80 29.04,-22.02 29.36,-22.19
|
||||||
|
77.80,35.50 77.45,35.48 76.77,35.66
|
||||||
|
28.01,41.97 27.53,41.92 27.24,42.09 27.01,42.06 26.58,41.95 26.51,41.83 26.36,41.80 26.32,41.72
|
||||||
|
102.93,11.71 102.74,12.09 102.76,12.43 102.50,12.67 102.46,13.02 102.33,13.29 102.34,13.56 102.55,13.59 102.54,13.66 102.91,14.14 103.20,14.33 103.60,14.42 103.98,14.36 104.78,14.43 104.97,14.37 105.00,14.25 105.07,14.23 105.18,14.35
|
||||||
|
88.89,27.32 88.74,27.18 88.86,26.96 89.15,26.82 89.33,26.85 89.55,26.80 89.61,26.72 90.12,26.75 90.35,26.89 90.74,26.77 91.29,26.79 91.43,26.87 91.67,26.80 92.05,26.87 92.07,26.98 91.99,27.10 92.08,27.29 91.99,27.45 91.74,27.44 91.59,27.56 91.63,27.76
|
||||||
|
104.07,1.28 104.06,1.40 103.82,1.46 103.70,1.44 103.60,1.21
|
||||||
|
100.12,6.44 100.18,6.67 100.26,6.68 100.35,6.55 100.75,6.46 100.87,6.25 101.05,6.24 101.08,5.96 100.98,5.77 101.11,5.64 101.26,5.79 101.56,5.91 101.68,5.78 101.87,5.83 102.10,6.24
|
||||||
|
5.99,50.75 6.24,50.60 6.17,50.55 6.20,50.50 6.34,50.45 6.36,50.32 6.18,50.23 6.12,50.12
|
||||||
|
-71.78,19.72 -71.71,19.49 -71.75,19.29 -71.65,19.16 -71.81,18.99 -71.74,18.73 -71.87,18.61 -72.00,18.60 -71.76,18.34 -71.77,18.04
|
||||||
|
14.06,13.08 13.61,13.70
|
||||||
|
46.53,29.10 46.91,29.54 47.15,30.00 47.33,30.08 47.64,30.10 47.98,29.98
|
||||||
|
88.11,27.87 88.14,27.95 88.58,28.09 88.80,28.01 88.85,27.87 88.75,27.52 88.89,27.32
|
||||||
|
-90.10,13.74 -90.05,13.90 -89.55,14.24 -89.57,14.39 -89.36,14.42
|
||||||
|
124.04,-9.34 124.09,-9.42 124.28,-9.43 124.41,-9.31 124.44,-9.19
|
||||||
|
124.92,-8.94 124.94,-9.05 125.10,-9.00 125.15,-9.04 125.10,-9.19 124.96,-9.21 125.07,-9.51
|
||||||
|
115.14,4.90 115.27,4.63 115.29,4.35 115.11,4.39 115.03,4.90
|
||||||
|
109.63,2.03 109.54,1.90 109.64,1.78 109.65,1.61 110.51,0.86 111.10,1.05 111.81,1.01 111.92,1.11 112.08,1.14 112.19,1.44 112.48,1.56 112.94,1.57 113.01,1.43 113.62,1.24 113.90,1.43 114.39,1.50 114.51,1.45 114.70,1.85 114.83,1.98 114.76,2.16 114.79,2.25 115.18,2.52 115.08,2.63 115.12,2.89 115.25,3.03 115.45,3.03 115.57,3.45 115.57,3.94 115.68,4.19 115.84,4.33 116.02,4.29 116.24,4.36 116.41,4.31 116.51,4.37 117.10,4.34 117.54,4.17 117.88,4.19
|
||||||
|
10.45,46.86 10.99,46.78 11.24,46.98 11.78,46.99 12.17,47.08 12.15,46.94 12.39,46.70 13.70,46.52
|
||||||
|
13.70,46.52 13.38,46.26 13.63,46.18 13.48,46.01 13.61,45.96 13.58,45.81 13.87,45.61 13.72,45.59
|
||||||
|
29.01,-2.72 29.10,-2.60 29.30,-2.67 29.39,-2.81 29.70,-2.79 29.87,-2.72 29.93,-2.34 30.12,-2.42 30.41,-2.31 30.55,-2.40
|
||||||
|
92.57,21.98 92.63,21.31 92.57,21.26 92.33,21.44 92.18,21.29 92.32,20.79
|
||||||
|
1.43,42.60 1.29,42.71 0.70,42.85 0.63,42.69 -0.04,42.69 -0.30,42.83 -0.59,42.80 -0.76,42.94 -1.18,43.02 -1.30,43.10 -1.46,43.05 -1.41,43.24 -1.71,43.31 -1.79,43.41
|
||||||
|
1.43,42.60 1.50,42.64 1.71,42.60 1.71,42.50
|
||||||
|
1.71,42.50 2.03,42.35 2.20,42.42 2.65,42.34 2.89,42.46 3.21,42.43
|
||||||
|
1.43,42.60 1.45,42.44 1.71,42.50
|
||||||
|
74.89,37.23 75.12,37.39 74.89,37.60 74.94,37.77 74.78,38.19 74.81,38.46 74.28,38.66 74.13,38.66 74.03,38.54 73.80,38.61 73.70,38.85 73.81,38.97 73.61,39.23 73.63,39.45
|
||||||
|
74.89,37.23 74.73,37.29 74.37,37.16 74.54,37.02
|
||||||
|
20.96,40.85 21.40,40.91 21.58,40.87 21.99,41.13 22.60,41.14 22.72,41.18 22.78,41.33 22.92,41.34
|
||||||
|
21.56,42.25 21.39,42.22 21.29,42.10 21.06,42.17 20.78,42.07 20.72,41.87 20.57,41.87
|
||||||
|
22.34,42.31 22.24,42.36 21.56,42.25
|
||||||
|
22.34,42.31 22.52,42.44 22.44,42.63 22.47,42.84 22.71,42.88 22.98,43.19 22.55,43.45 22.37,43.78 22.42,44.01 22.60,44.08 22.63,44.19 22.71,44.24
|
||||||
|
22.92,41.34 23.00,41.74 22.84,41.99 22.58,42.10 22.34,42.31
|
||||||
|
18.44,42.56 18.52,42.43
|
||||||
|
18.44,42.56 17.80,42.90 17.67,42.90
|
||||||
|
78.92,33.39 79.12,33.23
|
||||||
|
20.34,42.83 20.47,42.86 20.48,42.95 20.62,43.03 20.62,43.20 20.80,43.26 20.84,43.17 21.06,43.09 21.40,42.83 21.39,42.75 21.75,42.67 21.61,42.39 21.52,42.33 21.56,42.25
|
||||||
|
20.34,42.83 20.05,42.76 20.06,42.55
|
||||||
|
19.19,43.53 19.22,43.45 19.61,43.17 20.27,42.94 20.34,42.83
|
||||||
|
-89.36,14.42 -89.12,14.37 -88.71,14.03 -88.51,13.98 -88.48,13.85 -88.15,13.99 -87.99,13.88 -87.80,13.89 -87.72,13.81 -87.78,13.52 -87.74,13.45 -87.81,13.40
|
||||||
|
-87.34,12.98 -87.01,13.01 -86.92,13.22 -86.73,13.28 -86.76,13.75 -86.33,13.77 -86.04,14.05 -85.98,13.97 -85.73,13.86 -85.68,13.98 -85.18,14.34 -85.16,14.53 -85.04,14.61 -84.99,14.75 -84.86,14.81 -84.65,14.66 -84.45,14.64 -84.24,14.75 -84.15,14.72 -83.64,14.88 -83.54,14.98 -83.16,14.99
|
||||||
|
-75.28,-0.11 -75.78,0.09 -76.07,0.35 -76.27,0.44 -76.39,0.40 -76.43,0.26 -76.49,0.24 -76.83,0.25 -77.40,0.39 -77.47,0.64 -77.65,0.72 -77.70,0.84 -77.83,0.83 -78.18,0.97 -78.86,1.46
|
||||||
|
-57.60,3.37 -57.65,3.52 -57.83,3.68 -58.03,4.00 -58.05,4.17 -57.85,4.67 -57.92,4.82 -57.88,4.88 -57.71,4.99 -57.33,5.02 -57.21,5.20 -57.32,5.34 -57.19,5.55
|
||||||
|
-54.08,3.33 -53.99,3.59 -54.35,4.05 -54.48,4.84 -54.45,5.01 -54.16,5.36
|
||||||
|
-58.41,-33.94 -58.48,-33.65 -58.38,-33.14 -58.24,-33.11 -58.13,-32.98 -58.20,-32.47
|
||||||
|
-57.61,-30.19 -57.87,-30.59 -57.81,-30.86 -57.90,-30.98 -57.89,-31.20 -58.05,-31.49 -57.99,-31.58 -58.01,-31.68 -58.19,-31.92 -58.12,-32.25 -58.20,-32.47
|
||||||
|
-54.62,-25.58 -54.44,-25.62 -54.15,-25.52 -53.89,-25.67 -53.67,-26.29 -53.75,-26.75 -53.72,-26.88 -53.84,-27.12 -54.16,-27.25 -54.33,-27.42 -54.83,-27.55 -54.91,-27.71 -55.10,-27.87 -55.73,-28.20 -55.69,-28.38 -55.89,-28.37 -56.94,-29.59 -57.22,-29.78 -57.41,-30.03 -57.61,-30.19
|
||||||
|
20.62,69.04 20.90,68.98 20.92,68.91 22.00,68.52 22.85,68.37 23.10,68.26 23.18,68.14 23.32,68.13 23.64,67.95 23.50,67.88 23.54,67.61 23.45,67.46 23.73,67.42 23.77,67.33 23.63,67.23 23.64,67.13 23.99,66.81 23.67,66.38 23.72,66.22 23.91,66.15 24.16,65.81 24.19,65.61 24.12,65.51
|
||||||
|
4.23,51.39 4.17,51.31 3.90,51.21 3.58,51.29 3.43,51.25 3.35,51.38
|
||||||
|
5.99,50.75 5.75,50.76 5.64,50.84 5.75,50.95 5.80,51.15 5.48,51.29 5.21,51.28 5.03,51.47 4.85,51.40 4.76,51.49 4.63,51.42 4.50,51.47 4.38,51.43 4.37,51.36 4.23,51.39
|
||||||
|
6.34,49.45 6.53,49.39 6.74,49.16 6.89,49.21 7.04,49.11 7.45,49.15 7.61,49.06 8.13,48.97 8.14,48.89 7.84,48.64 7.62,48.16 7.53,47.67 7.62,47.59
|
||||||
|
9.52,47.52 9.18,47.67 8.88,47.66 8.57,47.78 8.40,47.69 8.57,47.65 8.45,47.60 7.62,47.59
|
||||||
|
9.53,47.27 9.63,47.47 9.52,47.52
|
||||||
|
9.58,47.06 9.49,47.06 9.53,47.27
|
||||||
|
7.02,45.93 6.77,46.17 6.82,46.28 6.76,46.42 6.43,46.43 6.23,46.33 6.27,46.25 6.20,46.19 6.01,46.14 5.97,46.21 6.10,46.28 6.12,46.38 6.07,46.46 6.16,46.61 6.41,46.76 6.46,46.95 6.67,47.03 7.00,47.32 6.90,47.39 6.97,47.45 7.14,47.49 7.34,47.43 7.62,47.59
|
||||||
|
-7.41,37.18 -7.50,37.59 -7.44,37.73 -7.19,38.01 -7.02,38.04 -6.96,38.19 -7.11,38.18 -7.34,38.46 -7.28,38.71 -7.05,38.91 -7.00,39.06 -7.17,39.14 -7.34,39.47 -7.54,39.66 -7.12,39.68 -6.98,39.80 -6.90,40.02 -7.03,40.17 -6.81,40.34 -6.84,40.78 -6.93,41.01 -6.21,41.53 -6.31,41.64 -6.54,41.67 -6.62,41.94 -7.15,41.98 -7.21,41.90 -7.40,41.83 -7.92,41.88 -8.15,41.81 -8.22,41.90 -8.13,42.02 -8.27,42.14 -8.78,41.94
|
||||||
|
13.81,48.77 13.79,48.59 13.72,48.54 13.49,48.58 13.37,48.36 12.76,48.11 12.95,47.89 12.90,47.72 13.05,47.66 13.01,47.48 12.81,47.54 12.77,47.64 12.69,47.67 12.48,47.64 12.21,47.72 12.19,47.62 11.72,47.58 11.04,47.39 10.87,47.52 10.44,47.55 10.37,47.37 10.18,47.28 10.20,47.36 10.07,47.39 9.97,47.51 9.75,47.58 9.52,47.52
|
||||||
|
16.09,46.86 15.98,46.80 15.96,46.68 15.77,46.71 15.44,46.63 14.89,46.61 14.55,46.40 13.70,46.52
|
||||||
|
18.44,42.56 18.55,42.64 18.47,42.78 18.46,43.00 18.62,43.03 18.67,43.23 18.85,43.35 19.03,43.29 18.95,43.53 19.19,43.53
|
||||||
|
19.19,43.53 19.30,43.59 19.45,43.56 19.50,43.64 19.25,43.97 19.58,44.01 19.12,44.36 19.35,44.88 18.84,44.88 18.66,45.08 18.00,45.14 17.81,45.08 17.65,45.16 17.50,45.12 17.13,45.17 16.92,45.28 16.79,45.20 16.53,45.22 16.29,45.01 16.03,45.19 15.79,45.18 15.74,44.77 16.10,44.52 16.30,44.12 17.08,43.52 17.27,43.45 17.29,43.31 17.65,43.01 17.59,42.94
|
||||||
|
19.01,44.87 19.10,44.97 19.06,45.14 19.14,45.20 19.40,45.21 19.00,45.40 19.06,45.52 18.92,45.60 18.95,45.66 18.84,45.84 18.91,45.93
|
||||||
|
16.95,48.60 17.14,48.84 17.76,48.89 18.09,49.07 18.16,49.26 18.60,49.49 18.83,49.51
|
||||||
|
17.15,48.01 16.87,48.39 16.95,48.60
|
||||||
|
17.15,48.01 17.32,47.99 17.76,47.77 18.72,47.79 18.79,48.00 19.47,48.11 19.63,48.22 19.95,48.15 20.33,48.30 20.49,48.53 21.45,48.55 21.72,48.35 22.13,48.41
|
||||||
|
14.81,50.86 15.02,51.25 14.91,51.46 14.72,51.52 14.74,51.63 14.60,51.83 14.75,52.08 14.55,52.36 14.62,52.53 14.13,52.88 14.41,53.22 14.21,53.95
|
||||||
|
22.88,47.95 22.77,48.11 22.58,48.13 22.35,48.26 22.25,48.41 22.13,48.41
|
||||||
|
28.59,43.74 28.22,43.77 28.05,43.82 27.88,43.99 27.74,43.96 27.43,44.02 27.09,44.17 26.22,44.01 25.82,43.77 25.50,43.67 23.22,43.87 22.92,43.83 22.87,43.95 23.03,44.08 22.71,44.24
|
||||||
|
20.24,46.11 20.71,45.74 20.77,45.75 20.79,45.47 21.49,45.15 21.35,45.01 21.53,44.90 21.36,44.83
|
||||||
|
22.92,41.34 23.64,41.39 24.01,41.46 24.06,41.53 24.49,41.56 24.77,41.36 24.99,41.36 25.25,41.24 25.92,41.31 26.14,41.39 26.07,41.67 26.11,41.73 26.41,41.70 26.58,41.60 26.62,41.40 26.33,41.24 26.33,40.95 26.04,40.73
|
||||||
|
20.06,42.55 19.79,42.48 19.73,42.63 19.65,42.63 19.28,42.17 19.36,42.07 19.34,41.87
|
||||||
|
26.62,48.26 26.79,48.26 26.98,48.16 27.61,47.34 28.07,46.98 28.24,46.64 28.24,46.45 28.10,45.97 28.16,45.65 28.07,45.60 28.21,45.45
|
||||||
|
22.88,47.95 23.20,48.08 23.41,47.99 24.18,47.91 24.58,47.93 24.89,47.72 24.98,47.72 25.46,47.91 26.16,47.99 26.31,48.20 26.62,48.26
|
||||||
|
22.54,49.07 22.81,49.02 22.85,49.06 22.71,49.17 22.73,49.30 22.65,49.54 22.71,49.61 23.71,50.38 23.97,50.41 24.09,50.53 23.98,50.79 24.10,50.87 23.66,51.31 23.61,51.52
|
||||||
|
20.90,55.29 21.39,55.28 22.07,55.06 22.57,55.06 22.82,54.87 22.68,54.56 22.77,54.36
|
||||||
|
31.76,52.10 31.58,52.31 31.62,52.55 31.52,52.70 31.56,52.76 31.26,53.02 31.42,53.20 31.67,53.20 31.85,53.11 32.14,53.09 32.70,53.34 32.71,53.42 32.47,53.55 32.45,53.69 32.20,53.78 31.75,53.81 31.83,54.03 31.40,54.20 31.18,54.45 31.07,54.49 31.15,54.63 30.80,54.78 30.83,54.91 30.98,55.05 30.81,55.31 30.90,55.40 30.91,55.57 30.23,55.85 29.94,55.85 29.48,55.68 29.35,55.78 29.38,55.94 29.03,56.02 28.79,55.94 28.56,56.09 28.28,56.06 28.15,56.14
|
||||||
|
31.76,52.10 32.12,52.05 32.28,52.11 32.44,52.31 32.81,52.25 33.15,52.34 33.74,52.34 33.92,52.25 34.11,51.98 34.40,51.78 34.38,51.72 34.12,51.68 34.27,51.34 34.21,51.26 34.71,51.17 35.06,51.20 35.16,51.06 35.31,51.04 35.44,50.73 35.41,50.54 35.59,50.37 35.67,50.35 35.89,50.44 36.12,50.41 36.31,50.28 36.50,50.28 36.62,50.21 36.76,50.29 37.42,50.41 37.70,50.11 38.05,49.92 38.26,50.05 38.92,49.82 39.17,49.86 39.30,49.74 39.46,49.73 39.78,49.57 40.08,49.58 40.11,49.25 39.89,49.06 39.69,49.01 39.75,48.91 40.00,48.82 39.79,48.81 39.64,48.59 39.84,48.54 39.89,48.36 39.85,48.30 39.96,48.27 39.78,47.89 39.66,47.84 38.90,47.86 38.64,47.67 38.37,47.61 38.29,47.56 38.20,47.32 38.28,47.28 38.20,47.18 38.21,47.09
|
||||||
|
43.44,41.11 43.63,40.93 43.72,40.72 43.57,40.48 43.71,40.17 43.67,40.13 44.01,40.01 44.29,40.04 44.78,39.68
|
||||||
|
87.82,49.16 88.12,49.26 88.19,49.45 88.83,49.45 88.90,49.54 89.01,49.47 89.11,49.50 89.24,49.63 89.40,49.61 89.65,49.72 89.64,49.90 89.98,49.98 90.05,50.09 90.66,50.22 91.02,50.42 91.42,50.47 91.80,50.69 92.19,50.70 92.35,50.86 92.63,50.69 92.78,50.78 92.94,50.78 93.10,50.60 94.25,50.56 94.35,50.22 94.61,50.02 94.93,50.04 95.11,49.94 95.52,49.91 95.79,50.01 95.94,49.96 96.07,50.00 96.32,49.90 96.99,49.88 97.21,49.73 97.36,49.74 97.59,49.91 98.00,50.01 98.25,50.30 98.28,50.53 98.03,50.64 97.84,51.05 98.04,51.45 98.22,51.51 98.35,51.72 98.64,51.80 98.89,52.12 99.72,51.87 99.92,51.76 100.47,51.73 101.38,51.45 102.11,51.35 102.22,50.83 102.32,50.72 102.29,50.59 102.68,50.39 103.16,50.29 103.30,50.20 103.63,50.14 104.08,50.15 104.47,50.31 105.38,50.47 106.22,50.30 106.71,50.31 107.23,49.99 107.92,49.95 107.97,49.65 108.61,49.32 109.24,49.33 110.71,49.14 111.34,49.36 112.08,49.42 112.49,49.53 112.81,49.52 113.06,49.62 113.16,49.80 113.57,50.01 114.30,50.27 114.74,50.23 115.43,49.90 115.72,49.88 116.22,50.01 116.55,49.92 116.68,49.82
|
||||||
|
116.68,49.82 117.25,49.62 117.87,49.51 118.45,49.84 119.26,50.07 119.35,50.28 119.16,50.41 119.45,50.70 120.07,51.60 120.51,51.85 120.75,52.10 120.67,52.30 120.70,52.49 120.66,52.57 120.17,52.60 120.07,52.63 120.04,52.72 120.70,53.17 120.99,53.28 122.34,53.49 123.61,53.55 124.81,53.13 125.08,53.20 125.65,53.04 125.73,52.89 125.87,52.87 126.05,52.74 126.02,52.61 126.34,52.36 126.51,51.93 126.70,51.70 126.71,51.57 126.81,51.51 126.92,51.10 127.31,50.71 127.34,50.35 127.59,50.21 127.49,49.98 127.55,49.80 127.71,49.67 128.00,49.57 128.70,49.60 128.82,49.46 129.07,49.37 129.50,49.39 130.20,48.89 130.55,48.86 130.62,48.77 130.55,48.60 130.80,48.34 130.71,48.13 130.73,48.02 130.92,47.84 130.96,47.71 132.48,47.71 132.71,47.95 133.14,48.11 133.47,48.10 133.84,48.27 134.29,48.37 134.67,48.25 134.67,48.15 134.57,48.02 134.75,47.72 134.54,47.49 134.29,47.41 134.17,47.30 134.20,47.13 133.87,46.50 133.86,46.25 133.70,46.14 133.65,45.96 133.51,45.88 133.44,45.60 133.19,45.49 133.11,45.13 132.94,45.03 131.98,45.24 131.85,45.33 131.45,44.98 130.98,44.84 131.26,44.07 131.17,43.70 131.18,43.51 131.26,43.38 131.07,42.90 130.58,42.81 130.42,42.73 130.58,42.62 130.53,42.54
|
||||||
|
130.53,42.54 130.25,42.74 130.24,42.89 129.90,43.00 129.70,42.45 129.52,42.38 129.31,42.41 129.20,42.22 128.92,42.04 128.05,41.99 128.03,41.90 128.29,41.61 128.15,41.39 128.01,41.45 127.69,41.44 127.18,41.53 126.95,41.77 126.72,41.72 126.60,41.64 126.49,41.36 125.99,40.90 125.73,40.87 125.42,40.66 124.89,40.46 124.39,40.10 124.36,40.00
|
||||||
|
130.53,42.54 130.69,42.30
|
||||||
|
70.96,40.24 70.65,40.20 70.37,40.38 70.38,40.45 70.75,40.72 70.40,41.04 70.29,40.89 69.71,40.66 69.41,40.80 69.36,40.77 69.31,40.63 69.21,40.57 69.30,40.33 69.22,40.29 69.27,40.20 68.95,40.22 68.63,40.17 68.97,40.09 68.80,40.05 68.87,39.91 68.64,39.84 68.59,39.63 68.46,39.54 67.72,39.62 67.43,39.47 67.36,39.22 67.65,39.13 67.69,38.99 68.04,38.98 68.13,38.93 68.05,38.67 68.09,38.47 68.35,38.21 68.29,38.03 67.81,37.49 67.76,37.17
|
||||||
|
35.89,35.92 36.15,35.83 36.20,35.94 36.35,36.00 36.38,36.17 36.64,36.23 36.54,36.46 36.66,36.80 36.94,36.76 37.07,36.65 37.44,36.64 38.19,36.90 38.44,36.86 38.77,36.69 39.36,36.68 40.02,36.83 40.71,37.10 41.52,37.09 42.06,37.21 42.20,37.30 42.31,37.23 42.36,37.11
|
||||||
|
35.45,31.48 35.40,31.23 35.44,31.13 35.14,30.42 35.14,30.14 34.97,29.56
|
||||||
|
35.55,32.40 35.56,31.77 35.45,31.48
|
||||||
|
35.61,32.68 35.55,32.40
|
||||||
|
88.11,27.87 87.14,27.84 86.61,28.10 86.52,27.96 86.41,27.93 86.14,28.11 86.08,28.08 86.06,27.93 85.99,27.91 85.68,28.28 85.12,28.32 85.09,28.37 85.16,28.59 84.80,28.56 84.23,28.91 84.10,29.22 83.94,29.28 83.58,29.18 83.16,29.61 82.85,29.68 82.22,30.06 82.04,30.33 81.64,30.39 81.42,30.34 81.18,30.04 81.11,30.04 81.01,30.16
|
||||||
|
97.32,28.22 97.34,27.94 96.88,27.59 96.90,27.44 97.10,27.12 96.95,27.13 96.73,27.33 96.19,27.26 95.46,26.76 95.13,26.60 95.05,26.35 95.13,26.04 95.02,25.91 94.95,25.70 94.58,25.32 94.55,25.22 94.71,25.05 94.29,24.32 94.13,23.88 93.68,24.01 93.49,23.97 93.33,24.06 93.41,23.68 93.39,23.34 93.35,23.08 93.31,23.03 93.16,23.03 93.08,22.72 93.15,22.23 93.04,22.18 92.96,22.00 92.85,22.01 92.69,22.13 92.57,21.98
|
||||||
|
100.12,20.32 99.89,20.42 99.72,20.33 99.46,20.36 99.49,20.15 99.34,20.08 99.07,20.10 98.92,19.77 98.37,19.69 98.02,19.75 97.82,19.46 97.80,19.13 97.71,18.93 97.75,18.59 97.52,18.50 97.37,18.52 97.45,18.36 97.63,18.29 97.74,17.94 97.71,17.80 98.44,16.98 98.48,16.73 98.66,16.33 98.84,16.42 98.89,16.35 98.82,16.18 98.59,16.05 98.56,15.37 98.19,15.20 98.20,14.98 98.25,14.81 98.57,14.36 98.93,14.05 99.14,13.72 99.18,13.23 99.12,13.03 99.22,12.74 99.41,12.55 99.61,11.78 99.19,11.11 98.76,10.66 98.70,10.19
|
||||||
|
103.09,18.14 103.09,18.14
|
||||||
|
103.09,18.14 103.26,18.28 103.29,18.41 103.95,18.32 104.43,17.70 104.74,17.46 104.82,17.30 104.74,16.88 104.82,16.47 105.05,16.16 105.41,15.99 105.40,15.83 105.64,15.66 105.49,15.26 105.55,14.93 105.50,14.59 105.42,14.47 105.18,14.35
|
||||||
|
100.12,20.32 100.14,20.25 100.32,20.39 100.52,20.18 100.54,20.09 100.40,19.76 100.51,19.55 100.74,19.51 100.97,19.61 101.21,19.55 101.20,19.33 101.29,18.98 101.05,18.44 101.14,18.14 100.91,17.58 100.96,17.54 101.17,17.50 101.69,17.89 101.77,18.03 101.95,18.08 102.10,18.21 102.55,17.97 102.66,17.82 102.81,17.95 103.05,18.03 103.09,18.14
|
||||||
|
101.14,21.57 101.08,21.47 100.70,21.25 100.54,20.99 100.52,20.92 100.62,20.86 100.25,20.73 100.12,20.32
|
||||||
|
97.32,28.22 97.54,28.51 97.60,28.52 98.06,28.19 98.30,27.55 98.45,27.66 98.65,27.57 98.74,26.79 98.69,26.19 98.56,26.07 98.66,25.86 98.47,25.79 98.33,25.59 98.14,25.57 98.01,25.29 97.82,25.25 97.71,25.03 97.74,24.87 97.58,24.77 97.53,24.49 97.67,24.38 97.71,24.23 97.56,23.91 97.69,23.90 98.21,24.11 98.58,24.07 98.84,24.12 98.68,23.91 98.83,23.62 98.80,23.52 98.88,23.38 98.86,23.19 99.42,23.07 99.51,22.96 99.39,22.83 99.34,22.50 99.21,22.28 99.19,22.13 99.92,22.03 99.94,21.76 100.10,21.66 100.15,21.48 100.60,21.47 101.08,21.76 101.13,21.74 101.14,21.57
|
||||||
|
102.13,22.38 102.58,21.90 102.66,21.68 102.74,21.68 102.82,21.81 102.95,21.68 102.85,21.27 103.10,20.89 103.64,20.70 104.10,20.95 104.58,20.65 104.37,20.44 104.62,20.37 104.70,20.21 104.85,20.20 104.93,20.02 104.59,19.62 104.03,19.68 104.06,19.48 103.89,19.30 104.72,18.80 105.15,18.65 105.11,18.41 105.33,18.19 105.46,18.15 105.69,17.74 106.50,16.95 106.55,16.65 106.66,16.49 106.74,16.45 106.85,16.52 106.93,16.35 107.40,16.04 107.39,15.95 107.17,15.80 107.65,15.26 107.48,14.98 107.52,14.71
|
||||||
|
27.40,5.11 27.07,5.20 26.82,5.06 25.53,5.31 25.40,5.26 25.25,5.02 25.07,4.97 24.77,4.93 24.32,4.99 23.42,4.66 23.12,4.74 22.86,4.72 22.71,4.59 22.42,4.13 21.69,4.28 21.54,4.24 20.56,4.46 20.23,4.83 19.81,5.09 19.50,5.13 19.07,4.89 18.83,4.52 18.59,4.35 18.61,3.48
|
||||||
|
30.55,-2.40 30.42,-2.64 30.47,-2.69 30.43,-2.87 30.78,-2.98 30.79,-3.27 30.63,-3.35 30.63,-3.42 30.43,-3.59 30.15,-4.09 29.95,-4.31 29.72,-4.46 29.40,-4.45
|
||||||
|
29.01,-2.72 29.02,-2.80 29.22,-3.05 29.21,-3.83 29.40,-4.45
|
||||||
|
29.58,-1.39 29.35,-1.52 29.20,-1.72 29.13,-1.86 29.13,-2.20 28.88,-2.40 28.89,-2.64 29.01,-2.72
|
||||||
|
35.27,5.49 34.98,5.86 34.71,6.66 34.06,7.23 33.90,7.51 33.67,7.67 33.23,7.76 33.00,7.90 33.28,8.44 33.95,8.44 34.07,8.55 34.08,9.46
|
||||||
|
22.86,10.92 22.49,11.00 22.04,10.82 21.77,10.64 21.71,10.57 21.68,10.29 21.53,10.21 21.40,10.00 21.26,9.97 20.77,9.41 20.34,9.13 20.07,9.13 19.67,9.02 19.15,9.02 18.96,8.94 18.89,8.84 19.11,8.66 18.56,8.05 17.65,7.98 16.78,7.55 16.55,7.87 16.40,7.77 16.38,7.68 15.96,7.51 15.48,7.52
|
||||||
|
13.29,2.16 13.22,2.26 11.56,2.30 11.35,2.30 11.33,2.17
|
||||||
|
11.13,-3.92 11.23,-3.69 11.50,-3.52 11.69,-3.68 11.88,-3.67 11.83,-3.53 11.93,-3.32 11.72,-3.18 11.76,-2.98 11.64,-2.86 11.54,-2.84 11.61,-2.34 11.73,-2.39 11.95,-2.34 12.06,-2.41 12.45,-2.33 12.43,-1.93 12.59,-1.83 12.79,-1.93 12.99,-2.31 13.46,-2.40 13.73,-2.14 13.88,-2.33 13.89,-2.47 13.99,-2.49 14.20,-2.35 14.16,-2.22 14.25,-2.00 14.38,-1.89 14.47,-0.57 14.21,-0.43 14.07,-0.27 13.86,-0.20 13.95,0.35 14.09,0.54 14.32,0.62 14.43,0.90 14.18,1.37 13.85,1.42 13.22,1.25 13.25,1.37 13.17,1.79 13.29,2.16
|
||||||
|
15.48,7.52 15.55,7.79 15.44,7.85 15.12,8.56 14.33,9.20 13.98,9.69 14.24,9.98 14.84,9.94 15.65,10.01 15.28,10.36 15.13,10.65 15.03,11.11 15.12,11.54 15.08,11.85 14.88,12.27 14.85,12.50 14.76,12.66 14.54,12.82 14.46,13.02 14.06,13.08
|
||||||
|
14.06,13.08 14.20,12.38 14.52,12.30 14.62,12.15 14.58,11.53 14.20,11.27 13.98,11.21 13.70,10.87 13.54,10.61 13.41,10.17 13.27,10.04 13.20,9.56 12.93,9.43 12.78,8.82 12.58,8.62 12.40,8.60 12.23,8.28 12.02,7.59 11.77,7.27 11.86,7.12 11.58,6.89 11.48,6.60 11.24,6.45 11.11,6.46 11.01,6.74 10.61,7.06 10.48,6.89 10.21,6.89 10.14,7.00 9.78,6.76 9.66,6.53 9.00,5.92 8.80,5.20 8.56,4.76
|
||||||
|
3.60,11.70 3.66,11.76 3.61,11.89 3.65,12.53 3.95,12.78 4.04,12.93 4.15,13.46 4.66,13.73 5.24,13.76 5.49,13.87 6.30,13.66 6.59,13.41 6.87,13.04 7.01,13.00 7.83,13.34 8.10,13.29 8.75,12.91 9.62,12.81 10.05,13.21 10.48,13.33 11.41,13.35 11.99,13.19 12.12,13.09 12.46,13.09 12.65,13.33 12.87,13.45 13.32,13.67 13.61,13.70
|
||||||
|
1.19,6.09 0.98,6.32 0.74,6.45 0.53,6.85 0.63,7.35 0.51,7.44 0.50,7.55 0.61,7.73 0.58,8.15 0.69,8.35 0.37,8.76 0.49,8.85 0.47,9.12 0.53,9.40 0.41,9.49 0.26,9.43 0.23,9.46 0.34,9.60 0.26,9.64 0.32,9.69 0.38,10.29 -0.09,10.67 0.01,11.02 -0.07,11.12
|
||||||
|
-2.70,9.48 -2.67,9.28 -2.75,9.05 -2.60,8.80 -2.51,8.21 -2.61,8.15 -2.61,8.05 -2.79,7.93 -2.99,7.20 -3.24,6.81 -3.24,6.65 -3.00,5.71 -2.96,5.64 -2.79,5.60 -2.76,5.36 -2.82,5.15 -3.11,5.09
|
||||||
|
-5.52,10.43 -5.46,10.77 -5.49,11.04 -5.35,11.13 -5.25,11.38 -5.29,11.83 -5.11,11.97 -4.70,12.08 -4.43,12.34 -4.42,12.58 -4.48,12.67 -4.23,12.79 -4.33,13.12 -4.15,13.31 -3.95,13.40 -3.53,13.18 -3.30,13.28 -3.25,13.66 -2.95,13.65 -2.87,13.95 -2.59,14.23 -2.46,14.27 -2.11,14.17 -1.97,14.46 -1.70,14.51 -1.05,14.82 -0.76,15.05 -0.54,15.08 -0.41,15.01 -0.24,15.06 0.22,14.91
|
||||||
|
-2.70,9.48 -2.82,9.43 -2.99,9.69 -3.22,9.90 -3.79,9.92 -4.27,9.74 -4.33,9.65 -4.63,9.71 -4.97,9.93 -5.10,10.24 -5.38,10.31 -5.52,10.43
|
||||||
|
-8.49,7.56 -8.30,6.98 -8.33,6.80 -8.60,6.51 -8.29,6.32 -7.89,6.23 -7.80,5.98 -7.45,5.84 -7.40,5.55 -7.43,5.32 -7.57,5.08 -7.59,4.82 -7.54,4.35
|
||||||
|
-8.49,7.56 -8.66,7.69 -8.89,7.26 -9.12,7.22 -9.26,7.38 -9.46,7.42 -9.37,7.70 -9.52,8.35 -9.68,8.48 -9.74,8.45 -9.78,8.54 -10.06,8.43 -10.15,8.52 -10.28,8.49
|
||||||
|
-5.52,10.43 -5.84,10.39 -6.03,10.19 -6.20,10.23 -6.26,10.72 -6.43,10.67 -6.42,10.56 -6.65,10.66 -6.69,10.35 -6.95,10.34 -7.02,10.14 -7.36,10.26 -7.50,10.44 -7.66,10.43 -7.81,10.24 -7.99,10.16
|
||||||
|
-15.04,10.94 -14.68,11.51 -14.27,11.66 -13.73,11.74 -13.74,12.01 -13.95,12.18 -13.71,12.31 -13.67,12.48 -13.73,12.67
|
||||||
|
-11.39,12.40 -11.50,12.20 -11.31,12.02 -10.93,12.21 -10.71,11.90 -10.47,12.14 -10.27,12.21 -9.75,12.03 -9.66,12.14 -9.36,12.26 -9.40,12.46 -9.22,12.48 -9.04,12.40 -8.82,11.92 -8.82,11.67 -8.40,11.37 -8.67,11.01 -8.47,11.05 -8.34,10.99 -8.27,10.49 -8.01,10.32 -7.99,10.16
|
||||||
|
107.52,14.71 107.41,14.56 107.29,14.59 106.94,14.33 106.78,14.34 106.50,14.58 106.35,14.45 106.23,14.48 106.17,14.37 105.98,14.34 106.12,14.05 106.07,13.92 105.90,13.92 105.74,14.08 105.53,14.16 105.35,14.11 105.18,14.35
|
||||||
|
114.06,4.60 114.22,4.48 114.32,4.26 114.42,4.26 114.57,4.05 114.65,4.04 114.78,4.17 114.84,4.39 114.75,4.72 115.03,4.90
|
||||||
|
-88.23,15.73 -88.96,15.15 -89.14,15.07 -89.22,14.87 -89.17,14.61 -89.36,14.42
|
||||||
|
-77.37,8.66 -77.48,8.50 -77.20,7.97 -77.35,7.84 -77.35,7.71 -77.54,7.57 -77.59,7.54 -77.76,7.70 -77.74,7.54 -77.90,7.23
|
||||||
|
-60.74,5.20 -61.39,5.94 -61.13,6.21 -61.20,6.59 -61.15,6.69 -60.87,6.79 -60.72,6.77 -60.35,7.00 -60.35,7.15 -60.58,7.16 -60.63,7.21 -60.61,7.32 -60.72,7.54 -60.51,7.81 -60.38,7.83 -60.03,8.05 -59.96,8.19 -59.85,8.25 -59.83,8.31 -60.02,8.55
|
||||||
|
-60.74,5.20 -60.67,5.16 -60.60,4.95 -61.00,4.54 -61.28,4.52 -61.55,4.29 -61.82,4.20 -62.15,4.10 -62.41,4.16 -62.71,4.02 -62.76,3.67 -62.86,3.59 -62.97,3.59 -63.34,3.94 -63.53,3.89 -64.02,3.93 -64.19,4.13 -64.58,4.14 -64.79,4.28 -64.82,4.23 -64.67,4.01 -64.22,3.59 -64.22,3.20 -64.01,2.67 -64.05,2.50 -63.39,2.41 -63.43,2.16 -64.01,1.93 -64.11,1.62 -64.21,1.53 -64.30,1.46 -64.49,1.45 -64.73,1.25 -65.10,1.11 -65.47,0.69 -65.56,0.69 -65.52,0.84 -65.68,0.98 -66.06,0.79 -66.35,0.77 -66.88,1.22
|
||||||
|
52.49,41.78 53.06,42.15 54.12,42.34 54.85,41.97 55.43,41.30 55.98,41.32
|
||||||
|
55.98,41.32 57.02,41.26 57.12,41.35 57.02,41.45 56.96,41.86 57.29,42.12 57.81,42.19 58.03,42.49 58.28,42.43 58.40,42.29 58.47,42.30 58.15,42.63 58.26,42.69 58.48,42.66 58.59,42.78 58.88,42.56 59.16,42.51 59.35,42.32 59.86,42.30 59.99,42.21 59.94,41.97 60.20,41.80 60.08,41.76 60.14,41.59 60.09,41.40 60.45,41.22 60.87,41.25 61.24,41.19 61.50,41.28 61.90,41.09 62.10,40.68 62.38,40.33 62.48,39.98 63.51,39.38 63.76,39.16 64.16,38.95 64.31,38.98 65.61,38.24 65.97,38.24 66.57,38.01 66.63,37.93 66.53,37.79 66.52,37.35
|
||||||
|
49.23,46.34 48.54,46.61 48.50,46.70 48.56,46.76 48.88,46.71 48.96,46.77 48.55,47.32 48.17,47.71 47.48,47.80 47.29,47.74 47.09,47.95 47.12,48.13 47.06,48.23 46.66,48.41 46.61,48.57 46.70,48.81 47.03,49.15 46.80,49.37 46.89,49.70 46.99,49.85 47.25,50.00 47.33,50.27 47.50,50.40 47.71,50.38 48.33,49.86 48.43,49.83 48.76,49.93 48.84,50.01 48.63,50.61 48.81,50.60 49.32,50.85 49.50,51.08 49.82,51.13 50.31,51.32 50.79,51.73 51.16,51.65 51.34,51.48 51.61,51.48 52.22,51.71 52.57,51.48 53.34,51.48 54.14,51.04 54.42,50.78 54.47,50.58 54.56,50.54 54.65,50.66 54.55,50.95 54.64,51.01 55.36,50.67 55.69,50.58 56.05,50.71 56.14,50.84 56.49,51.02 56.62,50.98 57.01,51.07 57.44,50.89 57.65,50.93 57.84,51.09 58.36,51.06 58.88,50.69 59.45,50.62 59.52,50.58 59.52,50.49 59.81,50.58 59.96,50.80 60.06,50.85 60.42,50.68 60.94,50.70 61.39,50.86 61.59,51.23 61.55,51.32 61.36,51.44 60.46,51.65 60.39,51.77 60.03,51.93 60.43,52.13 60.67,52.15 60.99,52.34 60.77,52.68 61.05,52.97 61.97,52.94 62.08,53.01 62.01,53.11 61.66,53.23 61.20,53.29 61.16,53.34 61.23,53.45 61.53,53.52 61.47,53.58 61.25,53.55 60.98,53.62 61.11,53.75 61.14,53.96 61.23,54.02 61.33,54.05 61.93,53.95 63.85,54.24 64.46,54.38 64.93,54.40 65.09,54.34 65.24,54.52 65.48,54.62 65.71,54.62 65.91,54.69 66.22,54.67 68.16,54.98 68.24,55.05 68.21,55.16 68.52,55.20 68.98,55.39 69.49,55.36 70.18,55.16 70.49,55.28 70.74,55.31 70.91,55.13 71.19,54.60 71.15,54.36 71.05,54.26 71.09,54.21 71.34,54.16 72.00,54.21 72.19,54.33 72.39,54.12 72.45,53.94 72.59,54.00 72.56,54.09 72.62,54.13 72.91,54.11 73.23,53.96 73.62,54.07 73.71,54.04 73.68,53.93 73.40,53.81 73.31,53.71 73.29,53.60 73.41,53.45 73.86,53.62 74.21,53.58 74.35,53.49 74.45,53.65 74.83,53.83 75.22,53.89 75.38,53.97 75.44,54.09 76.84,54.44 76.65,54.15 76.42,54.15 76.48,54.02 77.86,53.27 79.47,51.49 79.99,50.77 80.07,50.76 80.09,50.84 80.22,50.91 80.42,50.95 80.45,51.18 80.74,51.29 80.88,51.28 80.97,51.19 81.13,51.19 81.07,50.97 81.39,50.96 81.47,50.74 82.49,50.73 82.76,50.89 83.36,50.99 83.95,50.77 84.18,50.52 84.32,50.24 84.99,50.06 85.00,49.89 85.23,49.62 86.18,49.50 86.68,49.78 86.73,49.70 86.63,49.56 86.81,49.49 87.00,49.29 87.23,49.22 87.32,49.09
|
||||||
|
34.20,31.32 34.90,29.48
|
||||||
|
33.98,4.22 34.38,4.62
|
||||||
|
41.88,3.98 41.14,3.96 40.77,4.27 39.84,3.85 39.54,3.47 39.23,3.48 38.09,3.65 36.91,4.41 36.08,4.45 35.92,4.62
|
||||||
|
23.38,-17.64 21.42,-18.00 21.11,-17.96 20.75,-18.02 20.39,-17.89 18.96,-17.80 18.72,-17.70 18.40,-17.40 14.02,-17.41 13.48,-17.04 13.10,-16.97 12.55,-17.21 12.01,-17.17 11.74,-17.25
|
||||||
|
25.26,-17.79 25.64,-17.82 26.00,-17.97 26.14,-17.91 26.33,-17.93 26.78,-18.04 27.02,-17.96 27.93,-16.90 28.76,-16.53 28.83,-16.42 28.91,-15.99 29.49,-15.70 30.40,-15.64
|
||||||
|
23.38,-17.64 24.27,-17.48 24.73,-17.52 25.00,-17.57 25.26,-17.79
|
||||||
|
31.29,-22.40 32.43,-21.30 32.35,-21.14 32.48,-20.95 32.49,-20.66 32.67,-20.52 32.99,-19.98 33.01,-19.87 32.78,-19.39 32.85,-19.10 32.70,-18.94 32.72,-18.83 32.88,-18.73 32.99,-18.36 32.97,-17.25 32.88,-16.88 32.95,-16.71 32.24,-16.45 31.94,-16.43 31.69,-16.21 31.49,-16.18 31.24,-16.02 30.44,-16.00 30.40,-15.64
|
||||||
|
12.28,-6.00 12.42,-6.05 13.18,-5.86 16.43,-5.90 16.70,-6.16 16.74,-6.62 16.92,-6.93 16.98,-7.26 17.58,-8.10 18.01,-8.11 18.56,-7.94 18.94,-8.00 19.34,-7.97 19.53,-7.14 19.66,-7.04 19.88,-6.99 20.59,-6.92 20.54,-7.18 20.61,-7.28 21.78,-7.31 21.84,-7.42 21.80,-8.11 21.90,-8.34 21.91,-8.69 21.81,-9.47 22.20,-10.04 22.27,-10.26 22.31,-10.69 22.18,-10.89 22.23,-11.12 22.31,-11.20 22.56,-11.06 23.08,-11.09 23.46,-10.97 23.83,-11.01 23.97,-10.87
|
||||||
|
-16.56,13.59 -15.51,13.59 -15.43,13.73 -15.11,13.81 -14.94,13.79 -14.41,13.50 -13.98,13.54 -13.83,13.41 -13.85,13.34 -14.25,13.24 -15.15,13.56 -15.29,13.40 -15.81,13.33 -15.83,13.16 -16.65,13.15 -16.76,13.06
|
||||||
|
0.22,14.91 0.16,14.50 0.38,14.25 0.35,14.14 0.43,13.97 0.62,13.70 0.90,13.61 1.20,13.36 0.99,13.36 0.99,13.04 1.56,12.64 1.79,12.61 1.96,12.71 2.10,12.70 2.23,12.47 2.07,12.38 2.07,12.31 2.39,11.90
|
||||||
|
18.61,3.48 18.47,3.62 18.16,3.50 17.49,3.69 16.61,3.51 16.48,3.17 16.47,2.83 16.18,2.27
|
||||||
|
16.18,2.26 16.08,2.11 16.14,1.72 16.06,1.68 15.74,1.92 15.34,1.94 15.16,2.04 14.90,2.01 14.88,2.08 14.76,2.08 14.58,2.20 14.48,2.15 13.29,2.16
|
||||||
|
15.48,7.52 15.21,7.21 14.74,6.28 14.51,6.16 14.43,6.04 14.62,5.87 14.56,5.28 14.64,5.18 14.73,4.60 15.06,4.28 15.14,4.04 15.03,4.02 15.13,3.83 15.77,3.13 15.90,3.10 16.06,2.91 16.18,2.26
|
||||||
|
33.98,4.22 33.49,3.76 33.15,3.77 33.00,3.88 32.34,3.71 32.14,3.52 31.94,3.61 31.80,3.80 31.55,3.68 31.15,3.79 30.93,3.63 30.84,3.49
|
||||||
|
13.07,-4.63 12.83,-4.74 12.45,-5.07 12.52,-5.15 12.50,-5.70 12.21,-5.76
|
||||||
|
31.95,-25.96 31.99,-24.46 31.80,-23.89 31.55,-23.48 31.29,-22.40
|
||||||
|
33.20,-14.01 30.23,-14.99 30.40,-15.64
|
||||||
|
34.96,-11.58 34.62,-11.62 34.36,-12.16 34.56,-13.36 34.66,-13.49 34.91,-13.55 35.25,-13.90 35.85,-14.67 35.89,-14.89 35.81,-15.27 35.82,-15.68 35.76,-16.06 35.36,-16.16 35.17,-16.56 35.28,-16.81 35.27,-17.12 35.06,-17.08 35.08,-16.83 34.93,-16.76 34.42,-16.25 34.40,-16.08 34.25,-15.89 34.54,-15.30 34.51,-14.60 34.33,-14.41 33.64,-14.57 33.20,-14.01
|
||||||
|
29.58,-1.39 29.56,-0.98 29.72,0.10 29.93,0.50 29.94,0.82 30.18,0.97 30.32,1.19 30.48,1.24 31.25,2.04 31.27,2.15 31.18,2.27 30.96,2.40 30.73,2.46 30.85,2.89 30.75,3.04 30.91,3.41 30.84,3.49
|
||||||
|
36.52,14.26 36.43,15.13 36.52,15.25 36.91,16.30 36.89,16.62 36.98,16.80 37.01,17.06 37.41,17.06 37.55,17.32 37.78,17.46 38.25,17.58 38.61,18.01
|
||||||
|
42.38,12.47 42.13,12.77 41.95,12.88 41.77,13.18 41.36,13.50 40.82,14.11 40.14,14.46 39.90,14.44 39.53,14.54 39.20,14.48 39.02,14.63 38.81,14.48 38.43,14.43 38.22,14.65 37.88,14.85 37.57,14.15 37.26,14.45 37.02,14.27 36.81,14.32 36.52,14.26
|
||||||
|
27.35,57.53 27.40,57.67 27.54,57.80 27.78,57.87 27.67,57.93 27.50,58.22 27.53,58.44 27.43,58.79 27.76,59.05 27.90,59.28 28.15,59.37 28.01,59.48
|
||||||
|
74.31,32.81 74.66,32.76 74.69,32.49 74.99,32.46 75.30,32.32
|
||||||
|
-84.35,10.98 -84.70,11.05 -84.91,10.95 -85.58,11.19 -85.74,11.06
|
||||||
|
-82.88,8.07 -83.03,8.34 -82.86,8.45 -82.86,8.64 -82.92,8.74 -82.73,8.92 -82.94,9.06 -82.94,9.45 -82.80,9.59 -82.64,9.51 -82.56,9.58
|
||||||
|
-66.88,1.22 -67.21,2.39 -67.62,2.79 -67.86,2.79 -67.83,2.89 -67.31,3.42 -67.66,3.86 -67.86,4.51 -67.82,5.27 -67.64,5.56 -67.58,5.83 -67.47,5.93 -67.48,6.18 -67.86,6.29 -68.47,6.16 -68.94,6.20 -69.27,6.10 -69.43,6.12 -70.13,6.95 -70.74,7.09 -71.13,6.99 -72.01,7.03 -72.21,7.37 -72.39,7.42 -72.47,7.52 -72.45,7.97 -72.36,8.09 -72.39,8.29 -72.67,8.63 -72.80,9.11 -72.96,9.14 -73.06,9.26 -73.37,9.19 -73.01,9.79 -72.87,10.49 -72.69,10.84 -72.45,11.11 -72.25,11.20 -71.96,11.67 -71.32,11.86
|
||||||
|
-68.44,-52.36 -68.46,-52.29 -69.96,-52.01 -71.97,-51.96 -71.95,-51.88 -72.41,-51.54 -72.30,-51.30 -72.38,-51.10 -72.28,-50.91 -72.34,-50.68 -72.51,-50.61 -73.15,-50.74 -73.31,-50.36 -73.50,-50.13 -73.48,-49.77
|
||||||
|
-68.63,-52.65 -68.65,-54.92 -68.06,-54.88 -67.35,-54.93 -66.48,-55.11
|
||||||
|
22.77,54.36 19.60,54.46
|
||||||
|
48.44,28.54 47.67,28.53 47.43,28.99 46.53,29.10
|
||||||
|
44.77,37.14 44.88,36.80 45.02,36.70 45.05,36.47 45.24,36.36 45.36,36.02 45.56,35.98 45.78,35.82 46.27,35.77 46.00,35.61 45.98,35.48 46.11,35.32 46.13,35.13 45.92,35.03 45.68,34.80 45.64,34.57 45.50,34.58 45.44,34.42 45.54,34.22 45.40,33.97 45.74,33.60 45.88,33.61 45.87,33.49 46.02,33.42 46.15,33.23 46.08,33.09 46.11,32.96 46.38,32.93 47.12,32.47 47.37,32.42 47.51,32.15 47.83,31.79 47.68,31.40 47.68,31.00 48.01,30.99 48.01,30.47 48.33,30.29 48.43,30.04 48.55,29.96
|
||||||
|
61.26,35.62 61.25,35.87 61.15,35.98 61.21,36.19 61.12,36.64 60.34,36.64 60.06,36.96 59.45,37.25 59.30,37.51 58.82,37.68 58.26,37.67 57.98,37.83 57.35,37.97 57.31,38.13 57.19,38.22 56.44,38.25 56.32,38.19 56.27,38.08 55.58,38.10 55.38,38.05 54.90,37.78 54.70,37.47 54.19,37.33 53.91,37.34
|
||||||
|
140.97,-2.60 140.97,-6.35 140.86,-6.74 140.98,-6.91 140.98,-9.12
|
||||||
|
9.52,30.23 9.31,30.12 9.64,29.64 9.81,29.18 9.92,27.79 9.75,27.33 9.89,26.85 9.88,26.63 9.86,26.55 9.49,26.33 9.45,26.07 10.00,25.33 10.03,25.05 10.26,24.59 10.40,24.49 10.69,24.55 11.51,24.31 11.97,23.52
|
||||||
|
-13.73,12.67 -15.20,12.68 -15.57,12.49 -16.71,12.35
|
||||||
|
-11.39,12.40 -12.40,12.34 -12.93,12.53 -13.06,12.49 -13.08,12.63 -13.73,12.67
|
||||||
|
0.90,10.99 0.49,10.95 -0.07,11.12
|
||||||
|
-139.06,60.00 -139.19,60.08 -139.08,60.34 -139.68,60.33 -139.97,60.18 -140.45,60.30 -140.53,60.22 -141.00,60.30 -141.00,69.65
|
||||||
|
-130.62,54.71 -130.21,55.03 -130.02,55.28 -130.12,55.69 -130.03,55.89 -130.06,56.07 -130.41,56.12 -130.48,56.23 -130.74,56.34 -131.58,56.60 -131.82,56.59 -131.87,56.79 -132.10,56.86 -132.03,57.03 -132.34,57.08 -132.23,57.20 -133.42,58.34 -133.40,58.41 -133.82,58.71 -134.30,58.90 -134.44,59.09 -134.94,59.29 -135.07,59.44 -135.05,59.58 -135.48,59.79 -136.32,59.60 -136.25,59.53 -136.35,59.46 -136.47,59.46 -136.47,59.28 -136.58,59.15 -136.81,59.15 -137.44,58.90 -137.52,58.92 -137.48,58.99 -137.59,59.23 -138.63,59.78 -138.71,59.90 -139.06,60.00
|
||||||
|
-123.31,48.99 -122.69,48.99
|
||||||
|
-88.89,15.89 -89.24,15.89 -89.16,17.81
|
||||||
|
-75.28,-0.11 -75.48,-0.16 -75.63,-0.12 -75.26,-0.59 -75.25,-0.95 -75.41,-0.92 -75.57,-1.53 -76.09,-2.13 -76.68,-2.56 -77.86,-2.98 -78.18,-3.35 -78.16,-3.47 -78.23,-3.49 -78.35,-3.40 -78.42,-3.78 -78.68,-4.33 -78.69,-4.56 -78.91,-4.71 -78.92,-4.86 -79.03,-4.97 -79.33,-4.93 -79.50,-4.67 -79.52,-4.54 -79.64,-4.45 -79.80,-4.48 -80.14,-4.30 -80.38,-4.46 -80.48,-4.43 -80.35,-4.21 -80.49,-4.17 -80.49,-4.01 -80.30,-4.01 -80.18,-3.88 -80.32,-3.39
|
||||||
|
-69.97,-4.24 -70.34,-3.81 -70.53,-3.87 -70.74,-3.78 -70.07,-2.75 -70.10,-2.66 -70.97,-2.21 -71.40,-2.33 -71.75,-2.15 -71.98,-2.33 -72.22,-2.40 -72.40,-2.43 -72.63,-2.35 -72.94,-2.39 -73.15,-2.28 -73.13,-2.08 -73.20,-1.83 -73.50,-1.69 -73.52,-1.45 -73.66,-1.25 -74.25,-0.97 -74.42,-0.58 -74.80,-0.20 -74.95,-0.19 -75.14,-0.05 -75.28,-0.11
|
||||||
|
-66.88,1.22 -67.08,1.19 -67.12,1.70 -67.40,2.12 -67.56,2.07 -67.82,1.79 -67.94,1.75 -68.19,1.99 -68.26,1.85 -68.18,1.72 -69.32,1.72 -69.54,1.77 -69.85,1.71 -69.85,1.06 -69.31,1.05 -69.16,0.86 -69.15,0.66 -69.28,0.63 -69.47,0.73 -70.05,0.58 -70.07,-0.14 -69.92,-0.32 -69.63,-0.51 -69.61,-0.76 -69.45,-1.00 -69.40,-1.19 -69.97,-4.24
|
||||||
|
-69.58,-10.95 -68.69,-12.50 -68.76,-12.69 -68.98,-12.88 -68.98,-13.50 -69.07,-13.68 -68.87,-14.17 -69.00,-14.27 -69.01,-14.38 -69.36,-14.80 -69.37,-14.96 -69.17,-15.24 -69.42,-15.64 -69.22,-16.15 -68.84,-16.34 -69.03,-16.48 -69.02,-16.64 -69.20,-16.77 -69.44,-17.09 -69.62,-17.20 -69.51,-17.50
|
||||||
|
-69.58,-10.95 -68.85,-11.01 -68.73,-11.12 -68.62,-11.11 -68.31,-10.98 -68.07,-10.70 -67.72,-10.68 -67.42,-10.39 -67.11,-10.27 -66.58,-9.90 -65.92,-9.79 -65.56,-9.80 -65.40,-9.71 -65.31,-9.87 -65.30,-10.15 -65.45,-10.51 -65.32,-11.02 -65.39,-11.25 -65.18,-11.65 -65.19,-11.75 -65.09,-11.74 -64.99,-11.98 -64.51,-12.25 -64.42,-12.44 -63.94,-12.53 -63.69,-12.48 -63.35,-12.68 -63.07,-12.67 -63.02,-12.81 -62.77,-13.00 -62.12,-13.16 -61.79,-13.53 -61.08,-13.49 -60.51,-13.79 -60.40,-14.02 -60.47,-14.18 -60.30,-14.62 -60.27,-15.09 -60.58,-15.10 -60.24,-15.48 -60.18,-16.27 -58.54,-16.33 -58.35,-16.28 -58.35,-16.49 -58.48,-16.70 -58.40,-17.23 -57.99,-17.51 -57.83,-17.51 -57.59,-18.12 -57.50,-18.21 -57.57,-18.28 -57.78,-18.91 -57.73,-18.92 -57.72,-19.04 -57.80,-19.08 -58.13,-19.74 -57.86,-19.98 -58.16,-20.16
|
||||||
|
-69.97,-4.24 -70.05,-4.33 -70.24,-4.30 -70.40,-4.15 -70.80,-4.17 -70.97,-4.35 -71.84,-4.50 -72.35,-4.79 -72.70,-5.07 -72.89,-5.12 -72.98,-5.63 -73.24,-6.10 -73.14,-6.47 -73.76,-6.91 -73.80,-7.08 -73.72,-7.31 -73.96,-7.38 -74.00,-7.56 -73.72,-7.78 -73.78,-7.94 -73.64,-8.07 -73.55,-8.35 -73.36,-8.48 -73.30,-8.65 -72.97,-8.99 -72.97,-9.12 -73.21,-9.41 -72.81,-9.41 -72.38,-9.51 -72.17,-9.84 -72.14,-10.01 -71.24,-9.97 -70.54,-9.44 -70.64,-9.82 -70.64,-11.01 -70.53,-10.95 -70.29,-11.06 -69.96,-10.93 -69.58,-10.95
|
||||||
|
-62.65,-22.23 -62.28,-21.07 -62.28,-20.56 -61.92,-20.06 -61.76,-19.65 -60.01,-19.30 -59.09,-19.29 -58.18,-19.82 -58.16,-20.16
|
||||||
|
-17.05,20.81 -16.96,21.33 -13.02,21.33 -13.15,22.82 -13.03,23.00 -12.62,23.27 -12.02,23.47 -12.02,26.00 -8.68,26.00 -8.68,27.29
|
||||||
|
-8.68,27.29 -4.82,25.00
|
||||||
|
-8.68,27.66 -8.66,28.72 -7.69,29.35 -7.49,29.39 -7.16,29.61 -6.64,29.57 -6.52,29.66 -6.48,29.82 -6.00,29.83 -5.45,29.96 -5.18,30.17 -4.97,30.47 -4.32,30.70 -3.99,30.91 -3.67,30.96 -3.62,31.07 -3.83,31.20 -3.79,31.36 -3.83,31.66 -3.02,31.83 -2.86,32.07 -2.45,32.13 -1.28,32.09
|
||||||
|
-8.68,27.29 -8.68,27.66
|
||||||
|
-12.28,14.81 -12.86,15.24 -12.93,15.45 -13.21,15.62 -13.41,16.06 -13.51,16.14 -13.87,16.15 -14.09,16.42 -14.30,16.58 -14.53,16.66 -14.99,16.68 -15.77,16.49 -16.24,16.53 -16.44,16.20 -16.54,15.84
|
||||||
|
-4.82,25.00 -6.59,24.99 -5.63,16.57 -5.36,16.28 -5.51,15.50 -9.18,15.50 -9.34,15.53 -9.35,15.68 -9.43,15.62 -9.45,15.46 -9.94,15.37 -10.70,15.42 -10.95,15.15 -11.50,15.64 -11.76,15.43 -11.94,14.89 -12.10,14.75 -12.28,14.81
|
||||||
|
4.23,19.14 3.40,18.99 3.26,19.01 3.11,19.15 3.26,19.41 3.20,19.79 3.13,19.85 2.41,20.06 2.22,20.25 1.75,20.33 1.61,20.56 1.17,20.82 1.15,21.10 -4.82,25.00
|
||||||
|
4.23,19.14 5.84,19.48 7.48,20.87 11.97,23.52
|
||||||
|
9.52,30.23 9.04,32.07 8.33,32.54 8.11,33.06 7.73,33.27 7.50,33.83 7.51,34.08 7.75,34.25 7.84,34.41 8.12,34.56 8.25,34.73 8.28,34.98 8.39,35.20 8.25,35.80 8.35,36.37 8.21,36.52 8.44,36.76 8.60,36.83 8.58,36.94
|
||||||
|
23.98,19.50 15.98,23.45 14.98,23.00
|
||||||
|
24.98,22.00 24.98,20.00 23.98,20.00 23.98,19.50
|
||||||
|
25.15,31.65 24.85,31.33 24.96,30.68 24.70,30.20 24.98,29.18 24.98,22.00
|
||||||
|
22.86,10.92 22.92,11.34 22.59,11.58 22.58,11.99 22.47,12.07 22.35,12.66 22.23,12.71 21.93,12.68 21.83,12.79 21.99,13.11 22.23,13.33 22.11,13.80 22.17,13.91 22.54,14.16 22.38,14.55 22.67,14.72 22.76,15.00 22.93,15.16 22.93,15.53 23.11,15.70 23.97,15.72 23.98,19.50
|
||||||
|
24.98,22.00 31.21,21.99 31.40,22.20 31.49,22.15 31.43,22.00 36.87,22.00
|
||||||
|
41.88,3.98 41.34,3.20 40.96,2.81 40.98,-0.87 41.52,-1.57 41.53,-1.70
|
||||||
|
42.92,11.00 42.66,10.60 42.84,10.20 43.07,9.93 43.18,9.88 43.48,9.38 43.62,9.34 43.98,9.01 46.98,8.00 47.98,8.00
|
||||||
|
30.75,-8.19 28.90,-8.49 28.93,-8.59 28.87,-8.79 28.40,-9.22 28.60,-9.68 28.65,-10.55 28.36,-11.48 28.38,-11.57 28.48,-11.81 29.06,-12.35 29.49,-12.42 29.51,-12.23 29.80,-12.16 29.78,-13.44 29.65,-13.41 29.63,-13.30 29.55,-13.25 29.20,-13.40 29.01,-13.37 28.73,-12.93 28.55,-12.84 28.41,-12.52 27.57,-12.23 27.42,-11.94 27.24,-11.78 27.16,-11.58 27.05,-11.62 26.95,-11.90 26.82,-11.97 26.03,-11.89 25.51,-11.75 25.35,-11.62 25.29,-11.21 24.52,-11.44 24.38,-11.42 24.34,-11.37 24.40,-11.26 24.37,-11.13 24.14,-11.03 24.08,-10.89 23.97,-10.87
|
||||||
|
23.38,-17.64 22.15,-16.60 22.04,-16.26 21.98,-15.96 21.98,-13.00 23.96,-12.99 23.88,-12.80 23.99,-12.42 23.97,-11.64 24.05,-11.41 23.97,-10.87
|
||||||
|
33.90,-1.00 33.94,0.17 34.16,0.61 34.41,0.87 34.48,1.04 34.80,1.24 34.78,1.38 34.97,1.64 34.98,1.77 34.91,2.48 34.74,2.82 34.59,2.92 34.52,3.12 34.45,3.16 34.40,3.41 34.44,3.65 34.17,3.81 34.19,3.87 33.98,4.22
|
||||||
|
33.90,-1.00 37.64,-3.05 37.69,-3.25 37.62,-3.51 39.22,-4.69
|
||||||
|
11.33,2.17 9.98,2.17 9.80,2.30
|
||||||
|
11.33,2.17 11.34,1.00 10.03,1.00 9.91,0.96 9.76,1.07 9.59,1.03
|
||||||
|
25.26,-17.79 24.91,-17.82 24.53,-18.05 24.36,-17.98 24.24,-18.02 23.60,-18.46 23.30,-18.03 23.22,-18.00 20.97,-18.32 20.98,-21.96 19.98,-22.00 19.98,-24.75
|
||||||
|
19.98,-24.78 19.98,-28.45 19.54,-28.57 19.31,-28.73 19.25,-28.90 19.16,-28.94 18.84,-28.87 18.10,-28.87 17.45,-28.70 17.35,-28.50 17.36,-28.27 17.06,-28.03 16.88,-28.13 16.76,-28.45 16.45,-28.62
|
||||||
|
19.98,-24.75 19.98,-24.78
|
||||||
|
0.90,10.99 0.79,10.71 0.76,10.39 1.33,10.00 1.39,9.36 1.60,9.05 1.62,7.00 1.53,6.99 1.60,6.61 1.78,6.29 1.62,6.22
|
||||||
|
42.80,16.37 43.17,16.69 43.18,16.81 43.12,16.94 43.16,17.21 43.24,17.27 43.19,17.36 43.42,17.52 43.92,17.32 44.16,17.40 45.15,17.43 45.54,17.30 46.31,17.23 46.73,17.27 46.98,16.95 47.14,16.95 47.44,17.11 47.58,17.45 48.17,18.16 49.04,18.58 51.98,19.00
|
||||||
|
51.98,19.00 52.73,17.30 52.80,17.27 53.09,16.65
|
||||||
|
55.19,22.70 55.64,22.00 54.98,20.00 51.98,19.00
|
||||||
|
55.19,22.70 55.10,22.62 52.56,22.93 51.59,24.08 51.57,24.29
|
||||||
|
42.36,37.11 41.79,36.60 41.42,36.51 41.30,36.38 41.25,36.07 41.35,35.64 41.22,35.29 41.19,34.77 40.99,34.43 40.69,34.33 38.77,33.37
|
||||||
|
35.79,32.73 36.37,32.39 36.82,32.32 38.77,33.37
|
||||||
|
35.61,32.68 35.79,32.73
|
||||||
|
39.15,32.12 39.29,32.24 39.25,32.35 39.04,32.31 38.98,32.47 39.06,32.49 38.77,33.37
|
||||||
|
34.95,29.35 36.07,29.20 36.48,29.50 36.76,29.87 37.47,30.00 37.65,30.33 37.98,30.50 36.96,31.49 38.96,31.99 39.15,32.12
|
||||||
|
46.53,29.10 46.36,29.06 44.69,29.20 42.07,31.08 40.37,31.94 39.15,32.12
|
||||||
|
61.59,25.20 61.66,25.75 61.75,25.84 61.84,26.23 62.13,26.37 62.24,26.36 62.31,26.49 62.44,26.56 63.16,26.65 63.30,27.15 63.17,27.25 62.76,27.25 62.81,27.50 62.76,28.24 62.56,28.24 62.35,28.41 61.89,28.55 61.62,28.79 61.32,29.37 60.84,29.86
|
||||||
|
60.84,29.86 61.81,30.91 61.76,31.29 61.66,31.38 60.82,31.50 60.83,32.25 60.56,33.06 60.56,33.14 60.92,33.51 60.51,33.64 60.49,34.09 60.64,34.31 60.89,34.32 60.73,34.52 60.95,34.65 61.08,34.86 61.15,35.09 61.10,35.27 61.19,35.31 61.28,35.51 61.26,35.62
|
||||||
|
73.73,36.89 74.04,36.83 74.54,37.02
|
||||||
|
60.84,29.86 62.48,29.41 63.57,29.50 64.10,29.39 64.39,29.54 65.10,29.56 66.18,29.84 66.31,29.97 66.24,30.11 66.35,30.80 66.40,30.91 66.60,31.02 66.83,31.26 66.92,31.31 67.45,31.23 67.74,31.34 67.58,31.51 68.02,31.68 68.16,31.80 68.44,31.75 68.60,31.80 68.87,31.63 69.08,31.74 69.28,31.94 69.24,32.43 69.41,32.68 69.50,33.02 69.92,33.11 70.26,33.29 70.28,33.37 70.13,33.62 69.87,33.90 69.89,34.01 69.99,34.05 70.33,33.96 70.65,33.95 71.05,34.05 71.10,34.37 70.97,34.53 71.62,35.18 71.55,35.29 71.60,35.41 71.57,35.55 71.40,35.88 71.19,36.04 71.23,36.12 71.62,36.44 71.77,36.43 72.25,36.73 72.62,36.83 73.73,36.89
|
||||||
|
70.95,42.25 70.72,42.17 70.58,42.04 70.42,42.08 70.10,41.82 69.15,41.43 68.99,41.21 68.58,40.88 68.57,40.62 68.42,40.62 68.05,40.81 68.11,41.03 67.94,41.20 66.71,41.18 66.50,41.99 66.01,42.00 66.10,42.99 65.80,42.88 65.50,43.31 64.91,43.71 64.44,43.55 63.21,43.63 61.99,43.49 61.16,44.17 61.01,44.39 58.56,45.56 55.98,44.99 55.98,41.32
|
||||||
|
13.61,13.70 13.45,14.38 14.37,15.75 15.47,16.91 15.74,19.90 15.96,20.35 15.59,20.73 15.54,20.87 15.61,20.95 15.18,21.52 15.17,21.92 14.98,23.00
|
||||||
|
11.97,23.52 13.48,23.18 14.22,22.62 14.98,23.00
|
||||||
|
0.22,14.91 0.29,14.98 0.95,14.98 1.30,15.27 3.00,15.34 3.06,15.43 3.50,15.36 3.52,15.48 3.84,15.70 4.12,16.36 4.23,17.00 4.23,19.14
|
||||||
|
-0.07,11.12 -0.30,11.17 -0.63,10.93 -0.70,10.99 -1.04,11.01 -2.83,11.00 -2.91,10.59 -2.79,10.43 -2.78,9.75 -2.70,9.48
|
||||||
|
-13.29,9.05 -13.03,9.10 -12.96,9.26 -12.76,9.37 -12.50,9.86 -12.43,9.90 -12.28,9.93 -12.14,9.88 -11.92,9.92 -11.91,9.99 -11.27,10.00 -10.69,9.31 -10.75,9.10 -10.62,9.06 -10.61,8.87 -10.50,8.69 -10.71,8.34 -10.56,8.32 -10.39,8.48 -10.28,8.49
|
||||||
|
5.79,49.54 6.01,49.45 6.18,49.50 6.34,49.45
|
||||||
|
56.08,26.06 56.17,26.05 56.14,25.69 56.30,25.65
|
||||||
|
30.51,-1.07 30.36,-1.07 30.10,-1.37 29.93,-1.47 29.83,-1.34 29.58,-1.39
|
||||||
|
30.51,-1.07 30.51,-1.21 30.81,-1.56 30.88,-2.14 30.83,-2.34 30.55,-2.40
|
||||||
|
33.90,-1.00 30.81,-0.99 30.51,-1.07
|
||||||
|
34.59,-11.97 34.54,-12.02 34.62,-12.07 34.59,-11.97
|
||||||
|
87.32,49.09 87.82,49.16
|
||||||
|
45.50,40.66 45.48,40.61 45.55,40.62 45.50,40.66
|
||||||
|
44.99,41.09 45.00,41.02 44.99,41.09
|
||||||
|
12.44,43.98 12.40,43.93 12.49,43.90 12.50,43.99 12.44,43.98
|
||||||
|
70.57,41.02 70.65,40.94 70.57,41.02
|
||||||
|
-117.13,32.53 -114.72,32.72 -114.84,32.51 -111.04,31.32 -108.21,31.33 -108.21,31.78 -106.45,31.77 -106.15,31.45 -104.92,30.58 -104.68,30.13 -104.62,29.85 -104.40,29.57 -103.26,29.00 -103.09,29.04 -102.89,29.22 -102.73,29.64 -102.34,29.86 -101.99,29.80 -101.55,29.81 -101.38,29.74 -100.75,29.18 -100.33,28.50 -100.30,28.33 -99.75,27.73 -99.51,27.55 -99.46,27.06 -99.23,26.76 -99.11,26.45 -98.28,26.11 -97.80,26.04 -97.38,25.87 -97.15,25.96
|
||||||
|
-92.24,14.55 -92.16,14.69 -92.16,14.96 -92.07,15.07 -92.20,15.28 -91.74,16.07 -90.45,16.07 -90.42,16.39 -90.63,16.51 -90.71,16.71 -91.41,17.26 -90.99,17.25 -90.99,17.82 -89.16,17.81 -89.13,17.97 -89.05,18.00 -88.90,17.91 -88.81,17.97 -88.46,18.48 -88.04,18.42 -88.04,18.20 -87.83,18.18
|
||||||
|
-54.62,-25.58 -54.61,-25.43 -54.44,-25.12 -54.28,-24.31 -54.32,-24.13 -54.24,-24.05 -54.63,-23.81 -55.19,-24.02 -55.42,-23.95 -55.65,-22.89 -55.62,-22.67 -55.85,-22.31 -56.25,-22.26 -56.45,-22.08 -56.63,-22.23 -56.94,-22.27 -57.57,-22.18 -57.72,-22.10 -57.96,-22.11 -57.95,-21.49 -57.83,-21.00 -58.16,-20.16
|
||||||
|
-62.65,-22.23 -62.21,-22.61 -61.80,-23.18 -61.21,-23.56 -61.03,-23.76 -60.51,-23.96 -59.89,-24.09 -59.19,-24.56 -58.37,-24.96 -57.96,-25.05 -57.64,-25.33 -57.57,-25.53 -57.75,-25.70 -57.89,-26.01 -58.11,-26.18 -58.20,-26.38 -58.19,-26.63 -58.32,-26.86 -58.62,-27.13 -58.60,-27.31 -58.17,-27.27 -57.11,-27.47 -56.60,-27.47 -56.44,-27.55 -56.16,-27.32 -55.71,-27.41 -55.43,-27.01 -55.14,-26.93 -54.83,-26.65 -54.68,-26.31 -54.62,-25.58
|
||||||
|
-62.65,-22.23 -62.84,-22.00 -63.92,-22.03 -64.27,-22.60 -64.33,-22.83 -64.61,-22.23 -64.99,-22.11 -65.77,-22.10 -66.22,-21.80 -66.37,-22.11 -66.71,-22.22 -66.80,-22.41 -67.03,-22.55 -67.19,-22.82
|
||||||
|
26.62,48.26 26.85,48.39 27.23,48.37 27.55,48.48 27.82,48.42 28.09,48.26 28.29,48.24 28.46,48.09 28.53,48.15 28.77,48.12 28.92,47.95 29.13,47.96 29.21,47.78 29.13,47.49 29.54,47.27 29.51,47.13 29.57,46.96 29.88,46.83 29.94,46.72 29.92,46.54 30.13,46.42 29.84,46.35 29.71,46.45 29.61,46.40 29.30,46.47 29.20,46.38 29.19,46.52 28.96,46.46 28.93,46.36 29.01,46.18 28.95,46.05 28.74,45.94 28.73,45.85 28.49,45.67 28.50,45.52 28.21,45.45
|
||||||
|
67.76,37.17 67.96,36.97 68.07,36.95 68.91,37.33 69.30,37.12 69.41,37.21 69.40,37.40 69.49,37.55 69.82,37.61 70.04,37.55 70.19,37.58 70.25,37.66 70.21,37.92 70.62,38.33 70.88,38.46 71.26,38.31 71.33,38.17 71.28,37.92 71.58,37.91 71.43,37.13 71.53,36.85 71.67,36.70 71.80,36.69 72.36,36.98 72.66,37.03 72.90,37.27 73.38,37.46 73.72,37.42 73.65,37.24 74.17,37.33 74.35,37.42 74.66,37.39 74.89,37.23
|
||||||
|
66.52,37.35 66.83,37.37 67.07,37.33 67.20,37.24 67.52,37.27 67.76,37.17
|
||||||
|
61.26,35.62 61.34,35.63 61.62,35.43 61.98,35.44 62.31,35.17 62.46,35.25 62.69,35.26 63.06,35.45 63.17,35.68 63.13,35.85 64.01,36.01 64.51,36.34 64.82,37.13 65.09,37.24 65.55,37.25 65.64,37.47 65.77,37.57 66.11,37.41 66.52,37.35
|
||||||
|
92.57,21.98 92.49,22.69 92.36,22.93 92.25,23.68 91.93,23.69 91.94,23.50 91.75,23.29 91.75,23.05 91.62,22.98 91.51,23.03 91.44,23.20 91.37,23.20 91.37,23.07 91.32,23.10 91.16,23.66 91.23,23.92 91.37,24.09 91.88,24.20 91.95,24.36 92.10,24.41 92.23,24.88 92.44,24.85 92.47,24.94 92.05,25.17 90.44,25.16 89.83,25.29 89.82,25.94 89.67,26.21 89.59,26.19 89.55,26.01 89.37,26.01 89.19,26.11 89.02,26.41 88.92,26.38 88.97,26.25 88.83,26.25 88.68,26.29 88.62,26.43 88.42,26.57 88.35,26.50 88.44,26.37 88.15,26.09 88.08,25.89 88.50,25.54 88.77,25.49 88.95,25.26 88.82,25.18 88.46,25.19 88.31,24.88 88.15,24.91 88.02,24.63 88.15,24.49 88.29,24.48 88.72,24.27 88.70,24.00 88.57,23.67 88.74,23.44 88.72,23.25 88.93,23.19 88.85,23.04 89.05,22.09
|
||||||
|
2.39,11.90 2.37,12.22 2.81,12.38 3.60,11.70
|
||||||
|
18.61,3.48 18.62,3.30 18.49,2.92 18.21,2.41 18.07,2.01 18.06,1.53 17.90,1.12 17.89,0.23 17.72,-0.28 17.75,-0.55 17.28,-1.00 16.88,-1.23 16.54,-1.84 16.22,-2.18 16.22,-3.03 16.15,-3.46 15.99,-3.77 15.87,-3.93 15.60,-4.03 15.27,-4.31 14.71,-4.88 14.41,-4.83 14.37,-4.59 14.45,-4.45 14.36,-4.30 13.94,-4.48 13.72,-4.45 13.66,-4.72 13.41,-4.84 13.14,-4.60 13.07,-4.63
|
||||||
|
28.98,-30.00 29.10,-29.92 29.14,-29.70 29.29,-29.57 29.39,-29.27 29.30,-29.09 28.95,-28.88 28.63,-28.58 27.74,-28.94 27.29,-29.52 27.06,-29.63 27.39,-30.32 27.75,-30.60 28.06,-30.63 28.39,-30.15 28.65,-30.13 28.98,-30.00
|
||||||
|
29.36,-22.19 29.66,-22.15 30.19,-22.29 30.92,-22.29 31.29,-22.40
|
||||||
|
19.98,-24.78 20.43,-25.15 20.79,-25.92 20.82,-26.16 20.63,-26.44 20.69,-26.82 21.69,-26.84 21.79,-26.71 22.09,-26.58 22.22,-26.39 22.60,-26.13 22.88,-25.46 23.06,-25.31 23.39,-25.29 23.89,-25.60 24.19,-25.63 24.33,-25.74 24.75,-25.82 25.44,-25.71 25.58,-25.61 25.91,-24.75 26.40,-24.61 26.84,-24.24 26.99,-23.70 27.09,-23.58 27.76,-23.20 28.21,-22.69 28.38,-22.59 28.84,-22.48 29.13,-22.21 29.36,-22.19
|
||||||
|
34.96,-11.58 35.56,-11.60 35.79,-11.45 35.91,-11.45 36.31,-11.71 36.67,-11.68 36.98,-11.57 37.37,-11.71 37.72,-11.58 37.92,-11.29 38.18,-11.28 38.49,-11.41 38.79,-11.23 39.32,-11.12 39.99,-10.82 40.46,-10.46
|
||||||
|
-84.35,10.98 -84.17,10.78 -83.92,10.74 -83.71,10.79 -83.64,10.92
|
||||||
|
28.15,56.14 27.90,56.08 27.64,55.91 27.58,55.80 27.05,55.83 26.59,55.67
|
||||||
|
28.21,45.45 28.45,45.29 28.76,45.23 28.78,45.31 29.40,45.42 29.71,45.26
|
||||||
|
21.36,44.83 21.91,44.67 22.09,44.54 22.50,44.71 22.64,44.65 22.73,44.57 22.55,44.54 22.49,44.44 22.71,44.24
|
||||||
|
56.23,25.30 56.24,25.21 56.29,25.28 56.23,25.30
|
||||||
|
32.11,-26.84 32.89,-26.85
|
||||||
|
71.74,39.98 71.67,39.95 71.75,39.91 71.79,40.00 71.74,39.98
|
||||||
|
-122.69,48.99 -95.16,48.99 -95.16,49.37 -94.85,49.30 -94.80,49.00 -94.62,48.74 -94.06,48.66 -93.71,48.53 -93.38,48.62 -93.00,48.61 -92.50,48.44 -92.41,48.28 -92.17,48.34 -92.01,48.30 -91.52,48.06 -90.92,48.21 -90.74,48.10 -90.09,48.12 -89.90,48.00 -89.46,48.00 -88.38,48.30 -84.88,46.90 -84.78,46.64 -84.56,46.46 -84.15,46.54 -84.09,46.23 -83.98,46.08 -83.62,46.12 -83.47,45.99 -83.59,45.82 -82.55,45.35 -82.14,43.57 -82.41,43.07 -82.55,42.62 -83.07,42.30 -83.15,42.14 -83.14,41.98 -83.03,41.83 -82.69,41.68 -82.44,41.67 -81.28,42.21 -80.25,42.37 -79.04,42.80 -78.92,42.91 -79.03,43.02 -79.06,43.28 -79.17,43.47 -78.85,43.58 -76.82,43.63 -76.46,44.06 -76.15,44.30 -75.00,44.97 -71.52,45.01 -71.33,45.29 -71.13,45.26 -71.00,45.34 -70.87,45.27 -70.80,45.40 -70.69,45.43 -70.70,45.55 -70.42,45.74 -70.30,45.91 -70.25,46.25 -70.07,46.44 -70.01,46.71 -69.24,47.46 -69.05,47.43 -69.05,47.27 -68.94,47.21 -68.24,47.35 -67.81,47.08 -67.80,45.73 -67.43,45.60 -67.49,45.47 -67.43,45.38 -67.47,45.28 -67.37,45.17 -67.12,45.17
|
||||||
|
34.96,-11.58 34.89,-11.39 34.77,-11.34 34.61,-11.08 34.66,-10.71 34.58,-10.53 34.52,-10.03 34.32,-9.73 34.09,-9.54 34.00,-9.50 33.95,-9.66 33.89,-9.67
|
||||||
|
107.97,21.51 107.76,21.66 107.35,21.61 107.27,21.71 107.06,21.79 106.97,21.92 106.66,21.98 106.65,22.24 106.55,22.34 106.55,22.50 106.78,22.78 106.54,22.91 106.28,22.86 106.15,22.97 105.84,22.92 105.55,23.07 105.28,23.35 104.86,23.14 104.80,22.91 104.69,22.82 104.37,22.70 104.14,22.80 104.05,22.75 103.94,22.54 103.62,22.78 103.49,22.59 103.33,22.77 102.98,22.45 102.83,22.59 102.47,22.75 102.13,22.38
|
||||||
|
7.44,43.75 7.38,43.73
|
||||||
|
12.43,41.91 12.43,41.91
|
||||||
|
113.55,22.22 113.48,22.20
|
||||||
|
114.02,22.51 114.12,22.57 114.27,22.54
|
||||||
|
70.61,39.79 70.48,39.88 70.70,39.85 70.61,39.79
|
||||||
|
71.22,39.91 71.01,39.90 71.04,39.99 70.97,40.04 70.98,40.13 71.23,40.05 71.18,39.98 71.22,39.91
|
||||||
|
145.94,43.10 145.78,43.48 145.69,43.55 145.54,43.51 145.24,43.78 145.27,43.91 145.62,44.29 145.66,44.45 145.58,44.79
|
||||||
|
-1.28,32.09 -1.23,32.11 -1.24,32.34 -1.07,32.47 -1.45,32.78 -1.68,33.32 -1.63,33.57 -1.71,33.78 -1.71,34.18 -1.79,34.37 -1.73,34.47 -1.85,34.61 -1.80,34.75 -2.13,34.97 -2.22,35.10
|
||||||
|
29.00,9.61 29.47,9.77 29.60,9.92 29.61,10.07 30.00,10.28 30.76,9.73 31.22,9.80 31.79,10.38 31.93,10.66 32.42,11.09 32.34,11.31 32.34,11.72 32.07,12.01 32.74,12.01 32.72,12.22 33.20,12.22 33.07,11.59 33.17,10.85 33.13,10.76 33.38,10.65 33.91,10.18 33.96,9.86 33.87,9.51 34.08,9.46
|
||||||
|
24.15,8.67 24.21,8.77 24.53,8.89 24.67,9.39 24.78,9.53 24.79,9.81 24.96,9.99 25.02,10.24 25.10,10.31 25.86,10.41 25.92,10.17 26.55,9.53 26.66,9.48 27.07,9.61 27.88,9.60
|
||||||
|
27.88,9.60 28.05,9.33 28.84,9.33 28.84,9.46 29.00,9.61
|
||||||
|
-56.48,1.94 -56.70,2.04 -57.20,2.85 -57.30,3.38 -57.60,3.37
|
||||||
|
-54.62,2.33 -54.40,2.46 -54.20,2.82 -54.20,3.14 -54.08,3.33
|
||||||
|
33.89,-9.67 33.70,-9.60 33.42,-9.61 33.33,-9.52 32.92,-9.41
|
||||||
|
33.59,46.10 33.66,46.22 34.22,46.10 34.45,45.97 34.69,45.98 34.79,45.89 34.80,45.79 35.00,45.73
|
||||||
|
77.05,35.11 76.77,35.66
|
||||||
|
77.05,35.11 77.80,35.50
|
||||||
|
79.12,33.23 79.38,33.15 79.33,32.98 79.50,32.73 79.38,32.57 79.22,32.50
|
||||||
|
79.22,32.50 78.92,32.36 78.70,32.60 78.41,32.56 78.49,32.23
|
||||||
|
78.49,32.23 78.73,31.98 78.72,31.88
|
||||||
|
78.72,31.88 78.69,31.74 78.80,31.62 78.73,31.47 78.74,31.32 78.84,31.30
|
||||||
|
78.84,31.30 78.97,31.33 79.01,31.41 79.11,31.40 79.37,31.08
|
||||||
|
79.37,31.08 79.57,30.95 79.79,30.97 79.91,30.90
|
||||||
|
79.91,30.90 80.19,30.76 80.19,30.57
|
||||||
|
80.19,30.57 80.68,30.41 80.99,30.24 81.01,30.16
|
||||||
|
90.33,28.12 90.35,28.24 90.22,28.28 89.98,28.31 89.80,28.24
|
||||||
|
89.80,28.24 89.48,28.06 89.12,27.62
|
||||||
|
89.12,27.62 89.02,27.51
|
||||||
|
89.02,27.51 88.89,27.32
|
||||||
|
75.30,32.32 75.32,32.22 75.25,32.14 74.74,31.95 74.56,31.82 74.51,31.71 74.59,31.47 74.52,31.19 74.63,31.03 74.34,30.89 73.90,30.44 73.93,30.22 73.81,30.09 73.38,29.93 73.23,29.55 72.90,29.03 72.34,28.75 72.13,28.35 71.95,28.18 71.87,27.96 70.80,27.71 70.69,27.77 70.63,27.94 70.40,28.03 70.14,27.85 69.90,27.47 69.54,27.12 69.47,26.80 69.51,26.74 70.15,26.51 70.10,25.91 70.26,25.71 70.65,25.67 70.65,25.42 71.05,24.69 70.97,24.57 71.04,24.40 70.72,24.24 70.58,24.28 70.55,24.42 70.49,24.41 70.10,24.29 70.02,24.19 69.81,24.17 69.56,24.27 68.83,24.26 68.78,24.31 68.73,24.27 68.72,23.96 68.38,23.95 68.17,23.86
|
||||||
|
36.69,45.62 36.69,45.43 36.59,45.30 36.64,45.26 36.51,45.20 36.61,44.95
|
||||||
|
-55.81,-31.03 -56.00,-31.08 -56.00,-30.85
|
||||||
|
-56.00,-30.85 -56.18,-30.63 -56.83,-30.11 -57.12,-30.14 -57.21,-30.28 -57.55,-30.26 -57.61,-30.19
|
||||||
|
-73.48,-49.77 -73.58,-49.58 -73.46,-49.31 -73.14,-49.30
|
||||||
|
-73.14,-49.30 -73.15,-49.19 -73.03,-49.01 -72.65,-48.84 -72.58,-48.48 -72.35,-48.37 -72.29,-48.23 -72.33,-48.11 -72.51,-47.97 -72.52,-47.88 -72.35,-47.49 -72.04,-47.24 -71.90,-47.20 -71.96,-47.02 -71.94,-46.83 -71.70,-46.65 -71.76,-46.32 -71.88,-46.16 -71.63,-45.95 -71.75,-45.84 -71.75,-45.58 -71.51,-45.51 -71.35,-45.33 -71.35,-45.23 -71.60,-44.98 -72.04,-44.90 -72.06,-44.77 -71.26,-44.76 -71.16,-44.56 -71.21,-44.44 -71.82,-44.38 -71.81,-44.11 -71.68,-43.93 -71.79,-43.75 -71.74,-43.70 -71.75,-43.59 -71.90,-43.44 -71.90,-43.35 -71.75,-43.24 -71.78,-43.17 -72.10,-43.07 -72.14,-42.58 -72.05,-42.47 -72.11,-42.25 -72.03,-42.15 -71.86,-42.15 -71.75,-42.05 -71.91,-41.65 -71.87,-40.89 -71.94,-40.79 -71.80,-40.44 -71.70,-40.34 -71.82,-40.18 -71.66,-40.02 -71.64,-39.89 -71.72,-39.64 -71.54,-39.60 -71.42,-39.29 -71.40,-38.94 -70.95,-38.74 -70.85,-38.54 -70.97,-38.45 -71.03,-38.04 -71.17,-37.76 -71.13,-37.45 -71.20,-37.30 -71.12,-37.11 -71.19,-36.84 -71.06,-36.52 -70.75,-36.39 -70.72,-36.28 -70.40,-36.06 -70.42,-35.52 -70.56,-35.25 -70.39,-35.15 -70.29,-34.73 -70.05,-34.30 -69.85,-34.22 -69.89,-33.73 -69.80,-33.40 -69.82,-33.28 -70.02,-33.27 -70.08,-33.20 -70.09,-33.03 -70.02,-32.88 -70.12,-32.81 -70.17,-32.47 -70.32,-32.27 -70.36,-32.08 -70.25,-31.96 -70.45,-31.84 -70.59,-31.57 -70.52,-31.15 -70.39,-31.12 -70.31,-31.02 -70.35,-30.90 -70.15,-30.36 -69.96,-30.36 -69.84,-30.18 -69.96,-30.08 -69.93,-29.77 -70.03,-29.32 -69.83,-29.10 -69.66,-28.41 -69.17,-27.92 -68.85,-27.15 -68.71,-27.10 -68.59,-27.14 -68.32,-26.97 -68.32,-26.88 -68.59,-26.47 -68.58,-26.35 -68.41,-26.15 -68.59,-25.42 -68.50,-25.16 -68.38,-25.09 -68.56,-24.84 -68.56,-24.75 -68.25,-24.39 -67.36,-24.03 -67.01,-23.00 -67.19,-22.82
|
||||||
|
75.32,34.58 74.30,34.77 73.96,34.65 73.81,34.42 73.81,34.33 73.97,34.24 73.92,34.04 74.25,33.99 74.22,33.89 74.00,33.79 73.98,33.72 74.15,33.51 73.99,33.22 74.30,32.99 74.31,32.81
|
||||||
|
23.61,51.52 23.54,51.71 23.63,51.81 23.65,52.04 23.18,52.29 23.41,52.52 23.84,52.66 23.92,52.77 23.86,53.11 23.48,53.94
|
||||||
|
34.08,9.46 34.16,9.85 34.31,10.19 34.28,10.53 34.34,10.66 34.57,10.88 34.77,10.75 34.93,10.86 34.96,11.28 35.11,11.82 35.67,12.62 36.13,12.76 36.16,13.09 36.39,13.63 36.52,14.26
|
||||||
|
2.39,11.90 2.29,11.69 1.98,11.42 1.43,11.45 1.32,11.30 1.15,11.25 1.08,11.08 0.99,11.08 0.90,10.99
|
||||||
|
31.95,-25.96 31.87,-25.98 31.42,-25.75 31.21,-25.84 30.80,-26.41 30.79,-26.76 30.88,-26.79 31.06,-27.11 31.47,-27.30 31.96,-27.31 31.99,-26.82 32.11,-26.84
|
||||||
|
32.11,-26.84 32.06,-26.02 31.95,-25.96
|
||||||
|
76.77,35.66 76.56,35.77 76.55,35.89 76.15,35.83 76.07,35.98 75.91,36.05 75.97,36.38
|
||||||
|
27.33,5.19 27.79,4.64 28.19,4.35 28.43,4.32 28.73,4.50 29.22,4.39 29.47,4.61 29.68,4.59 30.19,3.98 30.51,3.84 30.59,3.62 30.76,3.62 30.84,3.49
|
||||||
|
22.86,10.92 23.31,10.39 23.65,9.82 23.62,9.34 23.47,9.11 23.55,8.94 23.54,8.82 23.68,8.73 24.15,8.67
|
||||||
|
46.11,38.88 46.49,38.91
|
||||||
|
44.78,39.68 45.14,39.25 45.48,39.01 46.11,38.88
|
||||||
|
46.49,38.91 47.48,39.50 47.77,39.65 48.00,39.68 48.32,39.40 48.10,39.24 48.29,39.02 48.05,38.93 48.00,38.85 48.59,38.41 48.87,38.44
|
||||||
|
81.01,30.16 80.85,30.14 80.40,29.73 80.25,29.42 80.23,29.19 80.08,28.99 80.07,28.83 80.42,28.61 80.59,28.65 81.85,27.87 81.99,27.91 82.45,27.67 82.68,27.67 82.73,27.52 83.29,27.37 83.45,27.47 83.83,27.38 84.09,27.49 84.61,27.30 84.69,27.04 85.13,26.86 85.24,26.75 85.57,26.84 85.70,26.78 85.79,26.60 86.01,26.65 86.70,26.44 87.02,26.56 87.09,26.43 87.29,26.36 87.41,26.42 87.85,26.44 88.00,26.38 88.16,26.72 87.98,27.13 88.15,27.75 88.11,27.87
|
||||||
|
75.97,36.38 75.84,36.65 75.67,36.74 75.42,36.74 75.35,36.91 74.54,37.02
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- A copy is on this computer: a check in an outlined circle. Adwaita 50
|
||||||
|
no longer ships emblem-ok-symbolic. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 8,2.5 A 5.5,5.5 0 1 1 8,13.5 A 5.5,5.5 0 1 1 8,2.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 4.1,8.6 L 5.2,7.5 L 7,9.3 L 10.8,5.4 L 11.9,6.5 L 7,11.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 450 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Show or hide the details pane: a window with its right-hand pane
|
||||||
|
filled in. Some themes draw sidebar-show-right-symbolic as a solid block. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 9.5,5 H 12.5 V 11 H 9.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 564 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- A share link: two open chain ends and the bar that joins them, laid
|
||||||
|
on the diagonal. Written out in plain coordinates: GTK's symbolic icon
|
||||||
|
renderer drops a transformed group. -->
|
||||||
|
<path fill="#222222" d="M 4.82,6.23 L 3.05,8 A 3.5,3.5 0 0 0 8,12.95 L 9.77,11.18 L 8.53,9.94 L 6.76,11.71 A 1.75,1.75 0 0 1 4.29,9.24 L 6.06,7.47 Z"/>
|
||||||
|
<path fill="#222222" d="M 6.23,4.82 L 8,3.05 A 3.5,3.5 0 0 1 12.95,8 L 11.18,9.77 L 9.94,8.53 L 11.71,6.76 A 1.75,1.75 0 0 0 9.24,4.29 L 7.47,6.06 Z"/>
|
||||||
|
<path fill="#222222" d="M 5.26,9.5 L 9.5,5.26 L 10.74,6.5 L 6.5,10.74 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 676 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Show on map: a folded map in three panels. No stock theme has a
|
||||||
|
map-symbolic. -->
|
||||||
|
<path fill="#222222" d="M 1,3.2 L 5,1.5 V 12.8 L 1,14.5 Z M 11,3.2 L 15,1.5 V 12.8 L 11,14.5 Z"/>
|
||||||
|
<path fill="#222222" opacity="0.55" d="M 6,1.5 L 10,3.2 V 14.5 L 6,12.8 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 366 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
|
||||||
|
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
|
||||||
|
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 441 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Shared with me: two people. system-users-symbolic survives only in
|
||||||
|
Adwaita's legacy folder. -->
|
||||||
|
<path fill="#222222" d="M 6,2.5 A 2.5,2.5 0 1 0 6,7.5 A 2.5,2.5 0 1 0 6,2.5 Z M 1.5,14.5 C 1.5,11 3.5,9 6,9 C 8.5,9 10.5,11 10.5,14.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 11,2.4 A 2.1,2.1 0 1 0 11,6.6 A 2.1,2.1 0 1 0 11,2.4 Z M 9.8,8.4 C 10.2,8.3 10.6,8.25 11,8.25 C 13.3,8.25 15,10 15,13.5 H 11.8 C 11.7,11.5 11,9.7 9.8,8.4 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 529 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Shared by me: one node passing to two others. Adwaita 50 no longer
|
||||||
|
ships emblem-shared-symbolic. -->
|
||||||
|
<path fill="#222222" d="M 12,1.25 A 2.25,2.25 0 1 0 12,5.75 A 2.25,2.25 0 1 0 12,1.25 Z M 4,5.75 A 2.25,2.25 0 1 0 4,10.25 A 2.25,2.25 0 1 0 4,5.75 Z"/>
|
||||||
|
<path fill="#222222" d="M 4.37,8.65 L 12.37,4.15 L 11.63,2.85 L 3.63,7.35 Z"/>
|
||||||
|
<path fill="#222222" d="M 12,10.25 A 2.25,2.25 0 1 0 12,14.75 A 2.25,2.25 0 1 0 12,10.25 Z"/>
|
||||||
|
<path fill="#222222" d="M 3.63,8.65 L 11.63,13.15 L 12.37,11.85 L 4.37,7.35 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 621 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Syncing: two arrows chasing each other round a circle. Bundled because
|
||||||
|
Adwaita 50 no longer ships emblem-synchronizing-symbolic. The lower arrow
|
||||||
|
is the upper one turned half a circle, written out in plain coordinates:
|
||||||
|
GTK's symbolic icon renderer drops a transformed group. -->
|
||||||
|
<path fill="#222222" d="M 2,8 A 6,6 0 0 1 12.91,4.56 L 11.48,5.56 A 4.25,4.25 0 0 0 3.75,8 Z"/>
|
||||||
|
<path fill="#222222" d="M 14.23,3.64 L 14.03,7.68 L 10.17,6.48 Z"/>
|
||||||
|
<path fill="#222222" d="M 14,8 A 6,6 0 0 1 3.09,11.44 L 4.52,10.44 A 4.25,4.25 0 0 0 12.25,8 Z"/>
|
||||||
|
<path fill="#222222" d="M 1.77,12.36 L 1.97,8.32 L 5.83,9.52 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 733 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Up to date: a check knocked out of a filled circle. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 4.1,8.6 L 5.2,7.5 L 7,9.3 L 10.8,5.4 L 11.9,6.5 L 7,11.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 305 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Sharing stopped: the share glyph with its last node crossed out. Not
|
||||||
|
a no-entry sign, which reads as an error. -->
|
||||||
|
<path fill="#222222" d="M 12,1.25 A 2.25,2.25 0 1 0 12,5.75 A 2.25,2.25 0 1 0 12,1.25 Z M 4,5.75 A 2.25,2.25 0 1 0 4,10.25 A 2.25,2.25 0 1 0 4,5.75 Z"/>
|
||||||
|
<path fill="#222222" d="M 4.37,8.65 L 12.37,4.15 L 11.63,2.85 L 3.63,7.35 Z"/>
|
||||||
|
<path fill="#222222" d="M 3.63,8.65 L 8.23,11.25 L 8.97,9.95 L 4.37,7.35 Z"/>
|
||||||
|
<path fill="#222222" d="M 9.28,11.02 L 13.48,15.22 L 14.72,13.98 L 10.52,9.78 Z"/>
|
||||||
|
<path fill="#222222" d="M 13.48,9.78 L 9.28,13.98 L 10.52,15.22 L 14.72,11.02 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 706 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
||||||
|
<path fill="#6d4aff" d="M4 12a4 4 0 0 1 4-4h11.2a4 4 0 0 1 2.9 1.2L25 12h15a4 4 0 0 1 4 4v22a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4z"/>
|
||||||
|
<path fill="#8a6eff" d="M4 19a3 3 0 0 1 3-3h34a3 3 0 0 1 3 3v19a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4z"/>
|
||||||
|
<path fill="#ffffff" fill-opacity="0.18" d="M7 16h34a3 3 0 0 1 3 3v1a3 3 0 0 0-3-3H7a3 3 0 0 0-3 3v-1a3 3 0 0 1 3-3z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 442 B |
@@ -10,14 +10,28 @@
|
|||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-filter-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-filter-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-cached-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-synced-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-unshare-symbolic.svg</file>
|
||||||
|
<!-- The Proton theme's folder: a full-colour icon from the icon theme
|
||||||
|
can't be recoloured by CSS. -->
|
||||||
|
<file preprocess="xml-stripblanks">scalable/places/pdfs-proton-folder.svg</file>
|
||||||
<!-- The app icon, from the one file packaging installs, so the in-app brand
|
<!-- The app icon, from the one file packaging installs, so the in-app brand
|
||||||
matches the launcher and resolves even when nothing is installed. -->
|
matches the launcher and resolves even when nothing is installed. -->
|
||||||
<file alias="scalable/apps/io.narl.proton-drive-linux.svg" preprocess="xml-stripblanks">../../../packaging/io.narl.proton-drive-linux.svg</file>
|
<file alias="scalable/apps/io.narl.proton-drive-linux.svg" preprocess="xml-stripblanks">../../../packaging/io.narl.proton-drive-linux.svg</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
<!-- The app stylesheet, loaded once at startup. Colours come from the
|
<!-- The app stylesheet, loaded once at startup. Colours come from the
|
||||||
libadwaita palette so the app follows the system accent, light/dark and
|
libadwaita palette so the app follows the system accent, light/dark and
|
||||||
high contrast; the Proton purple accent is a separate, opt-in provider. -->
|
high contrast. The Proton theme is a separate, opt-in provider. -->
|
||||||
<gresource prefix="/de/nils/protondrivelinux">
|
<gresource prefix="/de/nils/protondrivelinux">
|
||||||
<file>style.css</file>
|
<file>style.css</file>
|
||||||
|
<file>proton-theme.css</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/* Proton theme — the Proton web apps' Snow (light) and Carbon (dark) palettes.
|
||||||
|
*
|
||||||
|
* Loaded by main.rs after libadwaita's own stylesheet, which it re-applies on
|
||||||
|
* top of the user's GTK theme so that theme's hard-coded colours can't show
|
||||||
|
* through. Only libadwaita's colour variables are set here; every widget
|
||||||
|
* derives its look from them. Values from ProtonMail/WebClients,
|
||||||
|
* packages/colors/themes/src/{snow,carbon}/standard-base.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--accent-bg-color: #6d4aff;
|
||||||
|
--accent-fg-color: #ffffff;
|
||||||
|
--destructive-bg-color: #dc3251;
|
||||||
|
--destructive-fg-color: #ffffff;
|
||||||
|
--error-bg-color: #dc3251;
|
||||||
|
--error-fg-color: #ffffff;
|
||||||
|
--success-bg-color: #1ea885;
|
||||||
|
--success-fg-color: #ffffff;
|
||||||
|
--warning-bg-color: #ff9900;
|
||||||
|
--warning-fg-color: rgb(0 0 0 / 80%);
|
||||||
|
|
||||||
|
--window-bg-color: #ffffff;
|
||||||
|
--window-fg-color: #0c0c14;
|
||||||
|
--view-bg-color: #ffffff;
|
||||||
|
--view-fg-color: #0c0c14;
|
||||||
|
--headerbar-bg-color: #ffffff;
|
||||||
|
--headerbar-fg-color: #0c0c14;
|
||||||
|
--headerbar-backdrop-color: #ffffff;
|
||||||
|
--sidebar-bg-color: #f5f4f2;
|
||||||
|
--sidebar-fg-color: #0c0c14;
|
||||||
|
--sidebar-backdrop-color: #f8f8f6;
|
||||||
|
--secondary-sidebar-bg-color: #f8f8f6;
|
||||||
|
--secondary-sidebar-fg-color: #0c0c14;
|
||||||
|
--secondary-sidebar-backdrop-color: #f8f8f6;
|
||||||
|
--card-bg-color: #f5f4f2;
|
||||||
|
--card-fg-color: #0c0c14;
|
||||||
|
--dialog-bg-color: #ffffff;
|
||||||
|
--dialog-fg-color: #0c0c14;
|
||||||
|
--popover-bg-color: #ffffff;
|
||||||
|
--popover-fg-color: #0c0c14;
|
||||||
|
--thumbnail-bg-color: #f5f4f2;
|
||||||
|
--thumbnail-fg-color: #0c0c14;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--destructive-bg-color: #f5385a;
|
||||||
|
--error-bg-color: #f5385a;
|
||||||
|
|
||||||
|
--window-bg-color: #1e1c26;
|
||||||
|
--window-fg-color: #ffffff;
|
||||||
|
--view-bg-color: #1e1c26;
|
||||||
|
--view-fg-color: #ffffff;
|
||||||
|
--headerbar-bg-color: #1e1c26;
|
||||||
|
--headerbar-fg-color: #ffffff;
|
||||||
|
--headerbar-backdrop-color: #1e1c26;
|
||||||
|
--sidebar-bg-color: #16141c;
|
||||||
|
--sidebar-fg-color: #ffffff;
|
||||||
|
--sidebar-backdrop-color: #16141c;
|
||||||
|
--secondary-sidebar-bg-color: #1a1821;
|
||||||
|
--secondary-sidebar-fg-color: #ffffff;
|
||||||
|
--secondary-sidebar-backdrop-color: #1a1821;
|
||||||
|
--card-bg-color: #292733;
|
||||||
|
--card-fg-color: #ffffff;
|
||||||
|
--dialog-bg-color: #292733;
|
||||||
|
--dialog-fg-color: #ffffff;
|
||||||
|
--popover-bg-color: #292733;
|
||||||
|
--popover-fg-color: #ffffff;
|
||||||
|
--thumbnail-bg-color: #292733;
|
||||||
|
--thumbnail-fg-color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
* var(--success-color), ...) so the app follows the system accent, light/dark
|
* var(--success-color), ...) so the app follows the system accent, light/dark
|
||||||
* and high contrast. The one exception is the photo viewer, which always sits
|
* and high contrast. The one exception is the photo viewer, which always sits
|
||||||
* on a near-black backdrop and so uses fixed light-on-dark colours. The Proton
|
* on a near-black backdrop and so uses fixed light-on-dark colours. The Proton
|
||||||
* purple accent is opt-in and lives in its own provider (see main.rs).
|
* theme is opt-in and lives in its own provider (see main.rs and
|
||||||
|
* proton-theme.css).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Brand */
|
/* Brand */
|
||||||
@@ -18,6 +19,22 @@
|
|||||||
.sidebar-status image.error { color: var(--error-color); }
|
.sidebar-status image.error { color: var(--error-color); }
|
||||||
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
||||||
.sidebar-account { padding: 4px 4px 4px 8px; }
|
.sidebar-account { padding: 4px 4px 4px 8px; }
|
||||||
|
/* A count of what waits on a page, at the end of its sidebar row */
|
||||||
|
.sidebar-badge {
|
||||||
|
min-width: 10px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: alpha(currentColor, 0.15);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sync page status card: the state is the headline, the detail explains it */
|
||||||
|
.sync-card { padding: 18px 24px; }
|
||||||
|
.sync-card-icon { padding: 6px; }
|
||||||
|
.sync-card-icon.success { color: var(--success-color); }
|
||||||
|
.sync-card-icon.warning { color: var(--warning-color); }
|
||||||
|
.sync-card-icon.error { color: var(--error-color); }
|
||||||
|
|
||||||
/* File browser */
|
/* File browser */
|
||||||
.file-grid { padding: 6px; }
|
.file-grid { padding: 6px; }
|
||||||
@@ -26,13 +43,11 @@
|
|||||||
.file-tile:selected, .file-tile:hover:selected { background: color-mix(in srgb, var(--accent-bg-color) 20%, transparent); }
|
.file-tile:selected, .file-tile:hover:selected { background: color-mix(in srgb, var(--accent-bg-color) 20%, transparent); }
|
||||||
.file-thumbnail { border-radius: 7px; }
|
.file-thumbnail { border-radius: 7px; }
|
||||||
.file-badge { -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }
|
.file-badge { -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }
|
||||||
.badge-pinned { color: var(--yellow-3); }
|
.badge-pinned { color: var(--success-color); }
|
||||||
.badge-cached { color: var(--success-color); }
|
.badge-cached { color: var(--success-color); }
|
||||||
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||||
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
||||||
scale.browser-status-meter trough, progressbar.browser-status-meter trough { min-width: 104px; }
|
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
||||||
scale.browser-status-meter trough { min-height: 6px; }
|
|
||||||
progressbar.browser-status-meter trough, progressbar.browser-status-meter progress { min-height: 6px; }
|
|
||||||
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
||||||
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
||||||
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
|
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
|
||||||
@@ -51,19 +66,19 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
|
|||||||
.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; }
|
||||||
.photo-scrubber { min-width: 20px; padding: 0 2px; }
|
.photo-scrubber { background: linear-gradient(to left, alpha(var(--window-bg-color), 0.92) 40%, alpha(var(--window-bg-color), 0)); }
|
||||||
.photo-scrubber value { font-weight: 600; background-color: var(--popover-bg-color); border-radius: 6px; padding: 2px 8px; }
|
.photo-scrubber-knob { background-color: var(--accent-bg-color); border-radius: 2px; }
|
||||||
|
.photo-scrubber-bubble { font-weight: 600; background-color: var(--popover-bg-color); color: var(--popover-fg-color); border-radius: 999px; padding: 4px 12px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); }
|
||||||
.viewer-spinner { color: white; }
|
.viewer-spinner { color: white; }
|
||||||
.viewer-status { color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }
|
.viewer-status { color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }
|
||||||
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
|
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
|
||||||
|
|
||||||
/* Photos grid */
|
/* Photos grid */
|
||||||
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 2px 2px 6px 2px; }
|
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 2px 2px 6px 2px; }
|
||||||
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }
|
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; }
|
||||||
.photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }
|
|
||||||
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
|
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
|
||||||
.photo-thumb { transition: transform 220ms ease; }
|
.photo-thumb { transition: transform 220ms ease; }
|
||||||
.photo-tile:hover .photo-thumb { transform: scale(1.06); }
|
.photo-tile:hover .photo-thumb { transform: scale(1.02); }
|
||||||
.photo-check { color: white; background: rgba(0, 0, 0, 0.5); border-radius: 999px; padding: 3px; }
|
.photo-check { color: white; background: rgba(0, 0, 0, 0.5); border-radius: 999px; padding: 3px; }
|
||||||
.photo-check-on { background: var(--accent-bg-color); color: var(--accent-fg-color); }
|
.photo-check-on { background: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||||
.photo-tile-selected { outline: 3px solid var(--accent-bg-color); outline-offset: -3px; }
|
.photo-tile-selected { outline: 3px solid var(--accent-bg-color); outline-offset: -3px; }
|
||||||
@@ -82,14 +97,26 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
|
|||||||
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
|
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
|
||||||
.album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }
|
.album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }
|
||||||
.album-card:hover .photo-thumb { transform: scale(1.05); }
|
.album-card:hover .photo-thumb { transform: scale(1.05); }
|
||||||
.view-switch button { padding-left: 18px; padding-right: 18px; }
|
.view-switch button, .view-switch toggle { padding-left: 18px; padding-right: 18px; }
|
||||||
|
|
||||||
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
|
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
|
||||||
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
|
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
|
||||||
|
.role-pill.warning-pill { color: var(--warning-color); background-color: color-mix(in srgb, var(--warning-bg-color) 20%, transparent); }
|
||||||
|
|
||||||
|
/* Activity: the feed sits on the window background as one card per row, under
|
||||||
|
* day headings, like a boxed list with its rows set apart. */
|
||||||
|
listview.activity-feed { background: none; color: inherit; padding: 0 12px 18px 12px; }
|
||||||
|
listview.activity-feed > row { padding: 0; margin-bottom: 6px; background: none; }
|
||||||
|
listview.activity-feed > header { padding: 18px 6px 6px 6px; background: none; }
|
||||||
|
listview.activity-feed row.card { border-radius: 12px; }
|
||||||
|
|
||||||
/* Photos: how many timeline filters are on, as a small badge on the Filters button. */
|
/* Photos: how many timeline filters are on, as a small badge on the Filters button. */
|
||||||
.filter-count { min-width: 18px; padding: 0 5px; border-radius: 999px; font-size: 0.8em; font-weight: 700; color: var(--accent-fg-color); background-color: var(--accent-bg-color); }
|
.filter-count { min-width: 18px; padding: 0 5px; border-radius: 999px; font-size: 0.8em; font-weight: 700; color: var(--accent-fg-color); background-color: var(--accent-bg-color); }
|
||||||
/* The Places map: water a faint wash of the text colour, bubbles (drawn in
|
/* The Places map: sea and land come in the map's own colours; markers (drawn
|
||||||
* the area's own colour) in the accent. */
|
* in the area's own colour) in the accent. */
|
||||||
.places-map-frame { border-radius: 12px; background: color-mix(in srgb, currentColor 5%, transparent); }
|
.places-map-frame { border-radius: 12px; }
|
||||||
.places-map { color: var(--accent-bg-color); }
|
.places-map { color: var(--accent-bg-color); }
|
||||||
|
/* Credit on the Places street map, in the corner as on every web map. */
|
||||||
|
.places-spot-photo { padding: 0; }
|
||||||
|
.places-spot-photo picture { border-radius: 6px; overflow: hidden; }
|
||||||
|
.places-map-credit { padding: 2px 8px; border-top-left-radius: 8px; color: var(--window-fg-color); background: color-mix(in srgb, var(--window-bg-color) 80%, transparent); }
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! Dates and times as the app shows them, so every page words a moment the
|
||||||
|
//! same way.
|
||||||
|
//!
|
||||||
|
//! - [`relative`] for "when did this happen" in lists: "Just now", "5 min
|
||||||
|
//! ago", "Yesterday, 14:05", "Tuesday", then a date.
|
||||||
|
//! - [`short_date`] for date columns: "Sep 23", with the year only when it is
|
||||||
|
//! not this year.
|
||||||
|
//! - [`full`] for tooltips: the weekday, date, year and time.
|
||||||
|
//!
|
||||||
|
//! The formats are `strftime` patterns that go through gettext, so they follow
|
||||||
|
//! the language picked in Preferences, not only the system locale.
|
||||||
|
|
||||||
|
use gtk4::glib;
|
||||||
|
|
||||||
|
use crate::{gettext, gettext_f, ngettext_f};
|
||||||
|
|
||||||
|
/// Seconds in a day, for the "within the last week" checks.
|
||||||
|
const DAY: i64 = 86_400;
|
||||||
|
|
||||||
|
/// A past moment in epoch seconds, relative to now. Empty when the time can't
|
||||||
|
/// be read.
|
||||||
|
pub(crate) fn relative(secs: i64) -> String {
|
||||||
|
match (
|
||||||
|
glib::DateTime::from_unix_local(secs),
|
||||||
|
glib::DateTime::now_local(),
|
||||||
|
) {
|
||||||
|
(Ok(at), Ok(now)) => relative_to(&at, &now),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`relative`] for a fixed `now`, so it can be tested.
|
||||||
|
pub(crate) fn relative_to(at: &glib::DateTime, now: &glib::DateTime) -> String {
|
||||||
|
let ago = now.to_unix() - at.to_unix();
|
||||||
|
// A clock a little ahead of ours still reads as "just now".
|
||||||
|
if (-60..60).contains(&ago) {
|
||||||
|
return gettext("Just now");
|
||||||
|
}
|
||||||
|
if (60..3600).contains(&ago) {
|
||||||
|
let minutes = (ago / 60) as u64;
|
||||||
|
return ngettext_f("{n} min ago", "{n} min ago", minutes, &[]);
|
||||||
|
}
|
||||||
|
// Translators: strftime format for a time of day, such as "14:05".
|
||||||
|
let time = || format(at, &gettext("%H:%M"));
|
||||||
|
if at.ymd() == now.ymd() {
|
||||||
|
return time();
|
||||||
|
}
|
||||||
|
if now.add_days(-1).is_ok_and(|y| y.ymd() == at.ymd()) {
|
||||||
|
// Translators: {time} is a time of day, such as "14:05".
|
||||||
|
return gettext_f("Yesterday, {time}", &[("time", &time())]);
|
||||||
|
}
|
||||||
|
if (0..6 * DAY).contains(&ago) {
|
||||||
|
return format(at, "%A");
|
||||||
|
}
|
||||||
|
short_date_to(at, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A date in epoch seconds, without the time. Empty when the time can't be
|
||||||
|
/// read.
|
||||||
|
pub(crate) fn short_date(secs: i64) -> String {
|
||||||
|
match (
|
||||||
|
glib::DateTime::from_unix_local(secs),
|
||||||
|
glib::DateTime::now_local(),
|
||||||
|
) {
|
||||||
|
(Ok(at), Ok(now)) => short_date_to(&at, &now),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`short_date`] for a fixed `now`, so it can be tested.
|
||||||
|
pub(crate) fn short_date_to(at: &glib::DateTime, now: &glib::DateTime) -> String {
|
||||||
|
let pattern = if at.year() == now.year() {
|
||||||
|
// Translators: strftime format for a date in this year, such as "Sep 23".
|
||||||
|
gettext("%b %-d")
|
||||||
|
} else {
|
||||||
|
// Translators: strftime format for a date in another year, such as "Sep 23, 2025".
|
||||||
|
gettext("%b %-d, %Y")
|
||||||
|
};
|
||||||
|
format(at, &pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole moment in epoch seconds, for a tooltip. Empty when the time
|
||||||
|
/// can't be read.
|
||||||
|
pub(crate) fn full(secs: i64) -> String {
|
||||||
|
glib::DateTime::from_unix_local(secs)
|
||||||
|
.map(|at| {
|
||||||
|
// Translators: strftime format for a full date and time, such as
|
||||||
|
// "Wednesday, September 23, 2026, 14:05".
|
||||||
|
format(&at, &gettext("%A, %B %-d, %Y, %H:%M"))
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format(at: &glib::DateTime, pattern: &str) -> String {
|
||||||
|
at.format(pattern)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn at(y: i32, m: i32, d: i32, h: i32, min: i32) -> glib::DateTime {
|
||||||
|
glib::DateTime::from_local(y, m, d, h, min, 0.0).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_times_get_coarser_with_age() {
|
||||||
|
let now = at(2026, 9, 23, 15, 0);
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 23, 14, 59), &now), "1 min ago");
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 23, 14, 55), &now), "5 min ago");
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 23, 9, 5), &now), "09:05");
|
||||||
|
assert_eq!(
|
||||||
|
relative_to(&at(2026, 9, 22, 14, 5), &now),
|
||||||
|
"Yesterday, 14:05"
|
||||||
|
);
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 20, 10, 0), &now), "Sunday");
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 1, 10, 0), &now), "Sep 1");
|
||||||
|
assert_eq!(relative_to(&at(2024, 3, 5, 10, 0), &now), "Mar 5, 2024");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_moment_ago_or_slightly_ahead_is_just_now() {
|
||||||
|
let now = at(2026, 9, 23, 15, 0);
|
||||||
|
assert_eq!(relative_to(&now, &now), "Just now");
|
||||||
|
assert_eq!(
|
||||||
|
relative_to(&at(2026, 9, 23, 15, 0), &now.add_seconds(30.0).unwrap()),
|
||||||
|
"Just now"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_dates_show_the_year_only_when_it_differs() {
|
||||||
|
let now = at(2026, 9, 23, 15, 0);
|
||||||
|
assert_eq!(short_date_to(&at(2026, 1, 2, 8, 0), &now), "Jan 2");
|
||||||
|
assert_eq!(short_date_to(&at(2025, 12, 31, 8, 0), &now), "Dec 31, 2025");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//! The window's side of the daemon's event feed ([`Request::Subscribe`]).
|
||||||
|
//!
|
||||||
|
//! The window used to ask the daemon for its status every two seconds and let
|
||||||
|
//! pages re-read themselves on timers. Now one thread follows the feed and hands
|
||||||
|
//! each event to the main loop: a status or transfers snapshot is painted as it
|
||||||
|
//! comes, and a change reloads the page on screen if the page shows it. A
|
||||||
|
//! window nobody can see keeps its changes for later and catches up when it is
|
||||||
|
//! focused again.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use pdfs_core::control::{Event, Feed, follow};
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// How long the conflict list waits after a change before it is read again.
|
||||||
|
/// Listing walks every node the daemon knows, and a sync pass can report
|
||||||
|
/// changes many times a second.
|
||||||
|
const CONFLICTS_DEBOUNCE: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// How long the import's state waits after its progress moved before it is
|
||||||
|
/// read again.
|
||||||
|
const TAKEOUT_DEBOUNCE: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// How old the invitations may be when the window is focused. Another person
|
||||||
|
/// inviting this account sends the daemon no event, so focus is when they are
|
||||||
|
/// looked for.
|
||||||
|
const INVITATIONS_ON_FOCUS: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// What the feed keeps between events.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FeedState {
|
||||||
|
/// What changed while the window was out of sight.
|
||||||
|
deferred: RefCell<BTreeSet<Topic>>,
|
||||||
|
/// Whether a conflict read is already waiting out [`CONFLICTS_DEBOUNCE`].
|
||||||
|
conflicts_pending: Rc<Cell<bool>>,
|
||||||
|
/// Whether an import read is already waiting out [`TAKEOUT_DEBOUNCE`].
|
||||||
|
takeout_pending: Rc<Cell<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follow the daemon for as long as `window` is open.
|
||||||
|
pub(crate) fn follow_daemon(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||||
|
let (tx, rx) = async_channel::bounded(64);
|
||||||
|
let socket = ui.dirs.control_socket();
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("pdfs-feed".into())
|
||||||
|
.spawn(move || follow(&socket, |feed| tx.send_blocking(feed).is_ok()));
|
||||||
|
if let Err(e) = spawned {
|
||||||
|
tracing::error!("cannot start the event feed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = Rc::new(FeedState::default());
|
||||||
|
let ui_feed = ui.clone();
|
||||||
|
let state_feed = state.clone();
|
||||||
|
let window_feed = window.downgrade();
|
||||||
|
let rx_feed = rx.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
while let Ok(feed) = rx_feed.recv().await {
|
||||||
|
let hidden = window_feed.upgrade().is_none_or(|w| window_hidden(&w));
|
||||||
|
on_feed(&ui_feed, &state_feed, feed, hidden);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch up as soon as the user comes back to the window.
|
||||||
|
let ui_focus = ui.clone();
|
||||||
|
window.connect_is_active_notify(move |window| {
|
||||||
|
if !window.is_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let topics: Vec<Topic> = state.deferred.take().into_iter().collect();
|
||||||
|
if !topics.is_empty() {
|
||||||
|
follow_changes(&ui_focus, &state, &topics);
|
||||||
|
}
|
||||||
|
refresh_quota(&ui_focus);
|
||||||
|
refresh_invitations_badge(&ui_focus, INVITATIONS_ON_FOCUS);
|
||||||
|
});
|
||||||
|
// Closing the channel ends the loop above, and the thread with the next
|
||||||
|
// event it tries to hand over.
|
||||||
|
window.connect_close_request(move |_| {
|
||||||
|
rx.close();
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_feed(ui: &Rc<Ui>, state: &Rc<FeedState>, feed: Feed, hidden: bool) {
|
||||||
|
match feed {
|
||||||
|
// The daemon follows up with its whole state, so there is nothing to
|
||||||
|
// ask for here.
|
||||||
|
Feed::Connected | Feed::Event(Event::Heartbeat) => {}
|
||||||
|
Feed::Event(Event::Status(status)) => {
|
||||||
|
paint_status(ui, *status);
|
||||||
|
// Uploads are what move the quota, and they move the status too.
|
||||||
|
refresh_quota(ui);
|
||||||
|
}
|
||||||
|
// Painted even out of sight: the end of a batch is what sends the
|
||||||
|
// "Sync complete" notification.
|
||||||
|
Feed::Event(Event::Transfers { items, jobs }) => {
|
||||||
|
repaint_transfers(ui, &items, &jobs);
|
||||||
|
follow_takeout(ui, state, &jobs);
|
||||||
|
}
|
||||||
|
Feed::Event(Event::Changed { topics }) if hidden => {
|
||||||
|
state.deferred.borrow_mut().extend(topics);
|
||||||
|
}
|
||||||
|
Feed::Event(Event::Changed { topics }) => follow_changes(ui, state, &topics),
|
||||||
|
// Ask once, which paints "not connected" or "not responding", and clear
|
||||||
|
// progress that can no longer move.
|
||||||
|
Feed::Disconnected => {
|
||||||
|
refresh_status(ui);
|
||||||
|
repaint_transfers(ui, &[], &[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reload what `topics` says changed: the sidebar badges wherever the user
|
||||||
|
/// is, and the page on screen if it shows one of them.
|
||||||
|
fn follow_changes(ui: &Rc<Ui>, state: &FeedState, topics: &[Topic]) {
|
||||||
|
let changed = |topic| topics.contains(&topic);
|
||||||
|
if changed(Topic::Conflicts) {
|
||||||
|
let ui = ui.clone();
|
||||||
|
debounce(&state.conflicts_pending, CONFLICTS_DEBOUNCE, move || {
|
||||||
|
refresh_conflicts(&ui);
|
||||||
|
if activity_visible(&ui) {
|
||||||
|
refresh_activity_conflicts(&ui, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if changed(Topic::Shares) {
|
||||||
|
refresh_invitations_badge(ui, Duration::ZERO);
|
||||||
|
}
|
||||||
|
match ui.stack.visible_child_name().as_deref() {
|
||||||
|
Some("browser") if changed(Topic::Files) => load_browser(ui),
|
||||||
|
// The timeline is left alone: reloading it would throw away the place
|
||||||
|
// the user scrolled to. F5 reloads it on request.
|
||||||
|
Some("gallery")
|
||||||
|
if changed(Topic::Photos)
|
||||||
|
&& ui.gallery.content.visible_child_name().as_deref() == Some("albums") =>
|
||||||
|
{
|
||||||
|
load_albums(ui)
|
||||||
|
}
|
||||||
|
Some("trash") if changed(Topic::Trash) => load_trash(ui),
|
||||||
|
Some("shared") if changed(Topic::Shares) => load_shared(ui),
|
||||||
|
Some("sharedbyme") if changed(Topic::Shares) || changed(Topic::Files) => {
|
||||||
|
load_shared_by_me(ui)
|
||||||
|
}
|
||||||
|
Some("devices") if changed(Topic::Devices) => load_devices(ui),
|
||||||
|
Some("locations") => follow_sync_view(ui, topics),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the import's state again while its progress moves, and once more after
|
||||||
|
/// it stops, which is how a finished import is noticed. Nothing is read while
|
||||||
|
/// no import runs and the page is elsewhere.
|
||||||
|
fn follow_takeout(ui: &Rc<Ui>, state: &FeedState, jobs: &[JobItem]) {
|
||||||
|
let importing = jobs.iter().any(|job| job.title == IMPORT_JOB);
|
||||||
|
if importing
|
||||||
|
|| ui.takeout.running.get()
|
||||||
|
|| ui.stack.visible_child_name().as_deref() == Some("takeout")
|
||||||
|
{
|
||||||
|
let ui = ui.clone();
|
||||||
|
debounce(&state.takeout_pending, TAKEOUT_DEBOUNCE, move || {
|
||||||
|
refresh_takeout(&ui)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `f` once `delay` has passed, unless a run is already waiting. The run
|
||||||
|
/// sees everything that happened up to it, so nothing is missed by skipping.
|
||||||
|
fn debounce(pending: &Rc<Cell<bool>>, delay: Duration, f: impl FnOnce() + 'static) {
|
||||||
|
if pending.replace(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pending = pending.clone();
|
||||||
|
glib::timeout_add_local_once(delay, move || {
|
||||||
|
pending.set(false);
|
||||||
|
f();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
//! Activity entries as the feed words them.
|
||||||
|
//!
|
||||||
|
//! The daemon logs each entry in plain English for its own log and the CLI:
|
||||||
|
//! a target ("report.pdf", "3 item(s)", "sync conflict in Docs") and a detail
|
||||||
|
//! ("to /Documents", "was notes.txt", "2 uploaded, 1 deleted"). This module
|
||||||
|
//! reads the shapes the daemon writes and says them again in the user's
|
||||||
|
//! language. A shape it does not know, which is mostly an error message,
|
||||||
|
//! shows as the daemon wrote it.
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// What a feed row says about one entry.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) struct Described {
|
||||||
|
pub(crate) title: String,
|
||||||
|
/// Subtitle parts, shown joined by " · ".
|
||||||
|
pub(crate) details: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title and subtitle parts for `entry`.
|
||||||
|
pub(crate) fn describe(entry: &ActivityEntry) -> Described {
|
||||||
|
let title = entry_title(entry);
|
||||||
|
let mut details = Vec::new();
|
||||||
|
if entry.kind == ActivityKind::Conflict
|
||||||
|
&& let Some(stamp) = conflict_stamp(&entry.target)
|
||||||
|
{
|
||||||
|
let when = dates::relative(stamp);
|
||||||
|
if !when.is_empty() {
|
||||||
|
// Translators: {time} is when a conflicting copy was made, such as
|
||||||
|
// "Sep 21" or "5 min ago".
|
||||||
|
details.push(gettext_f("Made {time}", &[("time", &when)]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(detail) = detail_label(entry) {
|
||||||
|
details.insert(0, detail);
|
||||||
|
}
|
||||||
|
Described { title, details }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row title: what happened to what, one sentence per case so a
|
||||||
|
/// translator can place the name where their language needs it.
|
||||||
|
fn entry_title(entry: &ActivityEntry) -> String {
|
||||||
|
use ActivityKind::*;
|
||||||
|
let target = entry.target.as_str();
|
||||||
|
let ok = entry.ok;
|
||||||
|
|
||||||
|
// Entries whose target is a count or a fixed phrase rather than a name.
|
||||||
|
match (entry.kind, target) {
|
||||||
|
(Upload, "Google Photos import") => {
|
||||||
|
return if ok {
|
||||||
|
gettext("Imported from Google Photos")
|
||||||
|
} else {
|
||||||
|
gettext("Couldn't import from Google Photos")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
(Upload, "bulk upload") => return gettext("Couldn't upload files"),
|
||||||
|
(Upload, "add sync folder") => return gettext("Couldn't add a sync folder"),
|
||||||
|
(Download, "restore folders") => return gettext("Couldn't restore folders"),
|
||||||
|
(Unshare, "shared item") => return gettext("Left a shared item"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if matches!(entry.kind, Restore | DeleteForever | EmptyTrash)
|
||||||
|
&& let Some(n) = leading_count(target, &["item(s)", "item", "items"])
|
||||||
|
{
|
||||||
|
return match entry.kind {
|
||||||
|
Restore => ngettext_f("Restored {n} item", "Restored {n} items", n, &[]),
|
||||||
|
DeleteForever => ngettext_f(
|
||||||
|
"Deleted {n} item forever",
|
||||||
|
"Deleted {n} items forever",
|
||||||
|
n,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
_ => ngettext_f(
|
||||||
|
"Emptied the Trash ({n} item)",
|
||||||
|
"Emptied the Trash ({n} items)",
|
||||||
|
n,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if entry.kind == Download
|
||||||
|
&& let Some(rest) = target.strip_prefix("restored ")
|
||||||
|
&& let Some(n) = leading_count(rest, &["folder(s)"])
|
||||||
|
{
|
||||||
|
return ngettext_f("Restored {n} folder", "Restored {n} folders", n, &[]);
|
||||||
|
}
|
||||||
|
if entry.kind == Sync
|
||||||
|
&& let Some(title) = sync_title(target)
|
||||||
|
{
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(name) = target_name(target) else {
|
||||||
|
return match entry.kind {
|
||||||
|
Share => gettext("Shared an item"),
|
||||||
|
PublicLink => gettext("Created a link to an item"),
|
||||||
|
Unshare if entry.detail == "link removed" => gettext("Removed a link to an item"),
|
||||||
|
Unshare => gettext("Stopped sharing an item"),
|
||||||
|
_ => activity_title(entry.kind, ok, target),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if entry.kind == Unshare && entry.detail == "link removed" {
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
return gettext_f("Removed the link to {name}", &[("name", &name)]);
|
||||||
|
}
|
||||||
|
if entry.kind == Conflict
|
||||||
|
&& let Some(base) = conflict_base(&name)
|
||||||
|
{
|
||||||
|
// Translators: {name} is the file the conflicting copy was made from.
|
||||||
|
return gettext_f("Conflicting copy of {name}", &[("name", &base)]);
|
||||||
|
}
|
||||||
|
activity_title(entry.kind, ok, &name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title of a sync pass or mode switch, whose target is a phrase.
|
||||||
|
fn sync_title(target: &str) -> Option<String> {
|
||||||
|
if let Some(name) = target.strip_prefix("sync conflict in ") {
|
||||||
|
// Translators: {name} is a synced folder's name.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Conflicting changes in {name}",
|
||||||
|
&[("name", name)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(name) = target.strip_prefix("sync incomplete for ") {
|
||||||
|
// Translators: {name} is a synced folder's name.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Some items in {name} didn't sync",
|
||||||
|
&[("name", name)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(name) = target.strip_prefix("sync failed for ") {
|
||||||
|
// Translators: {name} is a synced folder's name.
|
||||||
|
return Some(gettext_f("Couldn't sync {name}", &[("name", name)]));
|
||||||
|
}
|
||||||
|
if let Some(name) = target.strip_prefix("couldn't switch ") {
|
||||||
|
// Translators: {name} is a synced folder's name.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Couldn't change how {name} syncs",
|
||||||
|
&[("name", name)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(path) = target.strip_suffix(" is now on-demand") {
|
||||||
|
let name = file_name(path);
|
||||||
|
// Translators: {name} is a folder's name.
|
||||||
|
return Some(gettext_f("{name} is now online only", &[("name", name)]));
|
||||||
|
}
|
||||||
|
if let Some(path) = target.strip_suffix(" is mirroring again; downloading") {
|
||||||
|
let name = file_name(path);
|
||||||
|
// Translators: {name} is a folder's name.
|
||||||
|
return Some(gettext_f("{name} is synced again", &[("name", name)]));
|
||||||
|
}
|
||||||
|
if target.starts_with("already ") {
|
||||||
|
return Some(gettext("Sync mode was already set"));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row title for an activity on a named item.
|
||||||
|
pub(crate) fn activity_title(kind: ActivityKind, ok: bool, name: &str) -> String {
|
||||||
|
let args = [("name", name)];
|
||||||
|
match (kind, ok) {
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Upload, true) => gettext_f("Uploaded {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Upload, false) => gettext_f("Couldn't upload {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Download, true) => gettext_f("Downloaded {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Download, false) => gettext_f("Couldn't download {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Sync, true) => gettext_f("Synced {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Sync, false) => gettext_f("Couldn't sync {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Rename, true) => gettext_f("Renamed {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Rename, false) => gettext_f("Couldn't rename {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Move, true) => gettext_f("Moved {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Move, false) => gettext_f("Couldn't move {name}", &args),
|
||||||
|
// Translators: {name} is a folder name.
|
||||||
|
(ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args),
|
||||||
|
// Translators: {name} is a folder name.
|
||||||
|
(ActivityKind::CreateFolder, false) => gettext_f("Couldn't create folder {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Trash, true) => gettext_f("Moved {name} to the Trash", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Trash, false) => gettext_f("Couldn't move {name} to the Trash", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Restore, true) => gettext_f("Restored {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Restore, false) => gettext_f("Couldn't restore {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::DeleteForever, true) => gettext_f("Deleted {name} forever", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::DeleteForever, false) => gettext_f("Couldn't delete {name}", &args),
|
||||||
|
(ActivityKind::EmptyTrash, true) => gettext("Emptied the Trash"),
|
||||||
|
(ActivityKind::EmptyTrash, false) => gettext("Couldn't empty the Trash"),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Share, true) => gettext_f("Shared {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Share, false) => gettext_f("Couldn't share {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::PublicLink, true) => gettext_f("Created a link to {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::PublicLink, false) => gettext_f("Couldn't create a link to {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Unshare, true) => gettext_f("Stopped sharing {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Unshare, false) => gettext_f("Couldn't stop sharing {name}", &args),
|
||||||
|
// Translators: {name} is the name of the conflicting copy.
|
||||||
|
(ActivityKind::Conflict, _) => gettext_f("Conflicting copy {name}", &args),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The subtitle's first part: the daemon's detail in the user's language, or
|
||||||
|
/// `None` when it only repeats the title.
|
||||||
|
fn detail_label(entry: &ActivityEntry) -> Option<String> {
|
||||||
|
let detail = entry.detail.trim();
|
||||||
|
let name = |s: &str| file_name(s).to_string();
|
||||||
|
match detail {
|
||||||
|
""
|
||||||
|
| "uploaded"
|
||||||
|
| "created"
|
||||||
|
| "trashed"
|
||||||
|
| "trashed from the mount"
|
||||||
|
| "stopped sharing"
|
||||||
|
| "left"
|
||||||
|
| "link created"
|
||||||
|
| "link removed"
|
||||||
|
| "access removed"
|
||||||
|
| "removed" => {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
"removed on Drive" => return Some(gettext("Removed on Proton Drive")),
|
||||||
|
"removed locally" => return Some(gettext("Removed on this computer")),
|
||||||
|
"replaced by a rename from the mount" => {
|
||||||
|
return Some(gettext("Replaced by a renamed file"));
|
||||||
|
}
|
||||||
|
"destination folder no longer exists; the file was left in place" => {
|
||||||
|
return Some(gettext(
|
||||||
|
"The destination folder no longer exists, so the file stayed where it was",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if entry.kind == ActivityKind::Upload
|
||||||
|
&& entry.target == "Google Photos import"
|
||||||
|
&& entry.ok
|
||||||
|
&& let Some(summary) = import_summary(detail)
|
||||||
|
{
|
||||||
|
return Some(summary);
|
||||||
|
}
|
||||||
|
if entry.kind == ActivityKind::Sync
|
||||||
|
&& let Some(summary) = sync_summary(detail)
|
||||||
|
{
|
||||||
|
return Some(summary);
|
||||||
|
}
|
||||||
|
if let Some(dest) = detail.strip_prefix("to ") {
|
||||||
|
// Translators: {name} is the folder an item was moved into.
|
||||||
|
return Some(gettext_f("To {name}", &[("name", &folder_name(dest))]));
|
||||||
|
}
|
||||||
|
if let Some(old) = detail.strip_prefix("was ") {
|
||||||
|
// Translators: {name} is an item's previous name.
|
||||||
|
return Some(gettext_f("Previously {name}", &[("name", &name(old))]));
|
||||||
|
}
|
||||||
|
if detail.starts_with("restored version ") {
|
||||||
|
return Some(gettext("Restored an earlier version"));
|
||||||
|
}
|
||||||
|
if detail.starts_with("deleted version ") {
|
||||||
|
return Some(gettext("Deleted an earlier version"));
|
||||||
|
}
|
||||||
|
if let Some(rest) = detail.strip_prefix("saved version ")
|
||||||
|
&& let Some((_, path)) = rest.split_once(" to ")
|
||||||
|
{
|
||||||
|
// Translators: {path} is where an earlier version was saved.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Saved an earlier version to {path}",
|
||||||
|
&[("path", &tilde_path(path))],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(copy) = detail.strip_prefix("kept both; the copy is now ") {
|
||||||
|
// Translators: {name} is the new name of the conflicting copy.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Kept both; the copy is now {name}",
|
||||||
|
&[("name", copy)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(base) = detail
|
||||||
|
.strip_prefix("kept ")
|
||||||
|
.and_then(|rest| rest.strip_suffix("; the copy is in Trash"))
|
||||||
|
{
|
||||||
|
// Translators: {name} is the file that was kept.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Kept {name}; the copy is in the Trash",
|
||||||
|
&[("name", base)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if detail.starts_with("kept the conflict copy ") {
|
||||||
|
return Some(gettext("Kept the conflicting copy"));
|
||||||
|
}
|
||||||
|
if let Some(base) = detail.strip_prefix("kept ") {
|
||||||
|
// Translators: {name} is the file that was kept.
|
||||||
|
return Some(gettext_f("Kept {name}", &[("name", base)]));
|
||||||
|
}
|
||||||
|
if let Some(base) = detail.strip_prefix("auto-removed duplicate of ") {
|
||||||
|
// Translators: {name} is the file the removed copy was identical to.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Same as {name}, so it was removed",
|
||||||
|
&[("name", base)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(alt) = detail.strip_prefix("destination already had that name; moved as ") {
|
||||||
|
// Translators: {name} is the name the item was given instead.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"The name was taken, so it was moved as {name}",
|
||||||
|
&[("name", alt)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(alt) = detail.strip_prefix("name was taken remotely; renamed to ") {
|
||||||
|
// Translators: {name} is the name the item was given instead.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"The name was taken on Proton Drive, so it was renamed to {name}",
|
||||||
|
&[("name", alt)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(alt) = detail.strip_prefix("name was taken remotely; created as ") {
|
||||||
|
// Translators: {name} is the name the item was given instead.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"The name was taken on Proton Drive, so it was created as {name}",
|
||||||
|
&[("name", alt)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(alt) =
|
||||||
|
detail.strip_prefix("its folder was trashed remotely; created in the root as ")
|
||||||
|
{
|
||||||
|
// Translators: {name} is the name the item was given.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Its folder was moved to the Trash on Proton Drive, so it was created at the top as {name}",
|
||||||
|
&[("name", alt)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(base) = detail.strip_prefix("differs from ") {
|
||||||
|
// Translators: {name} is the file the conflicting copy was made from.
|
||||||
|
return Some(gettext_f("Differs from {name}", &[("name", base)]));
|
||||||
|
}
|
||||||
|
if let Some(base) = detail
|
||||||
|
.strip_prefix("no original ")
|
||||||
|
.and_then(|rest| rest.strip_suffix(" to reconcile against"))
|
||||||
|
{
|
||||||
|
// Translators: {name} is the file the conflicting copy was made from.
|
||||||
|
return Some(gettext_f("{name} no longer exists", &[("name", base)]));
|
||||||
|
}
|
||||||
|
if let Some(base) = detail
|
||||||
|
.strip_prefix("identical to ")
|
||||||
|
.and_then(|rest| rest.split_once(';').map(|(base, _)| base))
|
||||||
|
{
|
||||||
|
// Translators: {name} is the file the conflicting copy was made from.
|
||||||
|
return Some(gettext_f("Same as {name}", &[("name", base)]));
|
||||||
|
}
|
||||||
|
if let Some((n, role)) = detail
|
||||||
|
.split_once(" recipient(s) as ")
|
||||||
|
.and_then(|(n, role)| Some((n.parse::<u64>().ok()?, role)))
|
||||||
|
{
|
||||||
|
return Some(match role {
|
||||||
|
"viewer" => ngettext_f("{n} person can view", "{n} people can view", n, &[]),
|
||||||
|
"editor" => ngettext_f("{n} person can edit", "{n} people can edit", n, &[]),
|
||||||
|
_ => ngettext_f("{n} person invited", "{n} people invited", n, &[]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(n) = leading_count(detail, &["file(s) kept as conflict copies"]) {
|
||||||
|
return Some(ngettext_f(
|
||||||
|
"{n} file kept as a conflicting copy",
|
||||||
|
"{n} files kept as conflicting copies",
|
||||||
|
n,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(n) = leading_count(detail, &["item(s) failed; will retry"]) {
|
||||||
|
return Some(ngettext_f(
|
||||||
|
"{n} item failed and will be tried again",
|
||||||
|
"{n} items failed and will be tried again",
|
||||||
|
n,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(capitalize(detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sync pass summary such as "2 uploaded, 1 folder(s) created", as a
|
||||||
|
/// list of counts. `None` when a part is not one the daemon writes.
|
||||||
|
fn sync_summary(detail: &str) -> Option<String> {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for part in detail.split(", ") {
|
||||||
|
let (n, label) = part.split_once(' ')?;
|
||||||
|
let n = n.parse::<u64>().ok()?;
|
||||||
|
parts.push(match label {
|
||||||
|
// Translators: part of a list summing up a sync pass.
|
||||||
|
"uploaded" => ngettext_f("{n} uploaded", "{n} uploaded", n, &[]),
|
||||||
|
// Translators: part of a list summing up a sync pass.
|
||||||
|
"downloaded" => ngettext_f("{n} downloaded", "{n} downloaded", n, &[]),
|
||||||
|
// Translators: part of a list summing up a sync pass.
|
||||||
|
"folder(s) created" => ngettext_f("{n} folder created", "{n} folders created", n, &[]),
|
||||||
|
// Translators: part of a list summing up a sync pass.
|
||||||
|
"deleted" => ngettext_f("{n} deleted", "{n} deleted", n, &[]),
|
||||||
|
// Translators: part of a list summing up a sync pass.
|
||||||
|
"conflicted" => ngettext_f("{n} in conflict", "{n} in conflict", n, &[]),
|
||||||
|
// Translators: part of a list summing up a sync pass; the files are
|
||||||
|
// still open in another app.
|
||||||
|
"deferred (open for write)" => ngettext_f("{n} still open", "{n} still open", n, &[]),
|
||||||
|
// Translators: part of a list summing up a sync pass.
|
||||||
|
"failed" => ngettext_f("{n} failed", "{n} failed", n, &[]),
|
||||||
|
_ => return None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(parts.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Google Photos import summary such as "40 uploaded · 2 already there".
|
||||||
|
fn import_summary(detail: &str) -> Option<String> {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for part in detail.split(" · ") {
|
||||||
|
if part == "cancelled" {
|
||||||
|
parts.push(gettext("Cancelled"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (n, label) = part.split_once(' ')?;
|
||||||
|
let n = n.parse::<u64>().ok()?;
|
||||||
|
parts.push(match label {
|
||||||
|
"uploaded" => ngettext_f("{n} uploaded", "{n} uploaded", n, &[]),
|
||||||
|
// Translators: photos an import skipped because they were already
|
||||||
|
// in Proton Drive.
|
||||||
|
"already there" => ngettext_f("{n} already there", "{n} already there", n, &[]),
|
||||||
|
"albums" => ngettext_f("{n} album", "{n} albums", n, &[]),
|
||||||
|
"failed" => ngettext_f("{n} failed", "{n} failed", n, &[]),
|
||||||
|
_ => return None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(parts.join(" · "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number in front of `text` when the rest is one of `units`, as in
|
||||||
|
/// "3 item(s)".
|
||||||
|
fn leading_count(text: &str, units: &[&str]) -> Option<u64> {
|
||||||
|
let (n, unit) = text.split_once(' ')?;
|
||||||
|
units.contains(&unit).then_some(())?;
|
||||||
|
n.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name to show for a target: the last part of a path, or `None` when
|
||||||
|
/// the daemon logged a node id because it had no path.
|
||||||
|
fn target_name(target: &str) -> Option<String> {
|
||||||
|
let looks_like_uid =
|
||||||
|
target.len() >= 40 && target.matches('~').count() == 1 && !target.contains(['/', ' ', '.']);
|
||||||
|
if target.is_empty() || looks_like_uid {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(file_name(target).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folder a destination path names, with the root read as "My files".
|
||||||
|
fn folder_name(dest: &str) -> String {
|
||||||
|
let name = file_name(dest.trim_end_matches('/'));
|
||||||
|
if name.is_empty() {
|
||||||
|
gettext("My files")
|
||||||
|
} else {
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file a `name (sync-conflict <stamp>).ext` copy was made from.
|
||||||
|
pub(crate) fn conflict_base(name: &str) -> Option<String> {
|
||||||
|
let (start, close) = conflict_marker(name)?;
|
||||||
|
Some(format!("{}{}", &name[..start], &name[close + 1..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a conflicting copy was made, from the stamp in its name.
|
||||||
|
fn conflict_stamp(name: &str) -> Option<i64> {
|
||||||
|
let (start, close) = conflict_marker(name)?;
|
||||||
|
let inner = &name[start + MARKER.len()..close];
|
||||||
|
inner.split('-').next()?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER: &str = " (sync-conflict ";
|
||||||
|
|
||||||
|
/// Where the ` (sync-conflict <stamp>[-<n>])` marker starts and where its
|
||||||
|
/// closing parenthesis is. The same shape the daemon reads back.
|
||||||
|
fn conflict_marker(name: &str) -> Option<(usize, usize)> {
|
||||||
|
let start = name.find(MARKER)?;
|
||||||
|
let after = start + MARKER.len();
|
||||||
|
let close = after + name[after..].find(')')?;
|
||||||
|
let inner = &name[after..close];
|
||||||
|
let mut parts = inner.splitn(2, '-');
|
||||||
|
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
||||||
|
if !digits(parts.next()?) || parts.next().is_some_and(|s| !digits(s)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((start, close))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn entry(kind: ActivityKind, target: &str, detail: &str, ok: bool) -> ActivityEntry {
|
||||||
|
ActivityEntry {
|
||||||
|
time: 0,
|
||||||
|
kind,
|
||||||
|
target: target.into(),
|
||||||
|
detail: detail.into(),
|
||||||
|
ok,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn details_that_repeat_the_title_are_dropped() {
|
||||||
|
let d = describe(&entry(ActivityKind::Upload, "a.txt", "uploaded", true));
|
||||||
|
assert_eq!(d.title, "Uploaded a.txt");
|
||||||
|
assert!(d.details.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paths_and_counts_read_as_names_and_numbers() {
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Move,
|
||||||
|
"/mnt/a.txt",
|
||||||
|
"to /mnt/Docs",
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(d.title, "Moved a.txt");
|
||||||
|
assert_eq!(d.details, vec!["To Docs"]);
|
||||||
|
let d = describe(&entry(ActivityKind::Restore, "3 item(s)", "", true));
|
||||||
|
assert_eq!(d.title, "Restored 3 items");
|
||||||
|
let d = describe(&entry(ActivityKind::EmptyTrash, "1 item", "", true));
|
||||||
|
assert_eq!(d.title, "Emptied the Trash (1 item)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failures_say_what_could_not_happen() {
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Upload,
|
||||||
|
"a.txt",
|
||||||
|
"quota exceeded",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(d.title, "Couldn't upload a.txt");
|
||||||
|
assert_eq!(d.details, vec!["Quota exceeded"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_passes_are_summed_up() {
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Sync,
|
||||||
|
"Docs",
|
||||||
|
"2 uploaded, 1 folder(s) created",
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(d.title, "Synced Docs");
|
||||||
|
assert_eq!(d.details, vec!["2 uploaded, 1 folder created"]);
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Sync,
|
||||||
|
"sync incomplete for Docs",
|
||||||
|
"2 item(s) failed; will retry",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(d.title, "Some items in Docs didn't sync");
|
||||||
|
assert_eq!(d.details, vec!["2 items failed and will be tried again"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_conflicting_copy_names_its_original() {
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Conflict,
|
||||||
|
"notes (sync-conflict 1700000000).txt",
|
||||||
|
"differs from notes.txt",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(d.title, "Conflicting copy of notes.txt");
|
||||||
|
assert_eq!(d.details[0], "Differs from notes.txt");
|
||||||
|
assert_eq!(d.details.len(), 2);
|
||||||
|
assert_eq!(conflict_base("a (sync-conflict 12-2)"), Some("a".into()));
|
||||||
|
assert_eq!(conflict_base("a (sync-conflict x).txt"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_ids_are_not_shown_as_names() {
|
||||||
|
let uid = format!("{}~{}", "A".repeat(30), "B".repeat(30));
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Share,
|
||||||
|
&uid,
|
||||||
|
"1 recipient(s) as viewer",
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(d.title, "Shared an item");
|
||||||
|
assert_eq!(d.details, vec!["1 person can view"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_details_show_as_written() {
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Download,
|
||||||
|
"a.txt",
|
||||||
|
"network timeout",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(d.details, vec!["Network timeout"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
|||||||
&ui,
|
&ui,
|
||||||
"network-offline-symbolic",
|
"network-offline-symbolic",
|
||||||
&gettext("Not connected"),
|
&gettext("Not connected"),
|
||||||
&gettext("The Proton Drive mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -286,7 +286,7 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
|||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&ui,
|
&ui,
|
||||||
&gettext("Couldn't create the album"),
|
&gettext("Couldn't create the album"),
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -351,7 +351,11 @@ fn run_album_request(ui: &Rc<Ui>, request: Request, done: String, failed: String
|
|||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, &failed, &message, kind)
|
toast_failure(&ui, &failed, &message, kind)
|
||||||
}
|
}
|
||||||
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&failed,
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -380,7 +384,7 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
|||||||
toast_error(
|
toast_error(
|
||||||
&ui,
|
&ui,
|
||||||
&gettext("Couldn't load albums"),
|
&gettext("Couldn't load albums"),
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -487,7 +491,7 @@ fn add_to_album(ui: &Rc<Ui>, album: String, photos: Vec<String>) {
|
|||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&ui,
|
&ui,
|
||||||
&gettext("Couldn't add to the album"),
|
&gettext("Couldn't add to the album"),
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -559,7 +563,7 @@ pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<Stri
|
|||||||
toast_error(
|
toast_error(
|
||||||
&ui,
|
&ui,
|
||||||
&gettext("Couldn't remove from the album"),
|
&gettext("Couldn't remove from the album"),
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -614,7 +618,7 @@ pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
|
|||||||
// would silently do something else. The Photos/Albums switcher goes too: an
|
// would silently do something else. The Photos/Albums switcher goes too: an
|
||||||
// album is a level below it, and back is the way out.
|
// album is a level below it, and back is the way out.
|
||||||
ui.gallery.filters.set_visible(false);
|
ui.gallery.filters.set_visible(false);
|
||||||
ui.gallery.view_switch.set_visible(false);
|
ui.gallery.view_switch.widget().set_visible(false);
|
||||||
ui.gallery.upload.set_visible(false);
|
ui.gallery.upload.set_visible(false);
|
||||||
ui.gallery.back.set_visible(true);
|
ui.gallery.back.set_visible(true);
|
||||||
// A stale kind/date filter would otherwise be sent with the album request.
|
// A stale kind/date filter would otherwise be sent with the album request.
|
||||||
@@ -632,7 +636,7 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.title.set_title(&gettext("Photos"));
|
ui.gallery.title.set_title(&gettext("Photos"));
|
||||||
ui.gallery.view_switch.set_visible(true);
|
ui.gallery.view_switch.widget().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);
|
||||||
}
|
}
|
||||||
@@ -649,17 +653,15 @@ fn albums_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
|||||||
/// whole content area; back leaves an open album for the grid it came from.
|
/// whole content area; back leaves an open album for the grid it came from.
|
||||||
pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||||
let ui_albums = ui.clone();
|
let ui_albums = ui.clone();
|
||||||
ui.gallery.albums_btn.clone().connect_toggled(move |btn| {
|
ui.gallery.view_switch.connect_changed(move |view| {
|
||||||
if btn.is_active() {
|
if view == VIEW_ALBUMS {
|
||||||
show_albums(&ui_albums);
|
show_albums(&ui_albums);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only the button being switched *on* acts: the group fires `toggled` for
|
|
||||||
// the one going off too, and acting on both would load twice.
|
|
||||||
let ui_photos = ui.clone();
|
let ui_photos = ui.clone();
|
||||||
ui.gallery.photos_btn.clone().connect_toggled(move |btn| {
|
ui.gallery.view_switch.connect_changed(move |view| {
|
||||||
if !btn.is_active() {
|
if view != VIEW_TIMELINE {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
close_album(&ui_photos);
|
close_album(&ui_photos);
|
||||||
|
|||||||
@@ -1,85 +1,115 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
pub(crate) struct DevicesState {
|
pub(crate) struct DevicesState {
|
||||||
// Devices page: two sections (synced folders + other devices), rebuilt
|
// Computers page: one list of the account's computers, this one first, and
|
||||||
// wholesale on each load.
|
// a read-only browser over another computer's backup.
|
||||||
|
/// "list", "browse", or "status" while the page loads or can't be read.
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
|
/// Rows added last time, removed before the next paint (adw groups have no
|
||||||
|
/// clear-all).
|
||||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||||
pub(crate) sync_group: adw::PreferencesGroup,
|
/// The browse view's files.
|
||||||
/// The single "This computer" summary row. The per-folder rows moved to the
|
pub(crate) files: FileList,
|
||||||
/// Locations page, which owns every local path.
|
/// The browse view: "list", or "empty".
|
||||||
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
|
pub(crate) files_content: gtk4::Stack,
|
||||||
/// "Rename" action in the "This computer" header. Insensitive until the
|
/// The breadcrumb trail from "Computers" down to the current folder.
|
||||||
/// device list identifies this machine's own device.
|
pub(crate) crumb: gtk4::Box,
|
||||||
pub(crate) rename_this: gtk4::Button,
|
/// Grid or list for the browse view; hidden on the device list.
|
||||||
/// This machine's own device `(uid, name)`, from the last device list. The
|
pub(crate) layout: gtk4::Button,
|
||||||
/// current device is filtered out of the "Other computers" rows, so this is
|
/// Where the browse view is, as `(uid, name)`: the computer first, then each
|
||||||
/// where its rename target lives. `None` until identified.
|
/// folder below it. Empty = the device list. A backup has no path in the
|
||||||
pub(crate) this_device: RefCell<Option<(String, String)>>,
|
/// mount, so descending is uid-addressed and the stack is the breadcrumb.
|
||||||
pub(crate) inflight: Cell<bool>,
|
pub(crate) nav: RefCell<Vec<(String, String)>>,
|
||||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
/// The [`DevicesState::nav`] the page on screen was painted for, or `None`
|
||||||
|
/// before the first paint.
|
||||||
|
pub(crate) listed: RefCell<Option<Vec<(String, String)>>>,
|
||||||
|
/// Runs the loads; the rows stay up while one runs.
|
||||||
|
pub(crate) loader: Rc<Loader>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DevicesState {
|
||||||
|
pub(crate) fn new(widgets: &DevicesWidgets) -> Self {
|
||||||
|
DevicesState {
|
||||||
|
content: widgets.content.clone(),
|
||||||
|
status: widgets.status.clone(),
|
||||||
|
retry: widgets.retry.clone(),
|
||||||
|
group: widgets.group.clone(),
|
||||||
|
rows: RefCell::new(Vec::new()),
|
||||||
|
files: widgets.files.clone(),
|
||||||
|
files_content: widgets.files_content.clone(),
|
||||||
|
crumb: widgets.crumb.clone(),
|
||||||
|
layout: widgets.layout.clone(),
|
||||||
|
nav: RefCell::new(Vec::new()),
|
||||||
|
listed: RefCell::new(None),
|
||||||
|
loader: Loader::new(&widgets.content),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Widgets the Devices page's load/repaint touch.
|
/// Widgets the Devices page's load/repaint touch.
|
||||||
pub(crate) struct DevicesWidgets {
|
pub(crate) struct DevicesWidgets {
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
/// "This computer" — this machine's device identity and a pointer to its
|
/// Every computer on the account, this one first.
|
||||||
/// folders on the Locations page.
|
|
||||||
pub(crate) sync_group: adw::PreferencesGroup,
|
|
||||||
/// "Rename" in the "This computer" header — renames this machine's device.
|
|
||||||
pub(crate) rename_this: gtk4::Button,
|
|
||||||
/// "Other computers" — the account's *other* registered devices. This
|
|
||||||
/// machine's own device is deliberately not among them; see
|
|
||||||
/// [`repaint_devices`].
|
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
|
pub(crate) files: FileList,
|
||||||
|
pub(crate) files_content: gtk4::Stack,
|
||||||
|
/// Goes up one folder, and from a computer's top level back to the list.
|
||||||
|
pub(crate) back: gtk4::Button,
|
||||||
|
pub(crate) crumb: gtk4::Box,
|
||||||
|
pub(crate) layout: gtk4::Button,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Computers page: a "This computer" section naming the device this machine
|
/// The Computers page: every computer backing up to the account, with this one
|
||||||
/// backs up to (renamable from its header), plus an "Other computers" section
|
/// first. This computer's row leads to its folders on the Sync page; another
|
||||||
/// listing the account's other registered devices. The synced folders
|
/// computer's row opens its backup read-only, in the same list as My files.
|
||||||
/// themselves live on the Sync page.
|
|
||||||
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||||
let refresh = refresh_button();
|
let group = adw::PreferencesGroup::new();
|
||||||
|
let clamp = adw::Clamp::builder().child(&group).build();
|
||||||
// This page is about device *identity* — which computer this is, which other
|
|
||||||
// computers back up to the account, and how to adopt one. The folders
|
|
||||||
// themselves are local paths, so they live on the Sync page; keeping a
|
|
||||||
// second copy of that list here would be two places to change the same mode.
|
|
||||||
let sync_group = adw::PreferencesGroup::builder()
|
|
||||||
.title(gettext("This computer"))
|
|
||||||
.description(gettext("The computer this machine backs up as."))
|
|
||||||
.build();
|
|
||||||
// Rename control for *this* machine's device, in the section header. The
|
|
||||||
// current device is filtered out of "Other computers", so this is the only
|
|
||||||
// place it can be renamed; insensitive until the device list identifies it.
|
|
||||||
let rename_this = gtk4::Button::builder()
|
|
||||||
.icon_name("document-edit-symbolic")
|
|
||||||
.tooltip_text(gettext("Rename this computer"))
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.sensitive(false)
|
|
||||||
.build();
|
|
||||||
rename_this.add_css_class("flat");
|
|
||||||
sync_group.set_header_suffix(Some(&rename_this));
|
|
||||||
let group = adw::PreferencesGroup::builder()
|
|
||||||
.title(gettext("Other computers"))
|
|
||||||
.description(gettext("Other computers backing up to this account."))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
|
||||||
groups.append(&sync_group);
|
|
||||||
groups.append(&group);
|
|
||||||
let clamp = adw::Clamp::builder().child(&groups).build();
|
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.child(&clamp)
|
.child(&clamp)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Browse: a path bar over the list, as on Shared with me.
|
||||||
|
let files = FileList::new();
|
||||||
|
files.show_list(true);
|
||||||
|
let layout = layout_button(&files);
|
||||||
|
layout.set_visible(false);
|
||||||
|
let back = gtk4::Button::builder()
|
||||||
|
.icon_name("go-previous-symbolic")
|
||||||
|
.tooltip_text(gettext("Back"))
|
||||||
|
.build();
|
||||||
|
back.add_css_class("flat");
|
||||||
|
let crumb = gtk4::Box::new(gtk4::Orientation::Horizontal, 2);
|
||||||
|
crumb.set_valign(gtk4::Align::Center);
|
||||||
|
let crumb_scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::External)
|
||||||
|
.vscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.hexpand(true)
|
||||||
|
.child(&crumb)
|
||||||
|
.build();
|
||||||
|
let path_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
|
path_bar.append(&back);
|
||||||
|
path_bar.append(&crumb_scroll);
|
||||||
|
let files_empty = adw::StatusPage::builder()
|
||||||
|
.icon_name("folder-open-symbolic")
|
||||||
|
.title(gettext("This folder is empty"))
|
||||||
|
.description(gettext("Nothing in this folder has been backed up."))
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
files_empty.add_css_class("compact");
|
||||||
|
let files_content = gtk4::Stack::new();
|
||||||
|
files_content.add_named(&files.views, Some("list"));
|
||||||
|
files_content.add_named(&files_empty, Some("empty"));
|
||||||
|
let browse = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
|
browse.append(&path_bar);
|
||||||
|
browse.append(&files_content);
|
||||||
|
|
||||||
let retry = gtk4::Button::builder()
|
let retry = gtk4::Button::builder()
|
||||||
.label(gettext("Retry"))
|
.label(gettext("Retry"))
|
||||||
.halign(gtk4::Align::Center)
|
.halign(gtk4::Align::Center)
|
||||||
@@ -98,6 +128,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
|||||||
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(&scroll, Some("list"));
|
content.add_named(&scroll, Some("list"));
|
||||||
|
content.add_named(&browse, Some("browse"));
|
||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
@@ -107,35 +138,66 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
|||||||
inner.set_margin_end(18);
|
inner.set_margin_end(18);
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
let (frame, header, _) = page_frame(&gettext("Computers"), &inner);
|
let (frame, header, _) = page_frame(&gettext("Computers"), &inner);
|
||||||
header.pack_end(&refresh);
|
header.pack_end(&layout);
|
||||||
|
|
||||||
(
|
(
|
||||||
frame.upcast(),
|
frame.upcast(),
|
||||||
DevicesWidgets {
|
DevicesWidgets {
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
sync_group,
|
|
||||||
rename_this,
|
|
||||||
group,
|
group,
|
||||||
|
files,
|
||||||
|
files_content,
|
||||||
|
back,
|
||||||
|
crumb,
|
||||||
|
layout,
|
||||||
retry,
|
retry,
|
||||||
refresh,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the Devices page's retry button and the Rename action.
|
/// Install the browse view's file list and the page's buttons.
|
||||||
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button) {
|
pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
|
||||||
|
let files = &ui.devices.files;
|
||||||
|
files.wire(
|
||||||
|
ui,
|
||||||
|
FileListBehavior {
|
||||||
|
activate: open_device_entry,
|
||||||
|
entry_menu: device_entry_menu,
|
||||||
|
bulk_menu: device_bulk_menu,
|
||||||
|
background_menu: device_background_menu,
|
||||||
|
badges: false,
|
||||||
|
drag_and_drop: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
files
|
||||||
|
.column_view
|
||||||
|
.append_column(&text_column(&pgettext("column", "Modified"), |e| {
|
||||||
|
if e.modified > 0 {
|
||||||
|
dates::short_date(e.modified)
|
||||||
|
} else {
|
||||||
|
"—".to_string()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
files
|
||||||
|
.column_view
|
||||||
|
.append_column(&text_column(&pgettext("column", "Size"), |e| {
|
||||||
|
if e.is_dir {
|
||||||
|
"—".to_string()
|
||||||
|
} else {
|
||||||
|
human_bytes(e.size)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let ui_back = ui.clone();
|
||||||
|
widgets.back.connect_clicked(move |_| {
|
||||||
|
ui_back.devices.nav.borrow_mut().pop();
|
||||||
|
load_devices(&ui_back);
|
||||||
|
});
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
retry.connect_clicked(move |_| {
|
widgets
|
||||||
service::restart();
|
.retry
|
||||||
load_devices(&ui_retry);
|
.connect_clicked(move |_| restart_service_then(&ui_retry, load_devices));
|
||||||
});
|
|
||||||
let ui_ren = ui.clone();
|
|
||||||
ui.devices.rename_this.connect_clicked(move |_| {
|
|
||||||
if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() {
|
|
||||||
prompt_rename_device(&ui_ren, &uid, &name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show a status page in place of the Devices list.
|
/// Show a status page in place of the Devices list.
|
||||||
@@ -147,21 +209,17 @@ pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
|
|||||||
ui.devices.content.set_visible_child_name("status");
|
ui.devices.content.set_visible_child_name("status");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch this machine's synced folders and the account's other devices, then
|
/// Load the view [`DevicesState::nav`] points at: the device list, or a folder
|
||||||
/// repaint both sections. The two requests are chained so a single unreachable
|
/// of another computer's backup.
|
||||||
/// daemon collapses the whole page to a status view.
|
|
||||||
pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||||
if ui.devices.inflight.get() {
|
cancel_file_thumbnails(ui);
|
||||||
|
let nav = ui.devices.nav.borrow().clone();
|
||||||
|
ui.devices.layout.set_visible(!nav.is_empty());
|
||||||
|
if !nav.is_empty() {
|
||||||
|
load_device_folder(ui, &nav);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.devices.inflight.set(true);
|
let ticket = begin_devices_load(ui, "computer-symbolic", gettext("Reading your computers."));
|
||||||
devices_status(
|
|
||||||
ui,
|
|
||||||
"computer-symbolic",
|
|
||||||
&gettext("Loading…"),
|
|
||||||
&gettext("Reading your computers."),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
// The two lists are independent and the daemon serves requests concurrently,
|
// The two lists are independent and the daemon serves requests concurrently,
|
||||||
// so fire both up front and collect them, rather than paying two round trips
|
// so fire both up front and collect them, rather than paying two round trips
|
||||||
@@ -172,213 +230,361 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let sync = rx.recv().await;
|
let sync = rx.recv().await;
|
||||||
let devices_reply = rx2.recv().await;
|
let devices_reply = rx2.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
if !ticket.is_current() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let folders = match sync {
|
let folders = match sync {
|
||||||
Ok(Ok(Response::SyncFolders { items })) => items,
|
Ok(Ok(Response::SyncFolders { items })) => items,
|
||||||
// The daemon answered but not as expected — treat as empty and carry
|
// The daemon answered but not as expected — treat as empty and carry
|
||||||
// on to the devices list rather than failing the whole page.
|
// on to the devices list rather than failing the whole page.
|
||||||
Ok(Ok(_)) => Vec::new(),
|
Ok(Ok(_)) => Vec::new(),
|
||||||
Ok(Err(_)) | Err(_) => {
|
Ok(Err(_)) | Err(_) => {
|
||||||
ui.busy_end();
|
|
||||||
ui.devices.inflight.set(false);
|
|
||||||
ui.devices.loaded_at.set(None);
|
|
||||||
devices_unreachable(&ui);
|
devices_unreachable(&ui);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Daemon reachable: show the list and paint the "This computer" section.
|
|
||||||
ui.devices.content.set_visible_child_name("list");
|
|
||||||
repaint_this_computer(&ui, &folders);
|
|
||||||
|
|
||||||
let devices = match devices_reply {
|
let devices = match devices_reply {
|
||||||
Ok(Ok(Response::Devices { items })) => items,
|
Ok(Ok(Response::Devices { items })) => items,
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
ui.busy_end();
|
ui.devices.content.set_visible_child_name("list");
|
||||||
ui.devices.inflight.set(false);
|
repaint_devices(&ui, &devices, &folders);
|
||||||
repaint_devices(&ui, &devices);
|
repaint_device_crumb(&ui, &[]);
|
||||||
ui.devices.loaded_at.set(Some(Instant::now()));
|
*ui.devices.listed.borrow_mut() = Some(Vec::new());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List one folder of another computer's backup. The computer's own top level
|
||||||
|
/// is its restorable folders; below that, any folder lists by uid.
|
||||||
|
fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||||
|
let ticket = begin_devices_load(ui, "folder-symbolic", gettext("Reading this folder."));
|
||||||
|
ui.busy_begin();
|
||||||
|
let (uid, _) = nav.last().cloned().unwrap_or_default();
|
||||||
|
let request = if nav.len() == 1 {
|
||||||
|
Request::ListDeviceRestorableFolders { device: uid }
|
||||||
|
} else {
|
||||||
|
Request::ListSharedFolder { uid }
|
||||||
|
};
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), request);
|
||||||
|
let ui = ui.clone();
|
||||||
|
let nav = nav.to_vec();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
if !ticket.is_current() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let entries = match result {
|
||||||
|
Ok(Ok(Response::Entries { entries })) => entries,
|
||||||
|
Ok(Ok(Response::RestorableFolders { items })) => {
|
||||||
|
items.into_iter().map(restorable_entry).collect()
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
// The folder or the computer is gone: fall back a level rather
|
||||||
|
// than stranding the page on a dead uid.
|
||||||
|
ui.devices.nav.borrow_mut().pop();
|
||||||
|
toast_failure(&ui, &gettext("Couldn't open folder"), &message, kind);
|
||||||
|
load_devices(&ui);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
devices_unreachable(&ui);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ui.devices.content.set_visible_child_name("browse");
|
||||||
|
repaint_device_crumb(&ui, &nav);
|
||||||
|
*ui.devices.listed.borrow_mut() = Some(nav);
|
||||||
|
if entries.is_empty() {
|
||||||
|
ui.devices.files.model.remove_all();
|
||||||
|
ui.devices.files_content.set_visible_child_name("empty");
|
||||||
|
} else {
|
||||||
|
ui.devices.files_content.set_visible_child_name("list");
|
||||||
|
replace_items(&ui.devices.files.model, &entries);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A computer's top-level folder as a browsable entry.
|
||||||
|
fn restorable_entry(folder: RestorableFolder) -> DirEntry {
|
||||||
|
DirEntry {
|
||||||
|
name: folder.name,
|
||||||
|
is_dir: true,
|
||||||
|
uid: folder.remote_uid,
|
||||||
|
..DirEntry::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a load of the view [`DevicesState::nav`] points at. A reload of the
|
||||||
|
/// view on screen keeps it up and usable; moving elsewhere greys it out until
|
||||||
|
/// the new view arrives.
|
||||||
|
fn begin_devices_load(ui: &Rc<Ui>, icon: &'static str, description: String) -> LoadTicket {
|
||||||
|
let ui_p = ui.clone();
|
||||||
|
let placeholder = move || {
|
||||||
|
devices_status(&ui_p, icon, &gettext("Loading…"), &description, false);
|
||||||
|
};
|
||||||
|
if *ui.devices.listed.borrow() == Some(ui.devices.nav.borrow().clone()) {
|
||||||
|
ui.devices.loader.refresh(placeholder)
|
||||||
|
} else {
|
||||||
|
ui.devices.listed.borrow_mut().take();
|
||||||
|
ui.devices.loader.replace(placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The daemon didn't answer the Devices page.
|
/// The daemon didn't answer the Devices page.
|
||||||
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
||||||
if service::is_failed() || !service::is_active() {
|
service_unreachable(ui, "devices", devices_status, load_devices);
|
||||||
devices_status(
|
|
||||||
ui,
|
|
||||||
"network-offline-symbolic",
|
|
||||||
&gettext("Not connected"),
|
|
||||||
&gettext("The Proton Drive mount service isn't running."),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
devices_status(
|
|
||||||
ui,
|
|
||||||
"folder-remote-symbolic",
|
|
||||||
&gettext("Connecting…"),
|
|
||||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
let ui = ui.clone();
|
|
||||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("devices") {
|
|
||||||
load_devices(&ui);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the "Other computers" section from a fresh listing. Empty is a normal
|
/// Rebuild the device list: this computer first, then the others.
|
||||||
/// state (this machine may be the only device), so it shows a placeholder row
|
|
||||||
/// rather than collapsing the page.
|
|
||||||
///
|
///
|
||||||
/// This machine's own device is filtered out. Deleting a device deletes its root
|
/// This computer's row offers no Remove. Deleting a device deletes its root
|
||||||
/// folder — every file backed up from it — and for *this* machine that would also
|
/// folder — every file backed up from it — and for this machine that would also
|
||||||
/// pull the ground out from under the synced folders listed directly above, so it
|
/// pull the ground out from under its synced folders, so it is not offered as a
|
||||||
/// is not offered as a peer of "remove some other laptop". Removing this
|
/// peer of "remove some other laptop". Removing this computer's backup means
|
||||||
/// computer's backup means removing its folders, each of which asks about its
|
/// removing its folders, each of which asks about its cloud copy on its own
|
||||||
/// cloud copy on its own terms.
|
/// terms.
|
||||||
pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo], folders: &[SyncFolderInfo]) {
|
||||||
for row in ui.devices.rows.borrow_mut().drain(..) {
|
for row in ui.devices.rows.borrow_mut().drain(..) {
|
||||||
ui.devices.group.remove(&row);
|
ui.devices.group.remove(&row);
|
||||||
}
|
}
|
||||||
// Identify this machine's own device so the "This computer" header's Rename
|
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||||
// action has a target. It never appears in the rows below, so this is the
|
let me = devices.iter().find(|d| d.this_device);
|
||||||
// only place its name/uid is captured.
|
rows.push(this_computer_row(ui, me, folders).upcast());
|
||||||
match devices.iter().find(|d| d.this_device) {
|
|
||||||
Some(me) => {
|
|
||||||
*ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone()));
|
|
||||||
ui.devices
|
|
||||||
.sync_group
|
|
||||||
// Translators: {name} is the name of this computer's device.
|
|
||||||
.set_description(Some(&gettext_f(
|
|
||||||
"Backing up as “{name}”.",
|
|
||||||
&[("name", &me.name)],
|
|
||||||
)));
|
|
||||||
ui.devices.rename_this.set_sensitive(true);
|
|
||||||
ui.devices
|
|
||||||
.rename_this
|
|
||||||
// Translators: {name} is the name of this computer's device.
|
|
||||||
.set_tooltip_text(Some(&gettext_f(
|
|
||||||
"Rename this computer ({name})",
|
|
||||||
&[("name", &me.name)],
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
*ui.devices.this_device.borrow_mut() = None;
|
|
||||||
ui.devices.rename_this.set_sensitive(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect();
|
let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect();
|
||||||
|
for dev in &others {
|
||||||
|
rows.push(other_computer_row(ui, dev).upcast());
|
||||||
|
}
|
||||||
if others.is_empty() {
|
if others.is_empty() {
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(gettext("No other computers"))
|
.title(gettext("No other computers"))
|
||||||
.subtitle(gettext("Desktop apps syncing to this account appear here."))
|
.subtitle(gettext(
|
||||||
|
"Install Proton Drive on another computer to see it here.",
|
||||||
|
))
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||||
ui.devices.group.add(&row);
|
let get = gtk4::LinkButton::with_label(PROTON_DRIVE_DOWNLOAD, &gettext("Get Proton Drive"));
|
||||||
*ui.devices.rows.borrow_mut() = vec![row.upcast()];
|
get.set_valign(gtk4::Align::Center);
|
||||||
return;
|
row.add_suffix(&get);
|
||||||
}
|
|
||||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
|
||||||
for dev in others {
|
|
||||||
let row = adw::ActionRow::builder()
|
|
||||||
.title(&dev.name)
|
|
||||||
.subtitle(device_subtitle(dev))
|
|
||||||
.build();
|
|
||||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
|
||||||
// Adoption is how a reinstalled or renamed machine re-attaches to the
|
|
||||||
// device it used to be, instead of registering a duplicate. It only
|
|
||||||
// makes sense on *another* computer's row, which is the only place this
|
|
||||||
// loop paints.
|
|
||||||
let (ui_ren, uid_ren, name_ren) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
|
||||||
let (ui_ad, uid_ad, name_ad) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
|
||||||
let (ui_rm, uid_rm, name_rm) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
|
||||||
let (ui_rs, uid_rs, name_rs) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
|
||||||
row.add_suffix(&more_menu_button(vec![
|
|
||||||
(
|
|
||||||
&gettext("Restore to This Computer…"),
|
|
||||||
Box::new(move || {
|
|
||||||
prompt_restore_folders(&ui_rs, Some((uid_rs.clone(), name_rs.clone())))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
&gettext("Rename…"),
|
|
||||||
Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
&gettext("Use This Computer's Identity…"),
|
|
||||||
Box::new(move || prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
&gettext("Remove Computer…"),
|
|
||||||
Box::new(move || prompt_remove_device(&ui_rm, &uid_rm, &name_rm)),
|
|
||||||
),
|
|
||||||
]));
|
|
||||||
ui.devices.group.add(&row);
|
|
||||||
rows.push(row.upcast());
|
rows.push(row.upcast());
|
||||||
}
|
}
|
||||||
|
for row in &rows {
|
||||||
|
ui.devices.group.add(row);
|
||||||
|
}
|
||||||
*ui.devices.rows.borrow_mut() = rows;
|
*ui.devices.rows.borrow_mut() = rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A device row's subtitle: its platform, and when it last synced. A device that
|
/// Where the Proton Drive apps for other systems are.
|
||||||
/// has never synced says so — an unexplained missing date reads as a bug, and
|
const PROTON_DRIVE_DOWNLOAD: &str = "https://proton.me/drive/download";
|
||||||
/// "never" is the fact that tells the user this computer isn't backing anything up.
|
|
||||||
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
|
/// This computer's row: its device name with a "This computer" pill, and how
|
||||||
match dev.last_sync {
|
/// many folders it backs up. It leads to those folders on the Sync page, which
|
||||||
// Translators: {platform} is the device's platform (such as "Linux"), {time} a date and time.
|
/// owns every local path. `me` is `None` until the daemon has registered this
|
||||||
Some(secs) if secs > 0 => gettext_f(
|
/// machine as a device.
|
||||||
"{platform} · last synced {time}",
|
fn this_computer_row(
|
||||||
&[
|
ui: &Rc<Ui>,
|
||||||
("platform", &dev.device_type),
|
me: Option<&DeviceInfo>,
|
||||||
("time", &activity_time(secs)),
|
folders: &[SyncFolderInfo],
|
||||||
],
|
) -> adw::ActionRow {
|
||||||
),
|
let row = adw::ActionRow::builder()
|
||||||
// Translators: {platform} is the device's platform, such as "Linux".
|
.title(me.map_or_else(|| gettext("This computer"), |d| d.name.clone()))
|
||||||
_ => gettext_f(
|
.subtitle(this_computer_subtitle(folders))
|
||||||
"{platform} · never synced",
|
.activatable(true)
|
||||||
&[("platform", &dev.device_type)],
|
.build();
|
||||||
),
|
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||||
|
if me.is_some() {
|
||||||
|
let pill = gtk4::Label::new(Some(&gettext("This computer")));
|
||||||
|
pill.add_css_class("role-pill");
|
||||||
|
pill.add_css_class("caption");
|
||||||
|
pill.set_valign(gtk4::Align::Center);
|
||||||
|
row.add_suffix(&pill);
|
||||||
|
}
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
if let Some(me) = me {
|
||||||
|
let (ui_c, uid, name) = (ui.clone(), me.uid.clone(), me.name.clone());
|
||||||
|
menu.item(&gettext("Rename…"), move || {
|
||||||
|
prompt_rename_device(&ui_c, &uid, &name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Restore Folders…"), move || {
|
||||||
|
prompt_restore_folders(&ui_c, None)
|
||||||
|
});
|
||||||
|
row.add_suffix(&menu.button());
|
||||||
|
row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
row.connect_activated(move |_| {
|
||||||
|
ui_c.locations.views.set_visible_child_name("folders");
|
||||||
|
ui_c.stack.set_visible_child_name("locations");
|
||||||
|
});
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Another computer's row. Opening it browses that computer's backup.
|
||||||
|
fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(&dev.name)
|
||||||
|
.subtitle(device_subtitle(dev))
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||||
|
let (uid, name) = (dev.uid.clone(), dev.name.clone());
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||||
|
menu.item(&gettext("Browse Files"), move || {
|
||||||
|
browse_device(&ui_c, &uid_c, &name_c)
|
||||||
|
});
|
||||||
|
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||||
|
menu.item(&gettext("Restore to This Computer…"), move || {
|
||||||
|
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
|
||||||
|
});
|
||||||
|
menu.section();
|
||||||
|
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||||
|
menu.item(&gettext("Rename…"), move || {
|
||||||
|
prompt_rename_device(&ui_c, &uid_c, &name_c)
|
||||||
|
});
|
||||||
|
// Adoption is how a reinstalled or renamed machine re-attaches to the device
|
||||||
|
// it used to be, instead of registering a duplicate. It only makes sense on
|
||||||
|
// another computer's row.
|
||||||
|
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||||
|
menu.item(&gettext("Continue This Backup Here…"), move || {
|
||||||
|
prompt_adopt_device(&ui_c, &uid_c, &name_c)
|
||||||
|
});
|
||||||
|
menu.section();
|
||||||
|
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||||
|
menu.item(&gettext("Remove…"), move || {
|
||||||
|
prompt_remove_device(&ui_c, &uid_c, &name_c)
|
||||||
|
});
|
||||||
|
row.add_suffix(&menu.button());
|
||||||
|
row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
row.connect_activated(move |_| browse_device(&ui_c, &uid, &name));
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open another computer's backup at its top level.
|
||||||
|
fn browse_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||||
|
*ui.devices.nav.borrow_mut() = vec![(uid.to_string(), name.to_string())];
|
||||||
|
load_devices(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the breadcrumb trail for `nav`: "Computers", then the computer and
|
||||||
|
/// each folder down to the current one, which is a plain heading.
|
||||||
|
fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||||
|
let crumb = &ui.devices.crumb;
|
||||||
|
while let Some(child) = crumb.first_child() {
|
||||||
|
crumb.remove(&child);
|
||||||
|
}
|
||||||
|
crumb.append(&device_crumb_node(
|
||||||
|
ui,
|
||||||
|
&gettext("Computers"),
|
||||||
|
0,
|
||||||
|
nav.is_empty(),
|
||||||
|
));
|
||||||
|
for (i, (_, name)) in nav.iter().enumerate() {
|
||||||
|
let sep = gtk4::Label::new(Some("›"));
|
||||||
|
sep.add_css_class("dim-label");
|
||||||
|
crumb.append(&sep);
|
||||||
|
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the "This computer" section: one row naming the device this machine
|
/// One breadcrumb segment: a heading for the current folder, or a flat button
|
||||||
/// backs up to, and how many folders it carries.
|
/// that goes back up to `depth` levels below the device list.
|
||||||
///
|
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||||
/// The per-folder rows (mode switch, sync now, remove) live on the Locations
|
if current {
|
||||||
/// page. They are local paths, and Locations is the one page that owns those;
|
let l = gtk4::Label::builder()
|
||||||
/// two lists of the same folders would be two places to flip the same switch.
|
.label(label)
|
||||||
pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||||
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
|
.build();
|
||||||
ui.devices.sync_group.remove(&row);
|
l.add_css_class("heading");
|
||||||
|
return l.upcast();
|
||||||
|
}
|
||||||
|
let button = gtk4::Button::builder().label(label).build();
|
||||||
|
button.add_css_class("flat");
|
||||||
|
let ui = ui.clone();
|
||||||
|
button.connect_clicked(move |_| {
|
||||||
|
ui.devices.nav.borrow_mut().truncate(depth);
|
||||||
|
load_devices(&ui);
|
||||||
|
});
|
||||||
|
button.upcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folders open in place; files download and open with the default app.
|
||||||
|
fn open_device_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
if entry.is_dir {
|
||||||
|
ui.devices
|
||||||
|
.nav
|
||||||
|
.borrow_mut()
|
||||||
|
.push((entry.uid.clone(), entry.name.clone()));
|
||||||
|
load_devices(ui);
|
||||||
|
} else {
|
||||||
|
open_shared_file(ui, &entry.uid, &entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_entry_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&pgettext("verb", "Open"), move || {
|
||||||
|
open_device_entry(&ui_c, &entry_c)
|
||||||
|
});
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_bulk_menu(_ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
menu.labelled_section(&ngettext_f(
|
||||||
|
"{n} selected",
|
||||||
|
"{n} selected",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_background_menu(ui: &Rc<Ui>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Select All"), move || {
|
||||||
|
ui_c.devices.files.selection.select_all();
|
||||||
|
});
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c));
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A device row's subtitle: its platform, and when it last backed up. A device
|
||||||
|
/// that never did says so — an unexplained missing date reads as a bug, and
|
||||||
|
/// "no backups yet" is the fact that tells the user this computer isn't backing
|
||||||
|
/// anything up.
|
||||||
|
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
|
||||||
|
let platform = platform_label(&dev.device_type);
|
||||||
|
match dev.last_sync {
|
||||||
|
// Translators: {platform} is the device's platform (such as "Linux"), {time} a relative time such as "5 minutes ago".
|
||||||
|
Some(secs) if secs > 0 => gettext_f(
|
||||||
|
"{platform} · last backup {time}",
|
||||||
|
&[("platform", platform), ("time", &dates::relative(secs))],
|
||||||
|
),
|
||||||
|
// Translators: {platform} is the device's platform, such as "Linux".
|
||||||
|
_ => gettext_f("{platform} · no backups yet", &[("platform", platform)]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The platform as its maker spells it. The API's names are enum variants.
|
||||||
|
fn platform_label(device_type: &str) -> &str {
|
||||||
|
match device_type {
|
||||||
|
"MacOs" => "macOS",
|
||||||
|
other => other,
|
||||||
}
|
}
|
||||||
let row = adw::ActionRow::builder()
|
|
||||||
.title(gettext("This computer"))
|
|
||||||
.subtitle(this_computer_subtitle(folders))
|
|
||||||
.build();
|
|
||||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
|
||||||
let manage = gtk4::Button::builder()
|
|
||||||
.label(gettext("Open Sync"))
|
|
||||||
.tooltip_text(gettext("Manage this computer's folders and mountpoint"))
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
manage.add_css_class("flat");
|
|
||||||
let ui_go = ui.clone();
|
|
||||||
manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations"));
|
|
||||||
row.add_suffix(&manage);
|
|
||||||
let ui_rs = ui.clone();
|
|
||||||
row.add_suffix(&more_menu_button(vec![(
|
|
||||||
&gettext("Restore Folders…"),
|
|
||||||
Box::new(move || prompt_restore_folders(&ui_rs, None)),
|
|
||||||
)]));
|
|
||||||
ui.devices.sync_group.add(&row);
|
|
||||||
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many folders this machine backs up, and whether any of them needs
|
/// How many folders this machine backs up, and whether any of them needs
|
||||||
/// attention — the one fact worth surfacing away from the folder list itself.
|
/// attention — the one fact worth surfacing away from the folder list itself.
|
||||||
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||||
if folders.is_empty() {
|
if folders.is_empty() {
|
||||||
return gettext("No folders backed up yet — add one on the Sync page.");
|
return gettext("No folders backed up yet");
|
||||||
}
|
}
|
||||||
let count = ngettext_f(
|
let count = ngettext_f(
|
||||||
"{n} folder backed up",
|
"{n} folder backed up",
|
||||||
@@ -391,11 +597,7 @@ pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
|||||||
.filter(|f| f.state == "error" || f.state == "conflict")
|
.filter(|f| f.state == "error" || f.state == "conflict")
|
||||||
.count();
|
.count();
|
||||||
match attention {
|
match attention {
|
||||||
// Translators: {folders} is "N folders backed up".
|
0 => count,
|
||||||
0 => gettext_f(
|
|
||||||
"{folders} · manage them on the Sync page",
|
|
||||||
&[("folders", &count)],
|
|
||||||
),
|
|
||||||
// Translators: {folders} is "N folders backed up"; {n} counts the folders with a problem.
|
// Translators: {folders} is "N folders backed up"; {n} counts the folders with a problem.
|
||||||
n => ngettext_f(
|
n => ngettext_f(
|
||||||
"{folders} · {n} needs attention",
|
"{folders} · {n} needs attention",
|
||||||
@@ -489,7 +691,7 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
|
|||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&ui,
|
&ui,
|
||||||
&gettext("Couldn't add folder"),
|
&gettext("Couldn't add folder"),
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -529,7 +731,7 @@ pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>, device: Option<(String, String
|
|||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&ui,
|
&ui,
|
||||||
&gettext("Couldn't list folders to restore"),
|
&gettext("Couldn't list folders to restore"),
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -577,7 +779,7 @@ fn show_restore_picker(
|
|||||||
.child(&group)
|
.child(&group)
|
||||||
.build();
|
.build();
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(gettext("Restore folders"))
|
.heading(gettext("Restore Folders"))
|
||||||
.body(restore_picker_body(
|
.body(restore_picker_body(
|
||||||
device.as_ref().map(|(_, n)| n.as_str()),
|
device.as_ref().map(|(_, n)| n.as_str()),
|
||||||
))
|
))
|
||||||
@@ -668,7 +870,7 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
|
|||||||
Ok(Ok(Response::Ok { .. })) => toast(
|
Ok(Ok(Response::Ok { .. })) => toast(
|
||||||
&ui,
|
&ui,
|
||||||
&if mode == "ondemand" {
|
&if mode == "ondemand" {
|
||||||
gettext("Folder will switch to on-demand")
|
gettext("Folder will switch to online only")
|
||||||
} else {
|
} else {
|
||||||
gettext("Folder will download and stay synced")
|
gettext("Folder will download and stay synced")
|
||||||
},
|
},
|
||||||
@@ -679,7 +881,7 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
|
|||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&ui,
|
&ui,
|
||||||
&gettext("Couldn't change mode"),
|
&gettext("Couldn't change mode"),
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
reload_sync_pages(&ui);
|
reload_sync_pages(&ui);
|
||||||
@@ -710,7 +912,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
|||||||
let body = if ondemand {
|
let body = if ondemand {
|
||||||
// Translators: {path} is a local folder path.
|
// Translators: {path} is a local folder path.
|
||||||
gettext_f(
|
gettext_f(
|
||||||
"Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton Drive, not on this disk, so the folder will be empty once it is unmounted. To keep a local copy, cancel, turn off On-demand, and wait for the download to finish before removing it.",
|
"Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.",
|
||||||
&[("path", path)],
|
&[("path", path)],
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -721,7 +923,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(gettext("Stop syncing folder"))
|
.heading(gettext("Stop Syncing Folder?"))
|
||||||
.body(body)
|
.body(body)
|
||||||
.build();
|
.build();
|
||||||
let group = adw::PreferencesGroup::new();
|
let group = adw::PreferencesGroup::new();
|
||||||
@@ -818,7 +1020,7 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
|||||||
let group = adw::PreferencesGroup::new();
|
let group = adw::PreferencesGroup::new();
|
||||||
group.add(&confirm);
|
group.add(&confirm);
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(gettext("Remove Computer"))
|
.heading(gettext("Remove Computer?"))
|
||||||
// Translators: {name} is the name of the computer being removed.
|
// Translators: {name} is the name of the computer being removed.
|
||||||
.body(gettext_f(
|
.body(gettext_f(
|
||||||
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is deleted along with it. The files on that computer itself are not touched — but this cannot be undone from here.",
|
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is deleted along with it. The files on that computer itself are not touched — but this cannot be undone from here.",
|
||||||
@@ -852,7 +1054,8 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
|||||||
dialog.present(win.as_ref());
|
dialog.present(win.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirm, then adopt another device as this machine's identity.
|
/// Confirm, then continue another computer's backup on this machine (adopt its
|
||||||
|
/// device).
|
||||||
///
|
///
|
||||||
/// Worth a confirmation rather than a plain click: adoption re-points this
|
/// Worth a confirmation rather than a plain click: adoption re-points this
|
||||||
/// machine's syncing at another computer's device folder, and the folders
|
/// machine's syncing at another computer's device folder, and the folders
|
||||||
@@ -862,20 +1065,22 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
|||||||
pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(gettext("Use this computer's identity"))
|
.heading(gettext("Continue This Backup Here?"))
|
||||||
// Translators: {name} is the name of the other computer whose identity is adopted.
|
// Translators: {name} is the name of the other computer whose backup this one takes over.
|
||||||
.body(gettext_f(
|
.body(gettext_f(
|
||||||
"Treat this machine as “{name}”?\n\nNew synced folders are created under that computer in Proton Drive, and this machine keeps that identity even if its hostname changes. Folders already synced here are not moved.\n\nUse “Restore folders” afterwards to bring back what “{name}” was syncing.",
|
"This computer takes over the backup of “{name}”, for example after a reinstall.\n\nNew synced folders go under “{name}” in Proton Drive, even if this computer's name changes. Folders already synced here are not moved. Use “Restore to This Computer” afterwards to bring back what “{name}” was syncing.",
|
||||||
&[("name", name)],
|
&[("name", name)],
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("cancel", &gettext("Cancel"));
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
dialog.add_response("adopt", &gettext("Use identity"));
|
dialog.add_response("adopt", &gettext("Continue Here"));
|
||||||
dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested);
|
dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested);
|
||||||
dialog.set_default_response(Some("cancel"));
|
dialog.set_default_response(Some("cancel"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
let uid = uid.to_string();
|
let uid = uid.to_string();
|
||||||
|
// Translators: {name} is the name of the computer whose backup this one took over.
|
||||||
|
let done = gettext_f("Now backing up as “{name}”", &[("name", name)]);
|
||||||
dialog.connect_response(None, move |_, resp| {
|
dialog.connect_response(None, move |_, resp| {
|
||||||
if resp == "adopt" {
|
if resp == "adopt" {
|
||||||
run_devices_mutation(
|
run_devices_mutation(
|
||||||
@@ -883,8 +1088,8 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
|||||||
Request::AdoptDevice {
|
Request::AdoptDevice {
|
||||||
uid: Some(uid.clone()),
|
uid: Some(uid.clone()),
|
||||||
},
|
},
|
||||||
&gettext("Identity adopted"),
|
&done,
|
||||||
&gettext("Couldn't adopt that computer"),
|
&gettext("Couldn't continue that backup here"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -909,7 +1114,11 @@ pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed
|
|||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, &failed, &message, kind)
|
toast_failure(&ui, &failed, &message, kind)
|
||||||
}
|
}
|
||||||
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&failed,
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -933,20 +1142,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_machine_with_no_folders_is_told_where_to_add_one() {
|
fn a_machine_with_no_folders_says_so() {
|
||||||
assert_eq!(
|
assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet");
|
||||||
this_computer_subtitle(&[]),
|
|
||||||
"No folders backed up yet — add one on the Sync page."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn folders_needing_attention_are_counted_not_hidden() {
|
fn folders_needing_attention_are_counted_not_hidden() {
|
||||||
// The per-folder rows moved to Locations, so this row is the only place
|
// The per-folder rows live on the Sync page, so this row is the only
|
||||||
// the Computers page can surface that something is wrong.
|
// place the Computers page can surface that something is wrong.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
this_computer_subtitle(&[folder("idle"), folder("idle")]),
|
this_computer_subtitle(&[folder("idle"), folder("idle")]),
|
||||||
"2 folders backed up · manage them on the Sync page"
|
"2 folders backed up"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
this_computer_subtitle(&[folder("idle"), folder("error")]),
|
this_computer_subtitle(&[folder("idle"), folder("error")]),
|
||||||
@@ -964,4 +1170,28 @@ mod tests {
|
|||||||
assert!(restore_picker_body(None).contains("this computer"));
|
assert!(restore_picker_body(None).contains("this computer"));
|
||||||
assert!(nothing_to_restore(Some("laptop")).contains("“laptop”"));
|
assert!(nothing_to_restore(Some("laptop")).contains("“laptop”"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn device(device_type: &str, last_sync: Option<i64>) -> DeviceInfo {
|
||||||
|
DeviceInfo {
|
||||||
|
uid: "dev".into(),
|
||||||
|
name: "laptop".into(),
|
||||||
|
device_type: device_type.into(),
|
||||||
|
last_sync,
|
||||||
|
this_device: false,
|
||||||
|
adopted: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_computer_that_never_backed_up_says_no_backups_yet() {
|
||||||
|
assert_eq!(
|
||||||
|
device_subtitle(&device("Linux", None)),
|
||||||
|
"Linux · no backups yet"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
device_subtitle(&device("Linux", Some(0))),
|
||||||
|
"Linux · no backups yet"
|
||||||
|
);
|
||||||
|
assert!(device_subtitle(&device("MacOs", Some(1))).starts_with("macOS · last backup "));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,14 +6,58 @@ const COPY_SIZE: i32 = 150;
|
|||||||
/// Each set's copies with their Keep toggles.
|
/// Each set's copies with their Keep toggles.
|
||||||
type SetToggles = Vec<Vec<(PhotoItem, gtk4::ToggleButton)>>;
|
type SetToggles = Vec<Vec<(PhotoItem, gtk4::ToggleButton)>>;
|
||||||
|
|
||||||
|
/// How often the dialog asks again while the daemon hashes thumbnails.
|
||||||
|
const HASHING_POLL: std::time::Duration = std::time::Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// What the finder looks for.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Finder {
|
||||||
|
/// Byte-identical copies, going by the server's content hash.
|
||||||
|
Exact,
|
||||||
|
/// Shots that look alike, going by their thumbnails.
|
||||||
|
Similar,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dialog's parts that a search fills in.
|
||||||
|
struct FinderView {
|
||||||
|
ui: Rc<Ui>,
|
||||||
|
status: adw::StatusPage,
|
||||||
|
progress: gtk4::ProgressBar,
|
||||||
|
note: gtk4::Label,
|
||||||
|
stack: gtk4::Stack,
|
||||||
|
trash: gtk4::Button,
|
||||||
|
toolbar: adw::ToolbarView,
|
||||||
|
/// Bumped by every new search and when the dialog closes, so a reply or a
|
||||||
|
/// poll for an older one is dropped.
|
||||||
|
generation: Cell<u32>,
|
||||||
|
/// The Trash button's handler for the review on show.
|
||||||
|
clicked: RefCell<Option<glib::SignalHandlerId>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the duplicate finder: every set of photos stored more than once, side
|
/// Open the duplicate finder: every set of photos stored more than once, side
|
||||||
/// by side, with the copy worth keeping ticked. The copies left unticked go to
|
/// by side, with the copy worth keeping ticked. The copies left unticked go to
|
||||||
/// Trash — one file each, never the rest of their shot.
|
/// Trash — one file each, never the rest of their shot. The Similar switch
|
||||||
|
/// looks for shots that look alike instead.
|
||||||
pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
|
pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
|
||||||
|
let progress = gtk4::ProgressBar::builder()
|
||||||
|
.show_text(true)
|
||||||
|
.width_request(280)
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
let note = gtk4::Label::builder()
|
||||||
|
.wrap(true)
|
||||||
|
.justify(gtk4::Justification::Center)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
note.add_css_class("dim-label");
|
||||||
|
let status_extra = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
|
status_extra.append(&progress);
|
||||||
|
status_extra.append(¬e);
|
||||||
let status = adw::StatusPage::builder()
|
let status = adw::StatusPage::builder()
|
||||||
.icon_name("edit-copy-symbolic")
|
.icon_name("edit-copy-symbolic")
|
||||||
.title(gettext("Looking for duplicates…"))
|
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
|
.child(&status_extra)
|
||||||
.build();
|
.build();
|
||||||
status.add_css_class("compact");
|
status.add_css_class("compact");
|
||||||
|
|
||||||
@@ -21,7 +65,6 @@ pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
|
|||||||
stack.add_named(&status, Some("status"));
|
stack.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let trash = gtk4::Button::builder()
|
let trash = gtk4::Button::builder()
|
||||||
.label(gettext("Move to Trash"))
|
|
||||||
.halign(gtk4::Align::End)
|
.halign(gtk4::Align::End)
|
||||||
.sensitive(false)
|
.sensitive(false)
|
||||||
.build();
|
.build();
|
||||||
@@ -32,8 +75,22 @@ pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
|
|||||||
bottom.set_halign(gtk4::Align::End);
|
bottom.set_halign(gtk4::Align::End);
|
||||||
bottom.append(&trash);
|
bottom.append(&trash);
|
||||||
|
|
||||||
|
// Exact first, then Similar: the index is the finder.
|
||||||
|
let switch = ToggleGroup::new(&[
|
||||||
|
(
|
||||||
|
&pgettext("duplicates", "Exact"),
|
||||||
|
Some(&gettext("Photos stored more than once")),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&pgettext("duplicates", "Similar"),
|
||||||
|
Some(&gettext("Photos that look alike")),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let header = adw::HeaderBar::new();
|
||||||
|
header.set_title_widget(Some(&switch.widget()));
|
||||||
|
|
||||||
let toolbar = adw::ToolbarView::new();
|
let toolbar = adw::ToolbarView::new();
|
||||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
toolbar.add_top_bar(&header);
|
||||||
toolbar.set_content(Some(&stack));
|
toolbar.set_content(Some(&stack));
|
||||||
toolbar.add_bottom_bar(&bottom);
|
toolbar.add_bottom_bar(&bottom);
|
||||||
toolbar.set_reveal_bottom_bars(false);
|
toolbar.set_reveal_bottom_bars(false);
|
||||||
@@ -46,47 +103,189 @@ pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
|
|||||||
.build();
|
.build();
|
||||||
dialog.present(ui_window(ui).as_ref());
|
dialog.present(ui_window(ui).as_ref());
|
||||||
|
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoDuplicates);
|
let view = Rc::new(FinderView {
|
||||||
let ui = ui.clone();
|
ui: ui.clone(),
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
note,
|
||||||
|
stack,
|
||||||
|
trash,
|
||||||
|
toolbar,
|
||||||
|
generation: Cell::new(0),
|
||||||
|
clicked: RefCell::new(None),
|
||||||
|
});
|
||||||
|
let view_closed = view.clone();
|
||||||
|
dialog.connect_closed(move |_| {
|
||||||
|
view_closed
|
||||||
|
.generation
|
||||||
|
.set(view_closed.generation.get().wrapping_add(1));
|
||||||
|
});
|
||||||
|
let view_switch = view.clone();
|
||||||
|
switch.connect_changed(move |index| {
|
||||||
|
let finder = if index == 1 {
|
||||||
|
Finder::Similar
|
||||||
|
} else {
|
||||||
|
Finder::Exact
|
||||||
|
};
|
||||||
|
search(&view_switch, finder);
|
||||||
|
});
|
||||||
|
search(&view, Finder::Exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop what the dialog shows and look again, for `finder`.
|
||||||
|
fn search(view: &Rc<FinderView>, finder: Finder) {
|
||||||
|
let generation = view.generation.get().wrapping_add(1);
|
||||||
|
view.generation.set(generation);
|
||||||
|
if let Some(review) = view.stack.child_by_name("review") {
|
||||||
|
view.stack.remove(&review);
|
||||||
|
}
|
||||||
|
if let Some(handler) = view.clicked.take() {
|
||||||
|
view.trash.disconnect(handler);
|
||||||
|
}
|
||||||
|
view.toolbar.set_reveal_bottom_bars(false);
|
||||||
|
view.status.set_icon_name(Some("edit-copy-symbolic"));
|
||||||
|
view.status.set_title(&match finder {
|
||||||
|
Finder::Exact => gettext("Looking for duplicates…"),
|
||||||
|
Finder::Similar => gettext("Looking for similar photos…"),
|
||||||
|
});
|
||||||
|
view.status.set_description(None);
|
||||||
|
view.progress.set_visible(false);
|
||||||
|
view.note.set_visible(false);
|
||||||
|
view.stack.set_visible_child_name("status");
|
||||||
|
ask(view, finder, generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the daemon for `finder`'s sets and show them, or, while it is still
|
||||||
|
/// hashing thumbnails, how far it has got — and ask again in a moment.
|
||||||
|
fn ask(view: &Rc<FinderView>, finder: Finder, generation: u32) {
|
||||||
|
let request = match finder {
|
||||||
|
Finder::Exact => Request::PhotoDuplicates,
|
||||||
|
Finder::Similar => Request::PhotoSimilar,
|
||||||
|
};
|
||||||
|
let rx = spawn_request(view.ui.dirs.control_socket(), request);
|
||||||
|
let view = view.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let sets = match rx.recv().await {
|
let reply = rx.recv().await;
|
||||||
Ok(Ok(Response::PhotoDuplicates { sets })) => sets,
|
if view.generation.get() != generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = match finder {
|
||||||
|
Finder::Exact => gettext("Couldn't look for duplicates"),
|
||||||
|
Finder::Similar => gettext("Couldn't look for similar photos"),
|
||||||
|
};
|
||||||
|
let (sets, uncompared) = match reply {
|
||||||
|
Ok(Ok(Response::PhotoDuplicates { sets })) => (sets, 0),
|
||||||
|
Ok(Ok(Response::PhotoSimilar {
|
||||||
|
hashing: Some(MappingProgress { done, total }),
|
||||||
|
..
|
||||||
|
})) => {
|
||||||
|
view.status.set_title(&gettext("Comparing photos…"));
|
||||||
|
view.status.set_description(Some(&gettext(
|
||||||
|
"Photos are compared by their thumbnails. The first time takes a while.",
|
||||||
|
)));
|
||||||
|
view.progress.set_fraction(if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
done as f64 / total as f64
|
||||||
|
});
|
||||||
|
view.progress.set_text(Some(&gettext_f(
|
||||||
|
// Translators: {done} and {total} are numbers of photos.
|
||||||
|
"{done} of {total}",
|
||||||
|
&[("done", &thousands(done)), ("total", &thousands(total))],
|
||||||
|
)));
|
||||||
|
view.progress.set_visible(true);
|
||||||
|
glib::timeout_add_local_once(HASHING_POLL, move || {
|
||||||
|
if view.generation.get() == generation {
|
||||||
|
ask(&view, finder, generation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::PhotoSimilar {
|
||||||
|
sets,
|
||||||
|
hashing: None,
|
||||||
|
uncompared,
|
||||||
|
})) => (sets, uncompared),
|
||||||
Ok(Ok(Response::Error { message, .. })) => {
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
status.set_icon_name(Some("dialog-warning-symbolic"));
|
view.status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||||
status.set_title(&gettext("Couldn't look for duplicates"));
|
view.status.set_title(&failed);
|
||||||
status.set_description(Some(&message));
|
view.status.set_description(Some(&message));
|
||||||
|
view.progress.set_visible(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
status.set_icon_name(Some("dialog-warning-symbolic"));
|
view.status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||||
status.set_title(&gettext("Couldn't look for duplicates"));
|
view.status.set_title(&failed);
|
||||||
status.set_description(Some(&gettext("The mount service didn't respond.")));
|
view.status
|
||||||
|
.set_description(Some(&gettext("The Proton Drive service didn't respond.")));
|
||||||
|
view.progress.set_visible(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
view.progress.set_visible(false);
|
||||||
if sets.is_empty() {
|
if sets.is_empty() {
|
||||||
status.set_icon_name(Some("emblem-ok-symbolic"));
|
view.status.set_icon_name(Some("pdfs-synced-symbolic"));
|
||||||
status.set_title(&gettext("No duplicates"));
|
match finder {
|
||||||
status.set_description(Some(&gettext(
|
Finder::Exact => {
|
||||||
"Every photo in your library is stored once.",
|
view.status.set_title(&gettext("No duplicates"));
|
||||||
)));
|
view.status.set_description(Some(&gettext(
|
||||||
|
"Every photo in your library is stored once.",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Finder::Similar => {
|
||||||
|
view.status.set_title(&gettext("No similar photos"));
|
||||||
|
view.status.set_description(Some(&gettext(
|
||||||
|
"No two photos in your library look alike.",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if uncompared > 0 {
|
||||||
|
view.note.set_label(&uncompared_note(uncompared));
|
||||||
|
view.note.set_visible(true);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let review = duplicate_review(&ui, sets, &trash);
|
let (review, handler) = duplicate_review(&view.ui, sets, &view.trash, finder, uncompared);
|
||||||
stack.add_named(&review, Some("review"));
|
view.clicked.replace(Some(handler));
|
||||||
stack.set_visible_child_name("review");
|
view.stack.add_named(&review, Some("review"));
|
||||||
toolbar.set_reveal_bottom_bars(true);
|
view.stack.set_visible_child_name("review");
|
||||||
schedule_thumbs(&ui);
|
view.toolbar.set_reveal_bottom_bars(true);
|
||||||
|
schedule_thumbs(&view.ui);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The review list: one section per set, each copy a thumbnail with a Keep
|
/// Says how many photos the similar finder left out for want of a thumbnail.
|
||||||
/// toggle. Wires `trash` to move every copy not kept to Trash.
|
fn uncompared_note(n: usize) -> String {
|
||||||
fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: >k4::Button) -> gtk4::Widget {
|
ngettext_f(
|
||||||
|
"{n} photo couldn't be compared because it has no thumbnail yet.",
|
||||||
|
"{n} photos couldn't be compared because they have no thumbnail yet.",
|
||||||
|
n as u64,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The review list: one section per set, each photo a thumbnail with a Keep
|
||||||
|
/// toggle. Wires `trash` to move every photo not kept to Trash, and returns
|
||||||
|
/// that handler so a new search can take it off again.
|
||||||
|
///
|
||||||
|
/// Exact copies start with only the first of each set kept. Similar photos
|
||||||
|
/// all start kept: photos that look alike can still be different shots.
|
||||||
|
fn duplicate_review(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
sets: Vec<Vec<PhotoItem>>,
|
||||||
|
trash: >k4::Button,
|
||||||
|
finder: Finder,
|
||||||
|
uncompared: usize,
|
||||||
|
) -> (gtk4::Widget, glib::SignalHandlerId) {
|
||||||
let intro = gtk4::Label::builder()
|
let intro = gtk4::Label::builder()
|
||||||
.label(gettext(
|
.label(match finder {
|
||||||
"These photos are stored more than once. The copies you don't keep move to Trash, where you can restore them.",
|
Finder::Exact => gettext(
|
||||||
))
|
"These photos are stored more than once. The copies you don't keep move to Trash, where you can restore them.",
|
||||||
|
),
|
||||||
|
Finder::Similar => gettext(
|
||||||
|
"These photos look alike. They can be copies of one photo or different shots of one moment, so all of them are kept until you untick them. The photos you don't keep move to Trash, where you can restore them.",
|
||||||
|
),
|
||||||
|
})
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.xalign(0.0)
|
.xalign(0.0)
|
||||||
.build();
|
.build();
|
||||||
@@ -98,6 +297,15 @@ fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: >k4::Button
|
|||||||
content.set_margin_start(18);
|
content.set_margin_start(18);
|
||||||
content.set_margin_end(18);
|
content.set_margin_end(18);
|
||||||
content.append(&intro);
|
content.append(&intro);
|
||||||
|
if uncompared > 0 {
|
||||||
|
let note = gtk4::Label::builder()
|
||||||
|
.label(uncompared_note(uncompared))
|
||||||
|
.wrap(true)
|
||||||
|
.xalign(0.0)
|
||||||
|
.build();
|
||||||
|
note.add_css_class("dim-label");
|
||||||
|
content.append(¬e);
|
||||||
|
}
|
||||||
|
|
||||||
// Every copy's toggle, by set, so the button can count what goes.
|
// Every copy's toggle, by set, so the button can count what goes.
|
||||||
let toggles: Rc<RefCell<SetToggles>> = Rc::new(RefCell::new(Vec::new()));
|
let toggles: Rc<RefCell<SetToggles>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
@@ -106,19 +314,28 @@ fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: >k4::Button
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let date = section_heading(first.capture_time, Grouping::Day);
|
let date = section_heading(first.capture_time, Grouping::Day);
|
||||||
// Translators: heading of one set in the duplicates dialog; {date} is the day the photo was taken.
|
let title = match finder {
|
||||||
let title = ngettext_f(
|
// Translators: heading of one set in the duplicates dialog; {date} is the day the photo was taken.
|
||||||
"{date}, {n} copy",
|
Finder::Exact => ngettext_f(
|
||||||
"{date}, {n} copies",
|
"{date}, {n} copy",
|
||||||
set.len() as u64,
|
"{date}, {n} copies",
|
||||||
&[("date", &date)],
|
set.len() as u64,
|
||||||
);
|
&[("date", &date)],
|
||||||
|
),
|
||||||
|
// Translators: heading of one set of photos that look alike; {date} is the day the first was taken.
|
||||||
|
Finder::Similar => ngettext_f(
|
||||||
|
"{date}, {n} photo",
|
||||||
|
"{date}, {n} photos",
|
||||||
|
set.len() as u64,
|
||||||
|
&[("date", &date)],
|
||||||
|
),
|
||||||
|
};
|
||||||
let heading = gtk4::Label::builder().label(title).xalign(0.0).build();
|
let heading = gtk4::Label::builder().label(title).xalign(0.0).build();
|
||||||
heading.add_css_class("heading");
|
heading.add_css_class("heading");
|
||||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||||
let mut set_toggles = Vec::new();
|
let mut set_toggles = Vec::new();
|
||||||
for (index, photo) in set.into_iter().enumerate() {
|
for (index, photo) in set.into_iter().enumerate() {
|
||||||
let (card, keep) = copy_card(ui, &photo, index == 0);
|
let (card, keep) = copy_card(ui, &photo, index == 0 || finder == Finder::Similar);
|
||||||
row.append(&card);
|
row.append(&card);
|
||||||
set_toggles.push((photo, keep));
|
set_toggles.push((photo, keep));
|
||||||
}
|
}
|
||||||
@@ -139,12 +356,20 @@ fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: >k4::Button
|
|||||||
let trash = trash.clone();
|
let trash = trash.clone();
|
||||||
move || {
|
move || {
|
||||||
let n = doomed(&toggles.borrow()).len();
|
let n = doomed(&toggles.borrow()).len();
|
||||||
trash.set_label(&ngettext_f(
|
trash.set_label(&match finder {
|
||||||
"Move {n} copy to Trash",
|
Finder::Exact => ngettext_f(
|
||||||
"Move {n} copies to Trash",
|
"Move {n} copy to Trash",
|
||||||
n as u64,
|
"Move {n} copies to Trash",
|
||||||
&[],
|
n as u64,
|
||||||
));
|
&[],
|
||||||
|
),
|
||||||
|
Finder::Similar => ngettext_f(
|
||||||
|
"Move {n} photo to Trash",
|
||||||
|
"Move {n} photos to Trash",
|
||||||
|
n as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
});
|
||||||
trash.set_sensitive(n > 0);
|
trash.set_sensitive(n > 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -171,12 +396,14 @@ fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: >k4::Button
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ui_trash = ui.clone();
|
let ui_trash = ui.clone();
|
||||||
trash.connect_clicked(move |button| {
|
let handler = trash.connect_clicked(move |button| {
|
||||||
let uids = doomed(&toggles.borrow());
|
let uids = doomed(&toggles.borrow());
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
trash_photos(&ui_trash, uids, true);
|
// An exact copy is one file of its shot; a similar photo stands for
|
||||||
|
// its whole shot, raw and all.
|
||||||
|
trash_photos(&ui_trash, uids, finder == Finder::Exact);
|
||||||
if let Some(dialog) = button
|
if let Some(dialog) = button
|
||||||
.ancestor(adw::Dialog::static_type())
|
.ancestor(adw::Dialog::static_type())
|
||||||
.and_downcast::<adw::Dialog>()
|
.and_downcast::<adw::Dialog>()
|
||||||
@@ -190,7 +417,7 @@ fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: >k4::Button
|
|||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.child(&content)
|
.child(&content)
|
||||||
.build();
|
.build();
|
||||||
scroll.upcast()
|
(scroll.upcast(), handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The uids of every copy not kept.
|
/// The uids of every copy not kept.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub(crate) struct LoginState {
|
|||||||
pub(crate) password: adw::PasswordEntryRow,
|
pub(crate) password: adw::PasswordEntryRow,
|
||||||
pub(crate) login_button: gtk4::Button,
|
pub(crate) login_button: gtk4::Button,
|
||||||
/// Spins inside the Sign in button while a sign-in is running.
|
/// Spins inside the Sign in button while a sign-in is running.
|
||||||
pub(crate) login_spinner: gtk4::Spinner,
|
pub(crate) login_spinner: Spinner,
|
||||||
pub(crate) login_status: gtk4::Label,
|
pub(crate) login_status: gtk4::Label,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +34,8 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
|||||||
group.add(&email);
|
group.add(&email);
|
||||||
group.add(&password);
|
group.add(&password);
|
||||||
|
|
||||||
let login_spinner = gtk4::Spinner::builder().visible(false).build();
|
let login_spinner = spinner();
|
||||||
|
login_spinner.set_visible(false);
|
||||||
let button_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
let button_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
button_content.append(&login_spinner);
|
button_content.append(&login_spinner);
|
||||||
button_content.append(>k4::Label::new(Some(&gettext("Sign in"))));
|
button_content.append(>k4::Label::new(Some(&gettext("Sign in"))));
|
||||||
@@ -170,7 +171,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
// Cache the new identity so `refresh` never hits the keyring.
|
// Cache the new identity so `refresh` never hits the keyring.
|
||||||
*ui.session.borrow_mut() = auth::load().ok();
|
*ui.session.borrow_mut() = auth::load().ok();
|
||||||
// Enable+start the mount service now that we have a session.
|
// Enable+start the mount service now that we have a session.
|
||||||
service::enable_start();
|
let _ = gio::spawn_blocking(service::enable_start).await;
|
||||||
refresh(&ui);
|
refresh(&ui);
|
||||||
}
|
}
|
||||||
Err(e) => ui.login.login_status.set_text(&e),
|
Err(e) => ui.login.login_status.set_text(&e),
|
||||||
@@ -185,7 +186,6 @@ fn set_signing_in(ui: &Rc<Ui>, running: bool) {
|
|||||||
ui.login.email.set_sensitive(!running);
|
ui.login.email.set_sensitive(!running);
|
||||||
ui.login.password.set_sensitive(!running);
|
ui.login.password.set_sensitive(!running);
|
||||||
ui.login.login_spinner.set_visible(running);
|
ui.login.login_spinner.set_visible(running);
|
||||||
ui.login.login_spinner.set_spinning(running);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What to tell the person when sign-in fails. Proton's own API messages are
|
/// What to tell the person when sign-in fails. Proton's own API messages are
|
||||||
@@ -295,7 +295,7 @@ pub(crate) fn spawn_login(
|
|||||||
/// the worker reads as a cancelled login.
|
/// the worker reads as a cancelled login.
|
||||||
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(gettext("Two-factor authentication"))
|
.heading(gettext("Two-Factor Authentication"))
|
||||||
.body(gettext(
|
.body(gettext(
|
||||||
"Enter the code from your authenticator app, or one of your recovery codes.",
|
"Enter the code from your authenticator app, or one of your recovery codes.",
|
||||||
))
|
))
|
||||||
@@ -344,12 +344,20 @@ pub(crate) fn sign_out(ui: &Rc<Ui>) {
|
|||||||
),
|
),
|
||||||
&gettext("Sign Out"),
|
&gettext("Sign Out"),
|
||||||
move || {
|
move || {
|
||||||
service::disable_stop();
|
// systemctl and the keyring both block, so run them on a worker.
|
||||||
if let Err(e) = auth::logout() {
|
let ui = ui.clone();
|
||||||
tracing::error!("logout failed: {e}");
|
glib::spawn_future_local(async move {
|
||||||
}
|
let result = gio::spawn_blocking(|| {
|
||||||
*ui.session.borrow_mut() = None;
|
service::disable_stop();
|
||||||
refresh(&ui);
|
auth::logout().map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if let Ok(Err(e)) = result {
|
||||||
|
tracing::error!("logout failed: {e}");
|
||||||
|
}
|
||||||
|
*ui.session.borrow_mut() = None;
|
||||||
|
refresh(&ui);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,877 @@
|
|||||||
|
//! The online street map under the Places map: vector tiles from OpenFreeMap,
|
||||||
|
//! fetched and drawn on a worker thread, and handed to the map as finished
|
||||||
|
//! images.
|
||||||
|
//!
|
||||||
|
//! It is off unless turned on in Preferences, since every tile fetched tells
|
||||||
|
//! OpenFreeMap which part of the world is being looked at. OpenFreeMap needs
|
||||||
|
//! no account or key, and its tiles are OpenStreetMap data in the
|
||||||
|
//! OpenMapTiles schema; both have to be credited wherever the map shows.
|
||||||
|
//!
|
||||||
|
//! Tiles are kept on disk under the cache directory, per build of the tile
|
||||||
|
//! set. A build never changes, so a cached tile is good until OpenFreeMap
|
||||||
|
//! publishes the next one, and then the old build's directory goes. Town and
|
||||||
|
//! country names are not baked into the images: they come back as [`Label`]s
|
||||||
|
//! for the map to set on top, where it can keep them upright, sharp and apart
|
||||||
|
//! from each other.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gtk4::cairo;
|
||||||
|
|
||||||
|
use super::mvt::{self, Kind, Layer};
|
||||||
|
|
||||||
|
/// Where OpenFreeMap describes its current tile set.
|
||||||
|
const TILEJSON_URL: &str = "https://tiles.openfreemap.org/planet";
|
||||||
|
|
||||||
|
/// The deepest zoom OpenFreeMap has tiles for. Closer in, a tile is drawn
|
||||||
|
/// from part of its ancestor at this zoom.
|
||||||
|
pub(crate) const DATA_MAX_ZOOM: u8 = 14;
|
||||||
|
|
||||||
|
/// A tile's edge in logical px at the zoom it was made for.
|
||||||
|
pub(crate) const TILE_EDGE: f64 = 256.0;
|
||||||
|
|
||||||
|
/// How many tiles are fetched at once.
|
||||||
|
const PARALLEL_FETCHES: usize = 6;
|
||||||
|
|
||||||
|
/// How long one fetch may take.
|
||||||
|
const FETCH_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
/// Past this size the tile cache is emptied rather than left to grow.
|
||||||
|
const DISK_BUDGET: u64 = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// One tile of the Web Mercator grid: at zoom `z` the world is `2^z` tiles
|
||||||
|
/// wide and tall, counted from the north-west.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub(crate) struct TileKey {
|
||||||
|
pub(crate) z: u8,
|
||||||
|
pub(crate) x: u32,
|
||||||
|
pub(crate) y: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TileKey {
|
||||||
|
pub(crate) fn parent(self) -> Option<Self> {
|
||||||
|
(self.z > 0).then(|| Self {
|
||||||
|
z: self.z - 1,
|
||||||
|
x: self.x / 2,
|
||||||
|
y: self.y / 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tile whose data this one is drawn from.
|
||||||
|
fn data(self) -> Self {
|
||||||
|
let mut key = self;
|
||||||
|
while key.z > DATA_MAX_ZOOM {
|
||||||
|
key = key.parent().unwrap_or(key);
|
||||||
|
}
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colours of the map, for a light or a dark window.
|
||||||
|
pub(crate) struct Palette {
|
||||||
|
pub(crate) land: u32,
|
||||||
|
pub(crate) water: u32,
|
||||||
|
wood: u32,
|
||||||
|
grass: u32,
|
||||||
|
ice: u32,
|
||||||
|
sand: u32,
|
||||||
|
residential: u32,
|
||||||
|
industrial: u32,
|
||||||
|
building: u32,
|
||||||
|
motorway: u32,
|
||||||
|
primary: u32,
|
||||||
|
road: u32,
|
||||||
|
casing: u32,
|
||||||
|
rail: u32,
|
||||||
|
path: u32,
|
||||||
|
pub(crate) border: u32,
|
||||||
|
state_border: u32,
|
||||||
|
pub(crate) coast: u32,
|
||||||
|
pub(crate) text: u32,
|
||||||
|
pub(crate) halo: u32,
|
||||||
|
pub(crate) water_text: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const LIGHT: Palette = Palette {
|
||||||
|
land: 0xf4f2ee,
|
||||||
|
water: 0xb3cde3,
|
||||||
|
wood: 0xd3e5c5,
|
||||||
|
grass: 0xdcebcf,
|
||||||
|
ice: 0xf2f6f9,
|
||||||
|
sand: 0xefe7d2,
|
||||||
|
residential: 0xebe7e2,
|
||||||
|
industrial: 0xebe4e8,
|
||||||
|
building: 0xdcd5ce,
|
||||||
|
motorway: 0xf2c98f,
|
||||||
|
primary: 0xfbe4ad,
|
||||||
|
road: 0xffffff,
|
||||||
|
casing: 0xd2cbc1,
|
||||||
|
rail: 0xb5afa8,
|
||||||
|
path: 0xbfae9c,
|
||||||
|
border: 0x9d94b5,
|
||||||
|
state_border: 0xc2bbd2,
|
||||||
|
coast: 0x9fbbd4,
|
||||||
|
text: 0x34313d,
|
||||||
|
halo: 0xffffff,
|
||||||
|
water_text: 0x4f7398,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) const DARK: Palette = Palette {
|
||||||
|
land: 0x24222d,
|
||||||
|
water: 0x141c2b,
|
||||||
|
wood: 0x212c26,
|
||||||
|
grass: 0x232d27,
|
||||||
|
ice: 0x2c313a,
|
||||||
|
sand: 0x2d2b27,
|
||||||
|
residential: 0x292733,
|
||||||
|
industrial: 0x2b2833,
|
||||||
|
building: 0x322f3c,
|
||||||
|
motorway: 0x7a6647,
|
||||||
|
primary: 0x625a4b,
|
||||||
|
road: 0x46434f,
|
||||||
|
casing: 0x1a1822,
|
||||||
|
rail: 0x4e4a5a,
|
||||||
|
path: 0x3f3b49,
|
||||||
|
border: 0x857ca0,
|
||||||
|
state_border: 0x5c556f,
|
||||||
|
coast: 0x2c3b52,
|
||||||
|
text: 0xe7e4ef,
|
||||||
|
halo: 0x16141c,
|
||||||
|
water_text: 0x86a6cb,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) fn palette(dark: bool) -> &'static Palette {
|
||||||
|
if dark { &DARK } else { &LIGHT }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_rgb(cr: &cairo::Context, color: u32, alpha: f64) {
|
||||||
|
cr.set_source_rgba(
|
||||||
|
f64::from((color >> 16) & 0xff) / 255.0,
|
||||||
|
f64::from((color >> 8) & 0xff) / 255.0,
|
||||||
|
f64::from(color & 0xff) / 255.0,
|
||||||
|
alpha,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What kind of place a name stands for, which sets how it is written and
|
||||||
|
/// which names win when they would overlap.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub(crate) enum LabelClass {
|
||||||
|
Country,
|
||||||
|
State,
|
||||||
|
City,
|
||||||
|
Town,
|
||||||
|
Village,
|
||||||
|
Suburb,
|
||||||
|
Hamlet,
|
||||||
|
Water,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LabelClass {
|
||||||
|
fn from_place(class: &str) -> Option<Self> {
|
||||||
|
Some(match class {
|
||||||
|
"country" => Self::Country,
|
||||||
|
"state" | "province" => Self::State,
|
||||||
|
"city" => Self::City,
|
||||||
|
"town" => Self::Town,
|
||||||
|
"village" => Self::Village,
|
||||||
|
"suburb" | "quarter" => Self::Suburb,
|
||||||
|
"hamlet" | "neighbourhood" | "isolated_dwelling" => Self::Hamlet,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The zoom from which such names are shown at all.
|
||||||
|
pub(crate) fn min_zoom(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Country | Self::Water | Self::City => 0,
|
||||||
|
Self::State => 4,
|
||||||
|
Self::Town => 8,
|
||||||
|
Self::Village => 11,
|
||||||
|
Self::Suburb => 12,
|
||||||
|
Self::Hamlet => 14,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The zoom past which such names are left out again: a country's name
|
||||||
|
/// is no help once its towns are.
|
||||||
|
pub(crate) fn max_zoom(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Country => 7,
|
||||||
|
Self::State => 9,
|
||||||
|
_ => u8::MAX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name to set on the map, at its place in world space (0..1 both ways).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct Label {
|
||||||
|
pub(crate) x: f64,
|
||||||
|
pub(crate) y: f64,
|
||||||
|
pub(crate) text: String,
|
||||||
|
pub(crate) class: LabelClass,
|
||||||
|
/// Lower is more important, within a class.
|
||||||
|
pub(crate) rank: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A finished tile: premultiplied ARGB pixels, as cairo keeps them.
|
||||||
|
pub(crate) struct Raster {
|
||||||
|
pub(crate) key: TileKey,
|
||||||
|
pub(crate) dark: bool,
|
||||||
|
pub(crate) edge: i32,
|
||||||
|
pub(crate) pixels: Vec<u8>,
|
||||||
|
pub(crate) labels: Vec<Label>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Raster {
|
||||||
|
pub(crate) fn surface(self) -> Option<cairo::ImageSurface> {
|
||||||
|
let stride = self.edge * 4;
|
||||||
|
cairo::ImageSurface::create_for_data(
|
||||||
|
self.pixels,
|
||||||
|
cairo::Format::ARgb32,
|
||||||
|
self.edge,
|
||||||
|
self.edge,
|
||||||
|
stride,
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum Done {
|
||||||
|
Tile(Raster),
|
||||||
|
/// Nothing came: no network, or no such tile. The outline shows there.
|
||||||
|
Failed(TileKey),
|
||||||
|
/// The tile went off screen before its turn.
|
||||||
|
Dropped(TileKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Job {
|
||||||
|
key: TileKey,
|
||||||
|
dark: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The worker thread making tiles. Dropping it stops the thread once the
|
||||||
|
/// fetches under way are done.
|
||||||
|
pub(crate) struct Tiles {
|
||||||
|
jobs: async_channel::Sender<Job>,
|
||||||
|
wanted: Arc<Mutex<HashSet<TileKey>>>,
|
||||||
|
/// Device pixels per logical px the tiles are made at.
|
||||||
|
pub(crate) scale: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tiles {
|
||||||
|
/// Start the worker, caching under `cache_dir`, drawing names in
|
||||||
|
/// `language` where the map has them. Finished tiles arrive on the
|
||||||
|
/// returned channel.
|
||||||
|
pub(crate) fn start(
|
||||||
|
cache_dir: PathBuf,
|
||||||
|
language: String,
|
||||||
|
scale: i32,
|
||||||
|
) -> (Self, async_channel::Receiver<Done>) {
|
||||||
|
let (jobs, job_rx) = async_channel::unbounded();
|
||||||
|
let (done_tx, done_rx) = async_channel::unbounded();
|
||||||
|
let wanted = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let worker = Worker {
|
||||||
|
dir: cache_dir,
|
||||||
|
language,
|
||||||
|
scale,
|
||||||
|
wanted: wanted.clone(),
|
||||||
|
done: done_tx,
|
||||||
|
};
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("map-tiles".into())
|
||||||
|
.spawn(move || worker.run(job_rx));
|
||||||
|
if let Err(e) = spawned {
|
||||||
|
tracing::warn!("map tiles: cannot start the worker: {e}");
|
||||||
|
}
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
jobs,
|
||||||
|
wanted,
|
||||||
|
scale,
|
||||||
|
},
|
||||||
|
done_rx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn request(&self, key: TileKey, dark: bool) {
|
||||||
|
let _ = self.jobs.try_send(Job { key, dark });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tiles on screen now. A queued tile that isn't is skipped.
|
||||||
|
pub(crate) fn set_wanted(&self, keys: HashSet<TileKey>) {
|
||||||
|
if let Ok(mut wanted) = self.wanted.lock() {
|
||||||
|
*wanted = keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Worker {
|
||||||
|
dir: PathBuf,
|
||||||
|
language: String,
|
||||||
|
scale: i32,
|
||||||
|
wanted: Arc<Mutex<HashSet<TileKey>>>,
|
||||||
|
done: async_channel::Sender<Done>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Worker {
|
||||||
|
fn run(self, jobs: async_channel::Receiver<Job>) {
|
||||||
|
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(runtime) => runtime,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("map tiles: no runtime: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.user_agent(concat!(
|
||||||
|
"proton-drive-linux/",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
" (",
|
||||||
|
env!("CARGO_PKG_REPOSITORY"),
|
||||||
|
")"
|
||||||
|
))
|
||||||
|
.timeout(FETCH_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("map tiles: no HTTP client: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let worker = Arc::new(self);
|
||||||
|
runtime.block_on(async move {
|
||||||
|
let source = Arc::new(tokio::sync::Mutex::new(None::<String>));
|
||||||
|
let slots = Arc::new(tokio::sync::Semaphore::new(PARALLEL_FETCHES));
|
||||||
|
while let Ok(job) = jobs.recv().await {
|
||||||
|
let Ok(permit) = slots.clone().acquire_owned().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let (worker, client, source) = (worker.clone(), client.clone(), source.clone());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let done = worker.make(&client, &source, job).await;
|
||||||
|
let _ = worker.done.send(done).await;
|
||||||
|
drop(permit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Let the tiles under way finish before the runtime goes.
|
||||||
|
let _ = slots.acquire_many(PARALLEL_FETCHES as u32).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn make(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
client: &reqwest::Client,
|
||||||
|
source: &tokio::sync::Mutex<Option<String>>,
|
||||||
|
job: Job,
|
||||||
|
) -> Done {
|
||||||
|
let key = job.key;
|
||||||
|
let still_wanted = self.wanted.lock().map(|w| w.contains(&key)).unwrap_or(true);
|
||||||
|
if !still_wanted {
|
||||||
|
return Done::Dropped(key);
|
||||||
|
}
|
||||||
|
let Some(data) = self.load(client, source, key.data()).await else {
|
||||||
|
return Done::Failed(key);
|
||||||
|
};
|
||||||
|
let worker = self.clone();
|
||||||
|
let made = tokio::task::spawn_blocking(move || {
|
||||||
|
let layers = mvt::decode(&data)?;
|
||||||
|
render(&layers, key, job.dark, worker.scale, &worker.language)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match made {
|
||||||
|
Ok(Some(raster)) => Done::Tile(raster),
|
||||||
|
_ => Done::Failed(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tile's data, from the disk cache or else from OpenFreeMap.
|
||||||
|
async fn load(
|
||||||
|
&self,
|
||||||
|
client: &reqwest::Client,
|
||||||
|
source: &tokio::sync::Mutex<Option<String>>,
|
||||||
|
key: TileKey,
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
|
let template = {
|
||||||
|
let mut source = source.lock().await;
|
||||||
|
if source.is_none() {
|
||||||
|
*source = self.tile_source(client).await;
|
||||||
|
}
|
||||||
|
source.clone()?
|
||||||
|
};
|
||||||
|
let path = self
|
||||||
|
.build_dir(&template)?
|
||||||
|
.join(key.z.to_string())
|
||||||
|
.join(key.x.to_string())
|
||||||
|
.join(format!("{}.pbf", key.y));
|
||||||
|
if let Ok(data) = tokio::fs::read(&path).await {
|
||||||
|
return Some(data);
|
||||||
|
}
|
||||||
|
let url = template
|
||||||
|
.replace("{z}", &key.z.to_string())
|
||||||
|
.replace("{x}", &key.x.to_string())
|
||||||
|
.replace("{y}", &key.y.to_string());
|
||||||
|
let reply = client.get(&url).send().await.ok()?;
|
||||||
|
if !reply.status().is_success() {
|
||||||
|
tracing::debug!("map tile {url}: {}", reply.status());
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let data = reply.bytes().await.ok()?.to_vec();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = tokio::fs::create_dir_all(parent).await;
|
||||||
|
}
|
||||||
|
let _ = tokio::fs::write(&path, &data).await;
|
||||||
|
Some(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL template of OpenFreeMap's current build, remembered on disk so
|
||||||
|
/// the cached tiles still show without a network.
|
||||||
|
async fn tile_source(&self, client: &reqwest::Client) -> Option<String> {
|
||||||
|
let saved = self.dir.join("tiles.json");
|
||||||
|
let fetched = async {
|
||||||
|
let reply = client.get(TILEJSON_URL).send().await.ok()?;
|
||||||
|
if !reply.status().is_success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
reply.bytes().await.ok()
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let (json, fresh) = match fetched {
|
||||||
|
Some(json) => (json.to_vec(), true),
|
||||||
|
None => (tokio::fs::read(&saved).await.ok()?, false),
|
||||||
|
};
|
||||||
|
let template = tile_template(&json)?;
|
||||||
|
if fresh {
|
||||||
|
let _ = tokio::fs::create_dir_all(&self.dir).await;
|
||||||
|
let _ = tokio::fs::write(&saved, &json).await;
|
||||||
|
let (dir, build) = (self.dir.clone(), build_name(&template)?);
|
||||||
|
let _ = tokio::task::spawn_blocking(move || prune(&dir, &build)).await;
|
||||||
|
}
|
||||||
|
Some(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_dir(&self, template: &str) -> Option<PathBuf> {
|
||||||
|
Some(self.dir.join(build_name(template)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first tile URL template in a TileJSON document.
|
||||||
|
fn tile_template(json: &[u8]) -> Option<String> {
|
||||||
|
let doc: serde_json::Value = serde_json::from_slice(json).ok()?;
|
||||||
|
let template = doc.get("tiles")?.get(0)?.as_str()?;
|
||||||
|
template
|
||||||
|
.starts_with("https://")
|
||||||
|
.then(|| template.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tile set's build, the path segment before `/{z}`: a safe directory
|
||||||
|
/// name, or `None`.
|
||||||
|
fn build_name(template: &str) -> Option<String> {
|
||||||
|
let head = template.split("/{z}").next()?;
|
||||||
|
let name = head.rsplit('/').next()?;
|
||||||
|
let safe = !name.is_empty()
|
||||||
|
&& name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
|
||||||
|
&& name != "."
|
||||||
|
&& name != "..";
|
||||||
|
safe.then(|| name.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the cached builds other than `keep`, and `keep` itself once it
|
||||||
|
/// holds more than [`DISK_BUDGET`].
|
||||||
|
fn prune(dir: &Path, keep: &str) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stale = entry.file_name() != keep || dir_size(&path) > DISK_BUDGET;
|
||||||
|
if stale && let Err(e) = std::fs::remove_dir_all(&path) {
|
||||||
|
tracing::debug!("map tiles: cannot remove {}: {e}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dir_size(dir: &Path) -> u64 {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.map(|entry| match entry.metadata() {
|
||||||
|
Ok(meta) if meta.is_dir() => dir_size(&entry.path()),
|
||||||
|
Ok(meta) => meta.len(),
|
||||||
|
Err(_) => 0,
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw tile `key` from the layers of its data tile, at `scale` device px
|
||||||
|
/// per logical px, and gather the names on it.
|
||||||
|
fn render(
|
||||||
|
layers: &[Layer],
|
||||||
|
key: TileKey,
|
||||||
|
dark: bool,
|
||||||
|
scale: i32,
|
||||||
|
language: &str,
|
||||||
|
) -> Option<Raster> {
|
||||||
|
let edge = (TILE_EDGE as i32) * scale.max(1);
|
||||||
|
let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, edge, edge).ok()?;
|
||||||
|
let labels = {
|
||||||
|
let cr = cairo::Context::new(&surface).ok()?;
|
||||||
|
cr.scale(f64::from(scale), f64::from(scale));
|
||||||
|
let painter = Painter {
|
||||||
|
cr: &cr,
|
||||||
|
key,
|
||||||
|
data: key.data(),
|
||||||
|
colors: palette(dark),
|
||||||
|
};
|
||||||
|
painter.paint(layers);
|
||||||
|
gather_labels(layers, key.data(), language)
|
||||||
|
};
|
||||||
|
surface.flush();
|
||||||
|
let pixels = surface.take_data().ok()?.to_vec();
|
||||||
|
Some(Raster {
|
||||||
|
key,
|
||||||
|
dark,
|
||||||
|
edge,
|
||||||
|
pixels,
|
||||||
|
labels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Painter<'a> {
|
||||||
|
cr: &'a cairo::Context,
|
||||||
|
key: TileKey,
|
||||||
|
data: TileKey,
|
||||||
|
colors: &'static Palette,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Painter<'_> {
|
||||||
|
fn paint(&self, layers: &[Layer]) {
|
||||||
|
let c = self.colors;
|
||||||
|
let z = self.key.z;
|
||||||
|
set_rgb(self.cr, c.land, 1.0);
|
||||||
|
let _ = self.cr.paint();
|
||||||
|
|
||||||
|
let layer = |name: &str| layers.iter().find(|l| l.name == name);
|
||||||
|
if let Some(layer) = layer("landcover") {
|
||||||
|
self.fill(layer, |class| match class {
|
||||||
|
"wood" | "forest" => Some(c.wood),
|
||||||
|
"grass" | "wetland" => Some(c.grass),
|
||||||
|
"ice" | "glacier" => Some(c.ice),
|
||||||
|
"sand" | "beach" => Some(c.sand),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("landuse") {
|
||||||
|
self.fill(layer, |class| match class {
|
||||||
|
"residential" | "suburb" | "neighbourhood" => Some(c.residential),
|
||||||
|
"industrial" | "commercial" | "retail" | "railway" => Some(c.industrial),
|
||||||
|
"stadium" | "pitch" | "playground" | "cemetery" => Some(c.grass),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("park") {
|
||||||
|
self.fill(layer, |_| Some(c.grass));
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("water") {
|
||||||
|
self.fill(layer, |_| Some(c.water));
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("waterway") {
|
||||||
|
self.stroke(layer, |class| {
|
||||||
|
let (base, from) = match class {
|
||||||
|
"river" => (1.2, 8),
|
||||||
|
"canal" => (1.0, 12),
|
||||||
|
"stream" => (0.6, 13),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
(z >= from).then(|| (c.water, grow(base, z), false))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if z >= 13
|
||||||
|
&& let Some(layer) = layer("building")
|
||||||
|
{
|
||||||
|
self.fill(layer, |_| Some(c.building));
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("transportation") {
|
||||||
|
self.roads(layer);
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("boundary") {
|
||||||
|
self.borders(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map tile units of the data tile onto this tile's logical px.
|
||||||
|
fn to_px(&self, layer: &Layer, (u, v): (f64, f64)) -> (f64, f64) {
|
||||||
|
let k = f64::from(1u32 << (self.key.z - self.data.z));
|
||||||
|
let ox = f64::from(self.key.x) - f64::from(self.data.x) * k;
|
||||||
|
let oy = f64::from(self.key.y) - f64::from(self.data.y) * k;
|
||||||
|
(
|
||||||
|
(u / layer.extent * k - ox) * TILE_EDGE,
|
||||||
|
(v / layer.extent * k - oy) * TILE_EDGE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trace(&self, layer: &Layer, part: &[(f64, f64)], close: bool) {
|
||||||
|
for (i, &point) in part.iter().enumerate() {
|
||||||
|
let (x, y) = self.to_px(layer, point);
|
||||||
|
if i == 0 {
|
||||||
|
self.cr.move_to(x, y);
|
||||||
|
} else {
|
||||||
|
self.cr.line_to(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if close {
|
||||||
|
self.cr.close_path();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill the layer's polygons in the colour `color` picks by class.
|
||||||
|
fn fill(&self, layer: &Layer, color: impl Fn(&str) -> Option<u32>) {
|
||||||
|
self.cr.set_fill_rule(cairo::FillRule::EvenOdd);
|
||||||
|
for feature in &layer.features {
|
||||||
|
if feature.kind != Kind::Polygon {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let class = layer.text(feature, "class").unwrap_or("");
|
||||||
|
let Some(rgb) = color(class) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
self.cr.new_path();
|
||||||
|
for part in &feature.parts {
|
||||||
|
self.trace(layer, part, true);
|
||||||
|
}
|
||||||
|
set_rgb(self.cr, rgb, 1.0);
|
||||||
|
let _ = self.cr.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stroke the layer's lines as `style` says by class: colour, width in
|
||||||
|
/// px, and whether dashed.
|
||||||
|
fn stroke(&self, layer: &Layer, style: impl Fn(&str) -> Option<(u32, f64, bool)>) {
|
||||||
|
self.cr.set_line_cap(cairo::LineCap::Round);
|
||||||
|
self.cr.set_line_join(cairo::LineJoin::Round);
|
||||||
|
for feature in &layer.features {
|
||||||
|
if feature.kind != Kind::Line {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let class = layer.text(feature, "class").unwrap_or("");
|
||||||
|
let Some((rgb, width, dashed)) = style(class) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
self.cr.new_path();
|
||||||
|
for part in &feature.parts {
|
||||||
|
self.trace(layer, part, false);
|
||||||
|
}
|
||||||
|
set_rgb(self.cr, rgb, 1.0);
|
||||||
|
self.cr.set_line_width(width);
|
||||||
|
let dash = [width * 2.0, width * 2.0];
|
||||||
|
self.cr.set_dash(if dashed { &dash } else { &[] }, 0.0);
|
||||||
|
let _ = self.cr.stroke();
|
||||||
|
}
|
||||||
|
self.cr.set_dash(&[], 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roads, minor first so the major ones cross over them, each on a
|
||||||
|
/// darker casing once they are wide enough for one.
|
||||||
|
fn roads(&self, layer: &Layer) {
|
||||||
|
let c = self.colors;
|
||||||
|
let z = self.key.z;
|
||||||
|
let road = |class: &str| -> Option<(u32, f64, u8)> {
|
||||||
|
let (color, width, rank) = match class {
|
||||||
|
"motorway" if z >= 6 => (c.motorway, grow(2.2, z), 4),
|
||||||
|
"trunk" if z >= 7 => (c.motorway, grow(2.2, z), 4),
|
||||||
|
"primary" if z >= 8 => (c.primary, grow(1.8, z), 3),
|
||||||
|
"secondary" if z >= 10 => (c.road, grow(1.4, z), 2),
|
||||||
|
"tertiary" if z >= 11 => (c.road, grow(1.4, z), 2),
|
||||||
|
"minor" | "busway" if z >= 12 => (c.road, grow(1.0, z), 1),
|
||||||
|
"service" if z >= 14 => (c.road, grow(0.6, z), 0),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((color, width, rank))
|
||||||
|
};
|
||||||
|
for rank in 0..=4u8 {
|
||||||
|
let pick = |class: &str| road(class).filter(|r| r.2 == rank);
|
||||||
|
if z >= 12 {
|
||||||
|
self.stroke(layer, |class| {
|
||||||
|
pick(class).map(|(_, width, _)| (c.casing, width + 1.5, false))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.stroke(layer, |class| {
|
||||||
|
pick(class).map(|(color, width, _)| (color, width, false))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.stroke(layer, |class| match class {
|
||||||
|
"rail" | "transit" if z >= 10 => Some((c.rail, grow(0.7, z).min(2.5), false)),
|
||||||
|
"path" | "track" if z >= 14 => Some((c.path, 0.8, true)),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn borders(&self, layer: &Layer) {
|
||||||
|
let c = self.colors;
|
||||||
|
let z = self.key.z;
|
||||||
|
self.cr.set_line_cap(cairo::LineCap::Round);
|
||||||
|
for feature in &layer.features {
|
||||||
|
if feature.kind != Kind::Line || layer.number(feature, "maritime") == Some(1.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (color, width, dashed) = match layer.number(feature, "admin_level") {
|
||||||
|
Some(level) if level <= 2.0 => {
|
||||||
|
(c.border, (0.9 * 1.12f64.powi(i32::from(z))).min(2.5), false)
|
||||||
|
}
|
||||||
|
Some(level) if level <= 4.0 && z >= 5 => (c.state_border, 0.8, true),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let dashed = dashed || layer.number(feature, "disputed") == Some(1.0);
|
||||||
|
self.cr.new_path();
|
||||||
|
for part in &feature.parts {
|
||||||
|
self.trace(layer, part, false);
|
||||||
|
}
|
||||||
|
set_rgb(self.cr, color, 1.0);
|
||||||
|
self.cr.set_line_width(width);
|
||||||
|
self.cr
|
||||||
|
.set_dash(if dashed { &[3.0, 2.5] } else { &[] }, 0.0);
|
||||||
|
let _ = self.cr.stroke();
|
||||||
|
}
|
||||||
|
self.cr.set_dash(&[], 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line width that grows with the zoom, as the things it stands for get
|
||||||
|
/// bigger on screen.
|
||||||
|
fn grow(base: f64, z: u8) -> f64 {
|
||||||
|
(base * 1.4f64.powi(i32::from(z) - 10)).clamp(0.5, 24.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The names of towns, countries and seas on data tile `data`, in world
|
||||||
|
/// space. Names in the tile's buffer beyond its edge are left to the
|
||||||
|
/// neighbour they belong to.
|
||||||
|
fn gather_labels(layers: &[Layer], data: TileKey, language: &str) -> Vec<Label> {
|
||||||
|
let n = f64::from(1u32 << data.z);
|
||||||
|
let mut labels = Vec::new();
|
||||||
|
for layer in layers {
|
||||||
|
let water = match layer.name.as_str() {
|
||||||
|
"place" => false,
|
||||||
|
"water_name" => true,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
for feature in &layer.features {
|
||||||
|
if feature.kind != Kind::Point {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let class = if water {
|
||||||
|
LabelClass::Water
|
||||||
|
} else {
|
||||||
|
let Some(class) = layer
|
||||||
|
.text(feature, "class")
|
||||||
|
.and_then(LabelClass::from_place)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
class
|
||||||
|
};
|
||||||
|
let Some(text) = label_name(layer, feature, language) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let rank = layer.number(feature, "rank").unwrap_or(99.0);
|
||||||
|
for &(u, v) in feature.parts.iter().flatten() {
|
||||||
|
if !(0.0..layer.extent).contains(&u) || !(0.0..layer.extent).contains(&v) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
labels.push(Label {
|
||||||
|
x: (f64::from(data.x) + u / layer.extent) / n,
|
||||||
|
y: (f64::from(data.y) + v / layer.extent) / n,
|
||||||
|
text: text.clone(),
|
||||||
|
class,
|
||||||
|
rank,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labels
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A feature's name in `language` where the map has it, else in Latin
|
||||||
|
/// letters, else as written locally.
|
||||||
|
fn label_name(layer: &Layer, feature: &mvt::Feature, language: &str) -> Option<String> {
|
||||||
|
[
|
||||||
|
format!("name:{language}"),
|
||||||
|
format!("name_{language}"),
|
||||||
|
"name:latin".to_owned(),
|
||||||
|
"name".to_owned(),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| layer.text(feature, key).filter(|name| !name.is_empty()))
|
||||||
|
.map(|name| name.replace('\n', " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deep_tiles_draw_from_their_ancestor_at_the_data_zoom() {
|
||||||
|
let key = TileKey {
|
||||||
|
z: 16,
|
||||||
|
x: 34_336,
|
||||||
|
y: 22_437,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
key.data(),
|
||||||
|
TileKey {
|
||||||
|
z: 14,
|
||||||
|
x: 8_584,
|
||||||
|
y: 5_609
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let shallow = TileKey { z: 3, x: 4, y: 2 };
|
||||||
|
assert_eq!(shallow.data(), shallow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_build_comes_from_the_tile_template() {
|
||||||
|
let json = br#"{"tiles":["https://tiles.openfreemap.org/planet/20260913_164504_pt/{z}/{x}/{y}.pbf"]}"#;
|
||||||
|
let template = tile_template(json).unwrap();
|
||||||
|
assert_eq!(build_name(&template).as_deref(), Some("20260913_164504_pt"));
|
||||||
|
assert_eq!(build_name("https://example.org/../{z}/{x}/{y}.pbf"), None);
|
||||||
|
assert_eq!(
|
||||||
|
tile_template(br#"{"tiles":["http://plain.example/{z}"]}"#),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_old_build_is_pruned_and_the_current_one_kept() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pdfs-map-prune-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(dir.join("old/1/0")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("new/1/0")).unwrap();
|
||||||
|
std::fs::write(dir.join("new/1/0/0.pbf"), b"tile").unwrap();
|
||||||
|
std::fs::write(dir.join("tiles.json"), b"{}").unwrap();
|
||||||
|
prune(&dir, "new");
|
||||||
|
assert!(!dir.join("old").exists());
|
||||||
|
assert!(dir.join("new/1/0/0.pbf").exists());
|
||||||
|
assert!(dir.join("tiles.json").exists());
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roads_widen_as_the_map_zooms_in() {
|
||||||
|
assert!(grow(1.4, 14) > grow(1.4, 10));
|
||||||
|
assert_eq!(grow(2.2, 30), 24.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
pub(crate) mod activity;
|
pub(crate) mod activity;
|
||||||
|
pub(crate) mod activity_text;
|
||||||
pub(crate) mod albums;
|
pub(crate) mod albums;
|
||||||
pub(crate) mod browser;
|
pub(crate) mod browser;
|
||||||
pub(crate) mod devices;
|
pub(crate) mod devices;
|
||||||
pub(crate) mod duplicates;
|
pub(crate) mod duplicates;
|
||||||
pub(crate) mod locations;
|
pub(crate) mod locations;
|
||||||
pub(crate) mod login;
|
pub(crate) mod login;
|
||||||
|
pub(crate) mod map_tiles;
|
||||||
|
pub(crate) mod mvt;
|
||||||
pub(crate) mod photo_viewer;
|
pub(crate) mod photo_viewer;
|
||||||
pub(crate) mod photos;
|
pub(crate) mod photos;
|
||||||
pub(crate) mod places;
|
pub(crate) mod places;
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
//! A reader for Mapbox Vector Tiles, the format the online Places map's tiles
|
||||||
|
//! come in: protobuf, one message per tile, holding named layers of point,
|
||||||
|
//! line and polygon features with key/value tags.
|
||||||
|
//!
|
||||||
|
//! Only what drawing needs is read — layer names, tags and geometry — and a
|
||||||
|
//! tile that doesn't parse is `None` rather than an error: the map then shows
|
||||||
|
//! the outline underneath it. The format is at
|
||||||
|
//! <https://github.com/mapbox/vector-tile-spec>.
|
||||||
|
|
||||||
|
/// One layer of a tile, such as `water` or `transportation`.
|
||||||
|
pub(crate) struct Layer {
|
||||||
|
pub(crate) name: String,
|
||||||
|
/// The tile's width in geometry units; 4096 almost everywhere.
|
||||||
|
pub(crate) extent: f64,
|
||||||
|
keys: Vec<String>,
|
||||||
|
values: Vec<Value>,
|
||||||
|
pub(crate) features: Vec<Feature>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) enum Value {
|
||||||
|
Text(String),
|
||||||
|
Number(f64),
|
||||||
|
Bool(bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum Kind {
|
||||||
|
Point,
|
||||||
|
Line,
|
||||||
|
Polygon,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Feature {
|
||||||
|
pub(crate) kind: Kind,
|
||||||
|
/// Pairs of indices into the layer's keys and values.
|
||||||
|
tags: Vec<(u32, u32)>,
|
||||||
|
/// Points, lines or polygon rings, in tile units. A point feature holds
|
||||||
|
/// one part with all its points.
|
||||||
|
pub(crate) parts: Vec<Vec<(f64, f64)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layer {
|
||||||
|
/// The value `feature` carries for `key`.
|
||||||
|
pub(crate) fn tag(&self, feature: &Feature, key: &str) -> Option<&Value> {
|
||||||
|
feature.tags.iter().find_map(|&(k, v)| {
|
||||||
|
(self.keys.get(k as usize)? == key).then(|| self.values.get(v as usize))?
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn text(&self, feature: &Feature, key: &str) -> Option<&str> {
|
||||||
|
match self.tag(feature, key)? {
|
||||||
|
Value::Text(text) => Some(text),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn number(&self, feature: &Feature, key: &str) -> Option<f64> {
|
||||||
|
match self.tag(feature, key)? {
|
||||||
|
Value::Number(n) => Some(*n),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every layer of the tile in `data`.
|
||||||
|
pub(crate) fn decode(data: &[u8]) -> Option<Vec<Layer>> {
|
||||||
|
let mut tile = Reader::new(data);
|
||||||
|
let mut layers = Vec::new();
|
||||||
|
while let Some((field, wire)) = tile.field()? {
|
||||||
|
match field {
|
||||||
|
3 => layers.push(decode_layer(tile.bytes()?)?),
|
||||||
|
_ => tile.skip(wire)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(layers)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_layer(data: &[u8]) -> Option<Layer> {
|
||||||
|
let mut layer = Layer {
|
||||||
|
name: String::new(),
|
||||||
|
extent: 4096.0,
|
||||||
|
keys: Vec::new(),
|
||||||
|
values: Vec::new(),
|
||||||
|
features: Vec::new(),
|
||||||
|
};
|
||||||
|
let mut reader = Reader::new(data);
|
||||||
|
while let Some((field, wire)) = reader.field()? {
|
||||||
|
match field {
|
||||||
|
1 => layer.name = reader.text()?,
|
||||||
|
2 => {
|
||||||
|
if let Some(feature) = decode_feature(reader.bytes()?) {
|
||||||
|
layer.features.push(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => layer.keys.push(reader.text()?),
|
||||||
|
4 => layer.values.push(decode_value(reader.bytes()?)?),
|
||||||
|
5 => layer.extent = reader.varint()? as f64,
|
||||||
|
_ => reader.skip(wire)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(layer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_value(data: &[u8]) -> Option<Value> {
|
||||||
|
let mut reader = Reader::new(data);
|
||||||
|
let mut value = Value::Bool(false);
|
||||||
|
while let Some((field, wire)) = reader.field()? {
|
||||||
|
value = match field {
|
||||||
|
1 => Value::Text(reader.text()?),
|
||||||
|
2 => Value::Number(f64::from(f32::from_bits(reader.fixed32()?))),
|
||||||
|
3 => Value::Number(f64::from_bits(reader.fixed64()?)),
|
||||||
|
4 | 5 => Value::Number(reader.varint()? as i64 as f64),
|
||||||
|
6 => Value::Number(zigzag(reader.varint()?) as f64),
|
||||||
|
7 => Value::Bool(reader.varint()? != 0),
|
||||||
|
_ => {
|
||||||
|
reader.skip(wire)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A feature, or `None` for one this map can't draw (unknown geometry type,
|
||||||
|
/// or broken).
|
||||||
|
fn decode_feature(data: &[u8]) -> Option<Feature> {
|
||||||
|
let mut reader = Reader::new(data);
|
||||||
|
let mut kind = None;
|
||||||
|
let mut tags = Vec::new();
|
||||||
|
let mut geometry = Vec::new();
|
||||||
|
while let Some((field, wire)) = reader.field()? {
|
||||||
|
match field {
|
||||||
|
2 => {
|
||||||
|
let packed = reader.packed()?;
|
||||||
|
tags = packed
|
||||||
|
.as_chunks::<2>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|&[k, v]| (k as u32, v as u32))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
kind = match reader.varint()? {
|
||||||
|
1 => Some(Kind::Point),
|
||||||
|
2 => Some(Kind::Line),
|
||||||
|
3 => Some(Kind::Polygon),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
4 => geometry = reader.packed()?,
|
||||||
|
_ => reader.skip(wire)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let kind = kind?;
|
||||||
|
Some(Feature {
|
||||||
|
kind,
|
||||||
|
tags,
|
||||||
|
parts: decode_geometry(kind, &geometry)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn the command stream of a feature's geometry into its parts. Positions
|
||||||
|
/// are deltas from the previous one, carried across parts.
|
||||||
|
fn decode_geometry(kind: Kind, commands: &[u64]) -> Option<Vec<Vec<(f64, f64)>>> {
|
||||||
|
let mut parts: Vec<Vec<(f64, f64)>> = Vec::new();
|
||||||
|
let (mut x, mut y) = (0i64, 0i64);
|
||||||
|
let mut i = 0;
|
||||||
|
while i < commands.len() {
|
||||||
|
let command = commands[i] & 7;
|
||||||
|
let count = (commands[i] >> 3) as usize;
|
||||||
|
i += 1;
|
||||||
|
match command {
|
||||||
|
// MoveTo starts a part — except for points, which all share one.
|
||||||
|
1 | 2 => {
|
||||||
|
if i + 2 * count > commands.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for n in 0..count {
|
||||||
|
x += zigzag(commands[i + 2 * n]);
|
||||||
|
y += zigzag(commands[i + 2 * n + 1]);
|
||||||
|
let starts = command == 1 && (kind != Kind::Point || parts.is_empty());
|
||||||
|
if starts {
|
||||||
|
parts.push(Vec::new());
|
||||||
|
}
|
||||||
|
parts.last_mut()?.push((x as f64, y as f64));
|
||||||
|
}
|
||||||
|
i += 2 * count;
|
||||||
|
}
|
||||||
|
// ClosePath: the ring's end joins its start, which drawing does.
|
||||||
|
7 => {}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zigzag(n: u64) -> i64 {
|
||||||
|
((n >> 1) as i64) ^ -((n & 1) as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The protobuf wire format, as far as vector tiles use it.
|
||||||
|
struct Reader<'a> {
|
||||||
|
data: &'a [u8],
|
||||||
|
at: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Reader<'a> {
|
||||||
|
fn new(data: &'a [u8]) -> Self {
|
||||||
|
Self { data, at: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next field's number and wire type; `Some(None)` at the end, `None`
|
||||||
|
/// on a broken message.
|
||||||
|
fn field(&mut self) -> Option<Option<(u64, u8)>> {
|
||||||
|
if self.at >= self.data.len() {
|
||||||
|
return Some(None);
|
||||||
|
}
|
||||||
|
let key = self.varint()?;
|
||||||
|
Some(Some((key >> 3, (key & 7) as u8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn varint(&mut self) -> Option<u64> {
|
||||||
|
let mut value = 0u64;
|
||||||
|
for shift in (0..64).step_by(7) {
|
||||||
|
let byte = *self.data.get(self.at)?;
|
||||||
|
self.at += 1;
|
||||||
|
value |= u64::from(byte & 0x7f) << shift;
|
||||||
|
if byte & 0x80 == 0 {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
|
||||||
|
let end = self.at.checked_add(n)?;
|
||||||
|
let bytes = self.data.get(self.at..end)?;
|
||||||
|
self.at = end;
|
||||||
|
Some(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(&mut self) -> Option<&'a [u8]> {
|
||||||
|
let len = usize::try_from(self.varint()?).ok()?;
|
||||||
|
self.take(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(&mut self) -> Option<String> {
|
||||||
|
Some(String::from_utf8_lossy(self.bytes()?).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixed32(&mut self) -> Option<u32> {
|
||||||
|
Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixed64(&mut self) -> Option<u64> {
|
||||||
|
Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn packed(&mut self) -> Option<Vec<u64>> {
|
||||||
|
let mut inner = Reader::new(self.bytes()?);
|
||||||
|
let mut values = Vec::new();
|
||||||
|
while inner.at < inner.data.len() {
|
||||||
|
values.push(inner.varint()?);
|
||||||
|
}
|
||||||
|
Some(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip(&mut self, wire: u8) -> Option<()> {
|
||||||
|
match wire {
|
||||||
|
0 => {
|
||||||
|
self.varint()?;
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
self.take(8)?;
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
self.bytes()?;
|
||||||
|
}
|
||||||
|
5 => {
|
||||||
|
self.take(4)?;
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn varint(mut n: u64, out: &mut Vec<u8>) {
|
||||||
|
while n >= 0x80 {
|
||||||
|
out.push((n as u8) | 0x80);
|
||||||
|
n >>= 7;
|
||||||
|
}
|
||||||
|
out.push(n as u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field(number: u64, bytes: &[u8], out: &mut Vec<u8>) {
|
||||||
|
varint(number << 3 | 2, out);
|
||||||
|
varint(bytes.len() as u64, out);
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn packed(values: &[u64]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for &v in values {
|
||||||
|
varint(v, &mut out);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zz(n: i64) -> u64 {
|
||||||
|
((n << 1) ^ (n >> 63)) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tile with one `water` layer holding one square tagged class=lake.
|
||||||
|
fn lake_tile() -> Vec<u8> {
|
||||||
|
let mut feature = Vec::new();
|
||||||
|
field(2, &packed(&[0, 0]), &mut feature);
|
||||||
|
varint(3 << 3, &mut feature);
|
||||||
|
varint(3, &mut feature);
|
||||||
|
let square = [
|
||||||
|
1 | 1 << 3,
|
||||||
|
zz(10),
|
||||||
|
zz(10),
|
||||||
|
2 | 3 << 3,
|
||||||
|
zz(20),
|
||||||
|
zz(0),
|
||||||
|
zz(0),
|
||||||
|
zz(20),
|
||||||
|
zz(-20),
|
||||||
|
zz(0),
|
||||||
|
7 | 1 << 3,
|
||||||
|
];
|
||||||
|
field(4, &packed(&square), &mut feature);
|
||||||
|
|
||||||
|
let mut value = Vec::new();
|
||||||
|
field(1, b"lake", &mut value);
|
||||||
|
|
||||||
|
let mut layer = Vec::new();
|
||||||
|
field(1, b"water", &mut layer);
|
||||||
|
field(2, &feature, &mut layer);
|
||||||
|
field(3, b"class", &mut layer);
|
||||||
|
field(4, &value, &mut layer);
|
||||||
|
varint(5 << 3, &mut layer);
|
||||||
|
varint(4096, &mut layer);
|
||||||
|
|
||||||
|
let mut tile = Vec::new();
|
||||||
|
field(3, &layer, &mut tile);
|
||||||
|
tile
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_polygon_decodes_with_its_tags() {
|
||||||
|
let layers = decode(&lake_tile()).unwrap();
|
||||||
|
let [water] = &layers[..] else {
|
||||||
|
panic!("one layer expected");
|
||||||
|
};
|
||||||
|
assert_eq!(water.name, "water");
|
||||||
|
assert_eq!(water.extent, 4096.0);
|
||||||
|
let feature = &water.features[0];
|
||||||
|
assert_eq!(feature.kind, Kind::Polygon);
|
||||||
|
assert_eq!(water.text(feature, "class"), Some("lake"));
|
||||||
|
assert_eq!(water.text(feature, "name"), None);
|
||||||
|
assert_eq!(
|
||||||
|
feature.parts,
|
||||||
|
vec![vec![(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cut_off_tile_is_rejected() {
|
||||||
|
let tile = lake_tile();
|
||||||
|
assert!(decode(&tile[..tile.len() - 3]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn points_of_one_feature_share_a_part() {
|
||||||
|
let commands = [1 | 2 << 3, zz(5), zz(5), zz(1), zz(-1)];
|
||||||
|
let parts = decode_geometry(Kind::Point, &commands).unwrap();
|
||||||
|
assert_eq!(parts, vec![vec![(5.0, 5.0), (6.0, 4.0)]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ pub(crate) struct Viewer {
|
|||||||
pub(crate) scroller: gtk4::ScrolledWindow,
|
pub(crate) scroller: gtk4::ScrolledWindow,
|
||||||
pub(crate) picture: gtk4::Picture,
|
pub(crate) picture: gtk4::Picture,
|
||||||
pub(crate) video: gtk4::Video,
|
pub(crate) video: gtk4::Video,
|
||||||
pub(crate) spinner: gtk4::Spinner,
|
pub(crate) spinner: Spinner,
|
||||||
pub(crate) status: gtk4::Label,
|
pub(crate) status: gtk4::Label,
|
||||||
pub(crate) title: gtk4::Label,
|
pub(crate) title: gtk4::Label,
|
||||||
pub(crate) counter: gtk4::Label,
|
pub(crate) counter: gtk4::Label,
|
||||||
@@ -224,7 +224,6 @@ fn viewer_item(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) -> Option<PhotoItem>
|
|||||||
/// plays in place once its file is down.
|
/// plays in place once its file is down.
|
||||||
pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||||
viewer.spinner.set_visible(true);
|
viewer.spinner.set_visible(true);
|
||||||
viewer.spinner.start();
|
|
||||||
viewer.status.set_visible(false);
|
viewer.status.set_visible(false);
|
||||||
viewer.loading.set(true);
|
viewer.loading.set(true);
|
||||||
*viewer.path.borrow_mut() = None;
|
*viewer.path.borrow_mut() = None;
|
||||||
@@ -266,7 +265,6 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
if *viewer.uid.borrow() != uid {
|
if *viewer.uid.borrow() != uid {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
viewer.spinner.stop();
|
|
||||||
viewer.spinner.set_visible(false);
|
viewer.spinner.set_visible(false);
|
||||||
viewer.loading.set(false);
|
viewer.loading.set(false);
|
||||||
// Armed whether or not the photo rendered: a slideshow steps past a
|
// Armed whether or not the photo rendered: a slideshow steps past a
|
||||||
@@ -307,7 +305,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
Ok(Ok(Response::Error { message, .. })) => fail(&message),
|
Ok(Ok(Response::Error { message, .. })) => fail(&message),
|
||||||
Ok(Ok(_)) => fail(&gettext("Unexpected reply from the mount service.")),
|
Ok(Ok(_)) => fail(&gettext("Unexpected reply from the Proton Drive service.")),
|
||||||
Ok(Err(_)) | Err(_) => fail(&gettext("Couldn't reach Proton Drive.")),
|
Ok(Err(_)) | Err(_) => fail(&gettext("Couldn't reach Proton Drive.")),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1136,7 +1134,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
.child(
|
.child(
|
||||||
&adw::ButtonContent::builder()
|
&adw::ButtonContent::builder()
|
||||||
.label(gettext("Show on map"))
|
.label(gettext("Show on map"))
|
||||||
.icon_name("map-symbolic")
|
.icon_name("pdfs-map-symbolic")
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.halign(gtk4::Align::Start)
|
.halign(gtk4::Align::Start)
|
||||||
@@ -1192,12 +1190,10 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
.child(&info_panel)
|
.child(&info_panel)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let spinner = gtk4::Spinner::builder()
|
let spinner = spinner();
|
||||||
.halign(gtk4::Align::Center)
|
spinner.set_halign(gtk4::Align::Center);
|
||||||
.valign(gtk4::Align::Center)
|
spinner.set_valign(gtk4::Align::Center);
|
||||||
.width_request(48)
|
spinner.set_size_request(48, 48);
|
||||||
.height_request(48)
|
|
||||||
.build();
|
|
||||||
spinner.add_css_class("viewer-spinner");
|
spinner.add_css_class("viewer-spinner");
|
||||||
overlay.add_overlay(&spinner);
|
overlay.add_overlay(&spinner);
|
||||||
|
|
||||||
@@ -1390,12 +1386,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
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 viewer_map = viewer.clone();
|
let viewer_map = viewer.clone();
|
||||||
|
let w_map = window.clone();
|
||||||
info_map.connect_clicked(move |_| {
|
info_map.connect_clicked(move |_| {
|
||||||
if let Some((lat, lon)) = *viewer_map.coords.borrow() {
|
let coords = *viewer_map.coords.borrow();
|
||||||
open_path(&format!(
|
if let Some((lat, lon)) = coords {
|
||||||
"https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=16/{lat:.6}/{lon:.6}"
|
w_map.close();
|
||||||
));
|
show_on_map(&ui_map, lat, lon);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1424,7 +1422,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, .. })) => Some(message),
|
Ok(Ok(Response::Error { message, .. })) => Some(message),
|
||||||
_ => Some(gettext("The mount service didn't respond.")),
|
_ => Some(gettext("The Proton Drive service didn't respond.")),
|
||||||
};
|
};
|
||||||
if let Some(detail) = failed {
|
if let Some(detail) = failed {
|
||||||
toast_error(
|
toast_error(
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ use crate::*;
|
|||||||
/// Edge length in px of a place's cover, the same as an album's.
|
/// Edge length in px of a place's cover, the same as an album's.
|
||||||
const COVER_EDGE: i32 = 200;
|
const COVER_EDGE: i32 = 200;
|
||||||
|
|
||||||
|
/// Edge length in px of a photo in the list of photos taken on one spot.
|
||||||
|
const SPOT_EDGE: i32 = 96;
|
||||||
|
|
||||||
/// How often the listing is asked for again while photo locations are read.
|
/// How often the listing is asked for again while photo locations are read.
|
||||||
const MAPPING_POLL: std::time::Duration = std::time::Duration::from_secs(2);
|
const MAPPING_POLL: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
@@ -99,7 +102,7 @@ pub(crate) fn load_places(ui: &Rc<Ui>) {
|
|||||||
&ui,
|
&ui,
|
||||||
"network-offline-symbolic",
|
"network-offline-symbolic",
|
||||||
&gettext("Not connected"),
|
&gettext("Not connected"),
|
||||||
&gettext("The Proton Drive mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -232,6 +235,8 @@ fn place_card(ui: &Rc<Ui>, place: &PlaceInfo) -> gtk4::Button {
|
|||||||
button.set_tooltip_text(Some(&format!("{}\n{subtitle}", place.name)));
|
button.set_tooltip_text(Some(&format!("{}\n{subtitle}", place.name)));
|
||||||
|
|
||||||
want_cover(ui, place.cover.uid.clone(), &picture);
|
want_cover(ui, place.cover.uid.clone(), &picture);
|
||||||
|
// The map marks the town with the same photo.
|
||||||
|
cover_map_marker(ui, &place.cover.uid, &picture);
|
||||||
|
|
||||||
let ui_open = ui.clone();
|
let ui_open = ui.clone();
|
||||||
let place = place.clone();
|
let place = place.clone();
|
||||||
@@ -239,6 +244,175 @@ fn place_card(ui: &Rc<Ui>, place: &PlaceInfo) -> gtk4::Button {
|
|||||||
button
|
button
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hand the map the photo `picture` shows once it is there.
|
||||||
|
fn cover_map_marker(ui: &Rc<Ui>, uid: &str, picture: >k4::Picture) {
|
||||||
|
let map = Rc::downgrade(&ui.gallery.places_map);
|
||||||
|
let set = move |picture: >k4::Picture, uid: &str| {
|
||||||
|
let texture = picture.paintable().and_downcast::<gtk4::gdk::Texture>();
|
||||||
|
if let (Some(map), Some(texture)) = (map.upgrade(), texture) {
|
||||||
|
map.set_cover(uid, &texture);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
set(picture, uid);
|
||||||
|
let uid = uid.to_owned();
|
||||||
|
picture.connect_paintable_notify(move |picture| set(picture, &uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the map the thumbnail of a photo it marks on its own, through the
|
||||||
|
/// gallery's thumbnail pipeline.
|
||||||
|
fn want_map_thumb(ui: &Rc<Ui>, photo: &PhotoItem) {
|
||||||
|
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&photo.uid) {
|
||||||
|
ui.gallery.places_map.set_cover(&photo.uid, texture);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A town's card may be waiting for the same photo: share its picture
|
||||||
|
// rather than take its place in the queue.
|
||||||
|
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
||||||
|
let picture = waiting.unwrap_or_else(|| {
|
||||||
|
let picture = gtk4::Picture::new();
|
||||||
|
want_thumb(ui, photo, &picture);
|
||||||
|
picture
|
||||||
|
});
|
||||||
|
cover_map_marker(ui, &photo.uid, &picture);
|
||||||
|
schedule_thumbs(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List the photos taken on one spot of the map in a popover at `(x, y)`;
|
||||||
|
/// clicking one opens it in its town.
|
||||||
|
fn show_spot_photos(ui: &Rc<Ui>, photos: Vec<(PlaceInfo, PhotoItem)>, (x, y): (f64, f64)) {
|
||||||
|
let Some((place, _)) = photos.first() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let popover = gtk4::Popover::new();
|
||||||
|
popover.set_parent(ui.gallery.places_map.widget());
|
||||||
|
popover.set_pointing_to(Some(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
|
||||||
|
|
||||||
|
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||||
|
content.set_margin_top(6);
|
||||||
|
content.set_margin_bottom(6);
|
||||||
|
content.set_margin_start(6);
|
||||||
|
content.set_margin_end(6);
|
||||||
|
let title = gtk4::Label::builder()
|
||||||
|
.label(&place.name)
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.build();
|
||||||
|
title.add_css_class("heading");
|
||||||
|
let count = gtk4::Label::builder()
|
||||||
|
.label(ngettext_f(
|
||||||
|
"{n} photo",
|
||||||
|
"{n} photos",
|
||||||
|
photos.len() as u64,
|
||||||
|
&[],
|
||||||
|
))
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.build();
|
||||||
|
count.add_css_class("dim-label");
|
||||||
|
count.add_css_class("caption");
|
||||||
|
content.append(&title);
|
||||||
|
content.append(&count);
|
||||||
|
|
||||||
|
let grid = gtk4::FlowBox::builder()
|
||||||
|
.selection_mode(gtk4::SelectionMode::None)
|
||||||
|
.homogeneous(true)
|
||||||
|
.min_children_per_line(1)
|
||||||
|
.max_children_per_line(4)
|
||||||
|
.row_spacing(4)
|
||||||
|
.column_spacing(4)
|
||||||
|
.build();
|
||||||
|
for (place, photo) in photos {
|
||||||
|
let picture = gtk4::Picture::builder()
|
||||||
|
.content_fit(gtk4::ContentFit::Cover)
|
||||||
|
.can_shrink(true)
|
||||||
|
.width_request(SPOT_EDGE)
|
||||||
|
.height_request(SPOT_EDGE)
|
||||||
|
.build();
|
||||||
|
spot_thumb(ui, &photo, &picture);
|
||||||
|
let button = gtk4::Button::builder()
|
||||||
|
.child(&picture)
|
||||||
|
.tooltip_text(format_capture_time(photo.capture_time))
|
||||||
|
.build();
|
||||||
|
button.add_css_class("flat");
|
||||||
|
button.add_css_class("places-spot-photo");
|
||||||
|
let ui_open = ui.clone();
|
||||||
|
let popover_open = popover.clone();
|
||||||
|
button.connect_clicked(move |_| {
|
||||||
|
popover_open.popdown();
|
||||||
|
open_place_photo(&ui_open, place.clone(), photo.uid.clone());
|
||||||
|
});
|
||||||
|
grid.append(&button);
|
||||||
|
}
|
||||||
|
schedule_thumbs(ui);
|
||||||
|
let scroller = gtk4::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.propagate_natural_width(true)
|
||||||
|
.propagate_natural_height(true)
|
||||||
|
.max_content_height(360)
|
||||||
|
.child(&grid)
|
||||||
|
.build();
|
||||||
|
content.append(&scroller);
|
||||||
|
popover.set_child(Some(&content));
|
||||||
|
// Gone with its last use; its parent is the map, which stays.
|
||||||
|
popover.connect_closed(|popover| {
|
||||||
|
let popover = popover.clone();
|
||||||
|
glib::idle_add_local_once(move || popover.unparent());
|
||||||
|
});
|
||||||
|
popover.popup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show photo's thumbnail in `picture`, sharing one another widget is
|
||||||
|
/// already waiting for.
|
||||||
|
fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
||||||
|
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
||||||
|
match waiting {
|
||||||
|
Some(waiting) => {
|
||||||
|
waiting
|
||||||
|
.bind_property("paintable", picture, "paintable")
|
||||||
|
.sync_create()
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
None => want_thumb(ui, photo, picture),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the map the photos of town `id` with where each was taken.
|
||||||
|
fn load_place_locations(ui: &Rc<Ui>, id: u32) {
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PlaceLocations { id });
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::PhotoLocations { items })) => {
|
||||||
|
ui.gallery.places_map.set_locations(id, items)
|
||||||
|
}
|
||||||
|
// The map keeps showing the town as one marker.
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
tracing::debug!("place locations failed: {message}")
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => tracing::debug!("place locations: no reply"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open `place` and then photo `uid` in it, paging through the town until
|
||||||
|
/// the photo has loaded.
|
||||||
|
fn open_place_photo(ui: &Rc<Ui>, place: PlaceInfo, uid: String) {
|
||||||
|
open_place(ui, place);
|
||||||
|
view_when_loaded(ui, uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_when_loaded(ui: &Rc<Ui>, uid: String) {
|
||||||
|
let ui_page = ui.clone();
|
||||||
|
ui.gallery.page_waiters.borrow_mut().push(Box::new(move || {
|
||||||
|
let ui = ui_page;
|
||||||
|
if find_photo_index(&ui.gallery.model, &uid).is_some() {
|
||||||
|
open_photo_viewer(&ui, uid);
|
||||||
|
} else if ui.gallery.has_more.get() && ui.gallery.place.borrow().is_some() {
|
||||||
|
view_when_loaded(&ui, uid);
|
||||||
|
ui.gallery.burst.set(true);
|
||||||
|
load_gallery(&ui, true);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/// Open one place in the gallery: the timeline view, paged from that town.
|
/// Open one place in the gallery: the timeline view, paged from that town.
|
||||||
fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
|
fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
|
||||||
ui.gallery.title.set_title(&place.name);
|
ui.gallery.title.set_title(&place.name);
|
||||||
@@ -248,7 +422,7 @@ fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
|
|||||||
|
|
||||||
// As in an album: no filters, no Upload, no view switch — back leads out.
|
// As in an album: no filters, no Upload, no view switch — back leads out.
|
||||||
ui.gallery.filters.set_visible(false);
|
ui.gallery.filters.set_visible(false);
|
||||||
ui.gallery.view_switch.set_visible(false);
|
ui.gallery.view_switch.widget().set_visible(false);
|
||||||
ui.gallery.upload.set_visible(false);
|
ui.gallery.upload.set_visible(false);
|
||||||
ui.gallery.back.set_visible(true);
|
ui.gallery.back.set_visible(true);
|
||||||
ui.gallery.kind.set(None);
|
ui.gallery.kind.set(None);
|
||||||
@@ -265,7 +439,7 @@ pub(crate) fn close_place(ui: &Rc<Ui>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.title.set_title(&gettext("Photos"));
|
ui.gallery.title.set_title(&gettext("Photos"));
|
||||||
ui.gallery.view_switch.set_visible(true);
|
ui.gallery.view_switch.widget().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);
|
||||||
}
|
}
|
||||||
@@ -281,14 +455,14 @@ fn places_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
|||||||
/// Wire the Places toggle of the view switcher.
|
/// Wire the Places toggle of the view switcher.
|
||||||
pub(crate) fn wire_places(ui: &Rc<Ui>) {
|
pub(crate) fn wire_places(ui: &Rc<Ui>) {
|
||||||
let ui_places = ui.clone();
|
let ui_places = ui.clone();
|
||||||
ui.gallery.places_btn.clone().connect_toggled(move |btn| {
|
ui.gallery.view_switch.connect_changed(move |view| {
|
||||||
if btn.is_active() {
|
if view == VIEW_PLACES {
|
||||||
show_places(&ui_places);
|
show_places(&ui_places);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_layout = ui.clone();
|
let ui_layout = ui.clone();
|
||||||
ui.gallery.places_map_btn.connect_toggled(move |_| {
|
ui.gallery.places_layout.connect_changed(move |_| {
|
||||||
// A status page (loading, empty, an error) stays until a listing
|
// A status page (loading, empty, an error) stays until a listing
|
||||||
// replaces it; only the grid and the map swap.
|
// replaces it; only the grid and the map swap.
|
||||||
if ui_layout
|
if ui_layout
|
||||||
@@ -302,15 +476,36 @@ pub(crate) fn wire_places(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let map = &ui.gallery.places_map;
|
||||||
let ui_open = ui.clone();
|
let ui_open = ui.clone();
|
||||||
ui.gallery
|
map.connect_open(move |place| open_place(&ui_open, place));
|
||||||
.places_map
|
let ui_photo = ui.clone();
|
||||||
.connect_open(move |place| open_place(&ui_open, place));
|
map.connect_open_photo(move |place, uid| open_place_photo(&ui_photo, place, uid));
|
||||||
|
let ui_locations = ui.clone();
|
||||||
|
map.connect_want_locations(move |id| load_place_locations(&ui_locations, id));
|
||||||
|
let ui_thumb = ui.clone();
|
||||||
|
map.connect_want_thumb(move |photo| want_map_thumb(&ui_thumb, &photo));
|
||||||
|
let ui_spot = ui.clone();
|
||||||
|
map.connect_open_spot(move |photos, at| show_spot_photos(&ui_spot, photos, at));
|
||||||
|
map.set_online(ui.dirs.load_config().online_map, ui.dirs.cache_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to the Places map and zoom in on `(latitude, longitude)`, where a
|
||||||
|
/// photo in the viewer was taken.
|
||||||
|
pub(crate) fn show_on_map(ui: &Rc<Ui>, latitude: f64, longitude: f64) {
|
||||||
|
ui.gallery.places_layout.set_active(PLACES_MAP);
|
||||||
|
if ui.gallery.view_switch.active() == VIEW_PLACES {
|
||||||
|
// Already in Places, perhaps inside a town: back out to the listing.
|
||||||
|
show_places(ui);
|
||||||
|
} else {
|
||||||
|
ui.gallery.view_switch.set_active(VIEW_PLACES);
|
||||||
|
}
|
||||||
|
ui.gallery.places_map.show_spot(latitude, longitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the places as cards or on the map, as the Grid/Map toggle says.
|
/// Show the places as cards or on the map, as the Grid/Map toggle says.
|
||||||
fn show_places_layout(ui: &Rc<Ui>) {
|
fn show_places_layout(ui: &Rc<Ui>) {
|
||||||
let layout = if ui.gallery.places_map_btn.is_active() {
|
let layout = if ui.gallery.places_layout.active() == PLACES_MAP {
|
||||||
"map"
|
"map"
|
||||||
} else {
|
} else {
|
||||||
"grid"
|
"grid"
|
||||||
|
|||||||
@@ -1,38 +1,94 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
pub(crate) struct SharedByMeState {
|
pub(crate) struct SharedByMeState {
|
||||||
// Shared (by me) page: one section listing the items I've shared, each with a
|
// Shared by me page: the items I have shared, as a file list whose columns
|
||||||
// copy-link / manage affordance. Rebuilt wholesale on each load.
|
// say where each lives, who has it and what its public link is doing.
|
||||||
|
pub(crate) files: FileList,
|
||||||
|
/// "list", or "status" while the page loads, is empty or can't be read.
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
/// "12 items" under the page title.
|
||||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
pub(crate) subtitle: adw::WindowTitle,
|
||||||
pub(crate) inflight: Cell<bool>,
|
/// Which items to show, indexed like [`SHARE_FILTERS`].
|
||||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
pub(crate) filter: gtk4::DropDown,
|
||||||
|
/// The last listing, unfiltered. The list holds [`DirEntry`]s; the columns
|
||||||
|
/// and menus look a row's people and link up here by uid.
|
||||||
|
pub(crate) items: Rc<RefCell<Vec<SharedItem>>>,
|
||||||
|
/// Runs the loads; the rows stay up while one runs.
|
||||||
|
pub(crate) loader: Rc<Loader>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedByMeState {
|
||||||
|
pub(crate) fn new(widgets: &SharedByMeWidgets) -> Self {
|
||||||
|
SharedByMeState {
|
||||||
|
files: widgets.files.clone(),
|
||||||
|
content: widgets.content.clone(),
|
||||||
|
status: widgets.status.clone(),
|
||||||
|
retry: widgets.retry.clone(),
|
||||||
|
subtitle: widgets.subtitle.clone(),
|
||||||
|
filter: widgets.filter.clone(),
|
||||||
|
items: Rc::new(RefCell::new(Vec::new())),
|
||||||
|
loader: Loader::new(&widgets.content),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Widgets the Shared (by-me) page's load/repaint touch.
|
/// Widgets the Shared (by-me) page's load/repaint touch.
|
||||||
pub(crate) struct SharedByMeWidgets {
|
pub(crate) struct SharedByMeWidgets {
|
||||||
|
pub(crate) files: FileList,
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
pub(crate) subtitle: adw::WindowTitle,
|
||||||
|
pub(crate) filter: gtk4::DropDown,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Shared page: one section listing the items I have shared with others —
|
/// Which shared items the page shows.
|
||||||
/// each row summarizing who has access, opening the item when activated, with
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
/// Copy link and a menu for the link and the per-node Share dialog.
|
pub(crate) enum ShareFilter {
|
||||||
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
All,
|
||||||
let refresh = refresh_button();
|
/// Items with a public link.
|
||||||
|
Links,
|
||||||
|
/// Items shared with people, invited or already in.
|
||||||
|
People,
|
||||||
|
/// Items whose public link has expired.
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
let group = adw::PreferencesGroup::new();
|
/// The filters in the order the header's drop-down lists them.
|
||||||
let clamp = adw::Clamp::builder().child(&group).build();
|
const SHARE_FILTERS: [ShareFilter; 4] = [
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
ShareFilter::All,
|
||||||
.vexpand(true)
|
ShareFilter::Links,
|
||||||
.child(&clamp)
|
ShareFilter::People,
|
||||||
.build();
|
ShareFilter::Expired,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn share_filter_label(filter: ShareFilter) -> String {
|
||||||
|
match filter {
|
||||||
|
ShareFilter::All => gettext("All Shared Items"),
|
||||||
|
ShareFilter::Links => gettext("Links Only"),
|
||||||
|
ShareFilter::People => gettext("People Only"),
|
||||||
|
ShareFilter::Expired => gettext("Expired Links"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Shared by me page: every item I have shared with people or by link.
|
||||||
|
/// Activating one opens its Share dialog, since managing access is what this
|
||||||
|
/// page is for; opening the item itself is in the menu.
|
||||||
|
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||||
|
let files = FileList::new();
|
||||||
|
files.show_list(true);
|
||||||
|
let layout = layout_button(&files);
|
||||||
|
let labels: Vec<String> = SHARE_FILTERS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(share_filter_label)
|
||||||
|
.collect();
|
||||||
|
let filter =
|
||||||
|
gtk4::DropDown::from_strings(&labels.iter().map(String::as_str).collect::<Vec<_>>());
|
||||||
|
filter.set_valign(gtk4::Align::Center);
|
||||||
|
filter.set_tooltip_text(Some(&gettext("Show only some shared items")));
|
||||||
|
|
||||||
let retry = gtk4::Button::builder()
|
let retry = gtk4::Button::builder()
|
||||||
.label(gettext("Retry"))
|
.label(gettext("Retry"))
|
||||||
@@ -42,7 +98,7 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
|||||||
retry.add_css_class("suggested-action");
|
retry.add_css_class("suggested-action");
|
||||||
retry.set_visible(false);
|
retry.set_visible(false);
|
||||||
let status = adw::StatusPage::builder()
|
let status = adw::StatusPage::builder()
|
||||||
.icon_name("emblem-shared-symbolic")
|
.icon_name("pdfs-share-symbolic")
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.child(&retry)
|
.child(&retry)
|
||||||
.build();
|
.build();
|
||||||
@@ -51,277 +107,154 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
|||||||
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(&scroll, Some("list"));
|
content.add_named(&files.views, Some("list"));
|
||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(18);
|
inner.set_margin_top(12);
|
||||||
inner.set_margin_bottom(18);
|
inner.set_margin_bottom(12);
|
||||||
inner.set_margin_start(18);
|
inner.set_margin_start(12);
|
||||||
inner.set_margin_end(18);
|
inner.set_margin_end(12);
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
let (frame, header, _) = page_frame(&gettext("Shared by Me"), &inner);
|
|
||||||
header.pack_end(&refresh);
|
let (frame, header, subtitle) = page_frame(&gettext("Shared by me"), &inner);
|
||||||
|
header.pack_start(&filter);
|
||||||
|
header.pack_end(&layout);
|
||||||
|
|
||||||
(
|
(
|
||||||
frame.upcast(),
|
frame.upcast(),
|
||||||
SharedByMeWidgets {
|
SharedByMeWidgets {
|
||||||
|
files,
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
group,
|
|
||||||
retry,
|
retry,
|
||||||
refresh,
|
subtitle,
|
||||||
|
filter,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the Shared (by-me) page's retry button.
|
/// Install the file list, its columns, the filter and the retry button.
|
||||||
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, retry: >k4::Button) {
|
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, widgets: &SharedByMeWidgets) {
|
||||||
|
let files = &ui.shared_by_me.files;
|
||||||
|
files.wire(
|
||||||
|
ui,
|
||||||
|
FileListBehavior {
|
||||||
|
activate: open_share_dialog,
|
||||||
|
entry_menu: shared_by_me_menu,
|
||||||
|
bulk_menu: shared_by_me_bulk_menu,
|
||||||
|
background_menu: shared_by_me_background_menu,
|
||||||
|
badges: false,
|
||||||
|
drag_and_drop: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let items = &ui.shared_by_me.items;
|
||||||
|
files
|
||||||
|
.column_view
|
||||||
|
.append_column(&text_column(&gettext("Location"), |e| {
|
||||||
|
location_label(&e.path)
|
||||||
|
}));
|
||||||
|
let people = items.clone();
|
||||||
|
files
|
||||||
|
.column_view
|
||||||
|
.append_column(&text_column(&pgettext("column", "People"), move |e| {
|
||||||
|
find_item(&people, &e.uid)
|
||||||
|
.map(|item| people_label(&item))
|
||||||
|
.unwrap_or_else(|| "—".to_string())
|
||||||
|
}));
|
||||||
|
files.column_view.append_column(&link_column(ui));
|
||||||
|
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
retry.connect_clicked(move |_| {
|
widgets
|
||||||
service::restart();
|
.retry
|
||||||
load_shared_by_me(&ui_retry);
|
.connect_clicked(move |_| restart_service_then(&ui_retry, load_shared_by_me));
|
||||||
|
let ui_filter = ui.clone();
|
||||||
|
widgets.filter.connect_selected_notify(move |_| {
|
||||||
|
let items = ui_filter.shared_by_me.items.borrow().clone();
|
||||||
|
repaint_shared_by_me(&ui_filter, &items);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show a status page in place of the Shared (by-me) list.
|
/// The shared item a row stands for.
|
||||||
pub(crate) fn shared_by_me_status(
|
fn find_item(items: &RefCell<Vec<SharedItem>>, uid: &str) -> Option<SharedItem> {
|
||||||
ui: &Rc<Ui>,
|
items.borrow().iter().find(|item| item.uid == uid).cloned()
|
||||||
icon: &str,
|
|
||||||
title: &str,
|
|
||||||
description: &str,
|
|
||||||
retry: bool,
|
|
||||||
) {
|
|
||||||
ui.shared_by_me.status.set_icon_name(Some(icon));
|
|
||||||
ui.shared_by_me.status.set_title(title);
|
|
||||||
ui.shared_by_me.status.set_description(Some(description));
|
|
||||||
ui.shared_by_me.retry.set_visible(retry);
|
|
||||||
ui.shared_by_me.content.set_visible_child_name("status");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the shared-by-me listing and repaint the page.
|
/// The Link column: what the public link is doing, and a button that copies
|
||||||
pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
/// it while it works.
|
||||||
if ui.shared_by_me.inflight.get() {
|
fn link_column(ui: &Rc<Ui>) -> gtk4::ColumnViewColumn {
|
||||||
return;
|
let factory = gtk4::SignalListItemFactory::new();
|
||||||
}
|
let ui_setup = ui.clone();
|
||||||
cancel_file_thumbnails(ui);
|
factory.connect_setup(move |_, item| {
|
||||||
ui.shared_by_me.inflight.set(true);
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
shared_by_me_status(
|
let label = gtk4::Label::builder()
|
||||||
ui,
|
.halign(gtk4::Align::Start)
|
||||||
"emblem-shared-symbolic",
|
.hexpand(true)
|
||||||
&gettext("Loading…"),
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
&gettext("Reading what you've shared."),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
ui.busy_begin();
|
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe);
|
|
||||||
let ui = ui.clone();
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
let result = rx.recv().await;
|
|
||||||
ui.busy_end();
|
|
||||||
ui.shared_by_me.inflight.set(false);
|
|
||||||
match result {
|
|
||||||
Ok(Ok(Response::SharedByMe { items })) => {
|
|
||||||
repaint_shared_by_me(&ui, &items);
|
|
||||||
ui.shared_by_me.loaded_at.set(Some(Instant::now()));
|
|
||||||
}
|
|
||||||
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
|
|
||||||
&ui,
|
|
||||||
"dialog-warning-symbolic",
|
|
||||||
&gettext("Couldn't read your shares"),
|
|
||||||
&message,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
Ok(Ok(_)) => shared_by_me_status(
|
|
||||||
&ui,
|
|
||||||
"dialog-warning-symbolic",
|
|
||||||
&gettext("Couldn't read your shares"),
|
|
||||||
&gettext("Unexpected reply from the mount service."),
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
Ok(Err(_)) | Err(_) => {
|
|
||||||
ui.shared_by_me.loaded_at.set(None);
|
|
||||||
shared_by_me_unreachable(&ui);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The daemon didn't answer the Shared (by-me) page.
|
|
||||||
pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
|
|
||||||
if service::is_failed() || !service::is_active() {
|
|
||||||
shared_by_me_status(
|
|
||||||
ui,
|
|
||||||
"network-offline-symbolic",
|
|
||||||
&gettext("Not connected"),
|
|
||||||
&gettext("The Proton Drive mount service isn't running."),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
shared_by_me_status(
|
|
||||||
ui,
|
|
||||||
"folder-remote-symbolic",
|
|
||||||
&gettext("Connecting…"),
|
|
||||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
let ui = ui.clone();
|
|
||||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("sharedbyme") {
|
|
||||||
load_shared_by_me(&ui);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rebuild the Shared (by-me) section from a fresh listing.
|
|
||||||
pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
|
||||||
for row in ui.shared_by_me.rows.borrow_mut().drain(..) {
|
|
||||||
ui.shared_by_me.group.remove(&row);
|
|
||||||
}
|
|
||||||
if items.is_empty() {
|
|
||||||
shared_by_me_status(
|
|
||||||
ui,
|
|
||||||
"emblem-shared-symbolic",
|
|
||||||
&gettext("Nothing shared yet"),
|
|
||||||
&gettext("Items you share with people or by link show up here."),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ui.shared_by_me.content.set_visible_child_name("list");
|
|
||||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
|
||||||
for item in items {
|
|
||||||
let entry = shared_item_as_entry(item);
|
|
||||||
let row = adw::ActionRow::builder()
|
|
||||||
.title(&item.name)
|
|
||||||
.subtitle(shared_item_summary(item))
|
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
|
label.add_css_class("dim-label");
|
||||||
|
let copy = gtk4::Button::builder()
|
||||||
// A node the daemon can place in my tree opens like it would in My Files.
|
.icon_name("pdfs-link-symbolic")
|
||||||
if !item.path.is_empty() {
|
.tooltip_text(gettext("Copy link"))
|
||||||
row.set_activatable(true);
|
.valign(gtk4::Align::Center)
|
||||||
let ui_open = ui.clone();
|
.build();
|
||||||
let entry_open = entry.clone();
|
copy.add_css_class("flat");
|
||||||
row.connect_activated(move |_| open_shared_by_me(&ui_open, &entry_open));
|
// Read at click time: the view recycles this cell for other rows.
|
||||||
}
|
let (ui, item_c) = (ui_setup.clone(), item.clone());
|
||||||
|
copy.connect_clicked(move |btn| {
|
||||||
let url = item.link.as_ref().and_then(|l| l.url.clone());
|
let Some(obj) = item_c.item().and_downcast::<BoxedAnyObject>() else {
|
||||||
// The one action people come here for most gets its own button.
|
return;
|
||||||
if let Some(url) = url.clone() {
|
};
|
||||||
let copy = gtk4::Button::builder()
|
let uid = obj.borrow::<DirEntry>().uid.clone();
|
||||||
.icon_name("edit-copy-symbolic")
|
if let Some(url) = find_item(&ui.shared_by_me.items, &uid)
|
||||||
.tooltip_text(gettext("Copy link"))
|
.and_then(|item| item.link)
|
||||||
.valign(gtk4::Align::Center)
|
.and_then(|link| link.url)
|
||||||
.build();
|
{
|
||||||
copy.add_css_class("flat");
|
|
||||||
let ui_copy = ui.clone();
|
|
||||||
copy.connect_clicked(move |btn| {
|
|
||||||
btn.clipboard().set_text(&url);
|
btn.clipboard().set_text(&url);
|
||||||
toast(&ui_copy, &gettext("Link copied"));
|
flash_copied(btn, "pdfs-link-symbolic");
|
||||||
});
|
toast(&ui, &gettext("Link copied"));
|
||||||
row.add_suffix(©);
|
}
|
||||||
}
|
});
|
||||||
row.add_suffix(&shared_by_me_menu(ui, &entry, url));
|
let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||||
|
cell.append(&label);
|
||||||
ui.shared_by_me.group.add(&row);
|
cell.append(©);
|
||||||
rows.push(row.upcast());
|
item.set_child(Some(&cell));
|
||||||
}
|
});
|
||||||
*ui.shared_by_me.rows.borrow_mut() = rows;
|
let items = ui.shared_by_me.items.clone();
|
||||||
}
|
factory.connect_bind(move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
/// The ⋮ menu on a shared item: open it, the link, the Share dialog, and
|
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||||
/// Stop Sharing.
|
let label = cell.first_child().and_downcast::<gtk4::Label>().unwrap();
|
||||||
///
|
let copy = cell.last_child().unwrap();
|
||||||
/// Manage access opens the per-node Share dialog, which addresses a pathless
|
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||||
/// node by uid, so every shared item can be managed from here.
|
let uid = obj.borrow::<DirEntry>().uid.clone();
|
||||||
fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry, url: Option<String>) -> gtk4::MenuButton {
|
let link = find_item(&items, &uid).and_then(|item| item.link);
|
||||||
let mut items: Vec<(String, MenuAction)> = Vec::new();
|
let now = glib::real_time() / 1_000_000;
|
||||||
if !entry.path.is_empty() {
|
let text = link_label(link.as_ref(), now);
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
label.set_label(&text);
|
||||||
items.push((
|
label.set_tooltip_text(Some(&text));
|
||||||
pgettext("verb", "Open"),
|
copy.set_visible(
|
||||||
Box::new(move || open_shared_by_me(&ui_c, &entry_c)),
|
link.as_ref()
|
||||||
));
|
.is_some_and(|l| l.url.is_some() && !link_expired(l, now)),
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
|
||||||
items.push((
|
|
||||||
gettext("Show in My Files"),
|
|
||||||
Box::new(move || show_in_my_files(&ui_c, &entry_c)),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(url) = url {
|
|
||||||
items.push((gettext("Open Link"), Box::new(move || open_uri(&url))));
|
|
||||||
}
|
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
|
||||||
items.push((
|
|
||||||
gettext("Manage Access…"),
|
|
||||||
Box::new(move || open_share_dialog(&ui_c, &entry_c)),
|
|
||||||
));
|
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
|
||||||
items.push((
|
|
||||||
gettext("Stop Sharing…"),
|
|
||||||
Box::new(move || prompt_stop_sharing(&ui_c, &entry_c)),
|
|
||||||
));
|
|
||||||
let (labels, actions): (Vec<String>, Vec<MenuAction>) = items.into_iter().unzip();
|
|
||||||
more_menu_button(labels.iter().map(String::as_str).zip(actions).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Confirm, then remove every member, invitation and the public link.
|
|
||||||
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
|
|
||||||
let dialog = adw::AlertDialog::builder()
|
|
||||||
.heading(gettext("Stop sharing?"))
|
|
||||||
// Translators: {name} is the shared item's name.
|
|
||||||
.body(gettext_f(
|
|
||||||
"Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.",
|
|
||||||
&[("name", &entry.name)],
|
|
||||||
))
|
|
||||||
.build();
|
|
||||||
dialog.add_response("cancel", &gettext("Cancel"));
|
|
||||||
dialog.add_response("stop", &gettext("Stop Sharing"));
|
|
||||||
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
|
|
||||||
dialog.set_default_response(Some("cancel"));
|
|
||||||
dialog.set_close_response("cancel");
|
|
||||||
let ui_c = ui.clone();
|
|
||||||
let uid = entry.uid.clone();
|
|
||||||
let name = entry.name.clone();
|
|
||||||
dialog.connect_response(Some("stop"), move |_, _| {
|
|
||||||
run_mutation(
|
|
||||||
&ui_c,
|
|
||||||
Request::StopSharingByUid { uid: uid.clone() },
|
|
||||||
// Translators: {name} is the item's name.
|
|
||||||
gettext_f("Stopped sharing {name}", &[("name", &name)]),
|
|
||||||
gettext_noop("Couldn't stop sharing"),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
dialog.present(ui_window(ui).as_ref());
|
let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Link")), Some(factory));
|
||||||
|
column.set_resizable(true);
|
||||||
|
column
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a shared item: a folder in My Files, a file the way My Files would.
|
/// Where a shared item lives in My files: its folder, "My files" at the top,
|
||||||
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
/// or a dash when the daemon can't place it.
|
||||||
if entry.is_dir {
|
pub(crate) fn location_label(path: &str) -> String {
|
||||||
ui.stack.set_visible_child_name("browser");
|
match path.rsplit_once('/') {
|
||||||
ui.browser.search.set_text("");
|
Some((parent, _)) => parent.to_string(),
|
||||||
browse_to(ui, entry.path.clone());
|
None if path.is_empty() => "—".to_string(),
|
||||||
} else {
|
None => gettext("My files"),
|
||||||
activate_entry(ui, entry);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Go to the folder holding a shared item in My Files.
|
/// Who can reach a shared item through an invitation.
|
||||||
pub(crate) fn show_in_my_files(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn people_label(item: &SharedItem) -> String {
|
||||||
let parent = entry
|
|
||||||
.path
|
|
||||||
.rsplit_once('/')
|
|
||||||
.map(|(parent, _)| parent.to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
ui.stack.set_visible_child_name("browser");
|
|
||||||
ui.browser.search.set_text("");
|
|
||||||
browse_to(ui, parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A one-line summary of who can reach a shared item, for its row subtitle.
|
|
||||||
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
|
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
if item.member_count > 0 {
|
if item.member_count > 0 {
|
||||||
// Translators: how many people have access to a shared item.
|
// Translators: how many people have access to a shared item.
|
||||||
@@ -341,19 +274,333 @@ pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
|
|||||||
&[],
|
&[],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if item.link.is_some() {
|
|
||||||
parts.push(gettext("Public link"));
|
|
||||||
}
|
|
||||||
if parts.is_empty() {
|
if parts.is_empty() {
|
||||||
// Translators: a shared item's summary when no people or link can be counted.
|
"—".to_string()
|
||||||
pgettext("state", "Shared")
|
|
||||||
} else {
|
} else {
|
||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a [`DirEntry`] from a [`SharedItem`] so the path-keyed Share dialog can
|
fn link_expired(link: &PublicLinkInfo, now: i64) -> bool {
|
||||||
/// open on it. Only used when the item's path is known.
|
link.expires.is_some_and(|at| at <= now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a public link is doing at `now` (epoch seconds): working, working
|
||||||
|
/// until a date, or expired, and whether a password guards it.
|
||||||
|
pub(crate) fn link_label(link: Option<&PublicLinkInfo>, now: i64) -> String {
|
||||||
|
let Some(link) = link else {
|
||||||
|
return "—".to_string();
|
||||||
|
};
|
||||||
|
let state = match link.expires {
|
||||||
|
Some(at) if at <= now => pgettext("link", "Expired"),
|
||||||
|
// Translators: {date} is when a public link stops working.
|
||||||
|
Some(at) => gettext_f("Expires {date}", &[("date", &dates::short_date(at))]),
|
||||||
|
None if link.created > 0 => gettext_f(
|
||||||
|
// Translators: {date} is when the public link was created.
|
||||||
|
"Active since {date}",
|
||||||
|
&[("date", &dates::short_date(link.created))],
|
||||||
|
),
|
||||||
|
None => pgettext("link", "Active"),
|
||||||
|
};
|
||||||
|
if link.has_password {
|
||||||
|
[state, gettext("Password protected")].join(" · ")
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `item` passes `filter` at `now` (epoch seconds).
|
||||||
|
pub(crate) fn matches_filter(item: &SharedItem, filter: ShareFilter, now: i64) -> bool {
|
||||||
|
match filter {
|
||||||
|
ShareFilter::All => true,
|
||||||
|
ShareFilter::Links => item.link.is_some(),
|
||||||
|
ShareFilter::People => item.member_count + item.invite_count > 0,
|
||||||
|
ShareFilter::Expired => item.link.as_ref().is_some_and(|l| link_expired(l, now)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show a status page in place of the Shared (by-me) list.
|
||||||
|
pub(crate) fn shared_by_me_status(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
icon: &str,
|
||||||
|
title: &str,
|
||||||
|
description: &str,
|
||||||
|
retry: bool,
|
||||||
|
) {
|
||||||
|
ui.shared_by_me.status.set_icon_name(Some(icon));
|
||||||
|
ui.shared_by_me.status.set_title(title);
|
||||||
|
ui.shared_by_me.status.set_description(Some(description));
|
||||||
|
ui.shared_by_me.retry.set_visible(retry);
|
||||||
|
ui.shared_by_me.content.set_visible_child_name("status");
|
||||||
|
ui.shared_by_me.subtitle.set_subtitle("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the shared-by-me listing and repaint the page.
|
||||||
|
pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
||||||
|
cancel_file_thumbnails(ui);
|
||||||
|
let ui_p = ui.clone();
|
||||||
|
let ticket = ui.shared_by_me.loader.refresh(move || {
|
||||||
|
ui_p.shared_by_me.files.model.remove_all();
|
||||||
|
shared_by_me_status(
|
||||||
|
&ui_p,
|
||||||
|
"pdfs-share-symbolic",
|
||||||
|
&gettext("Loading…"),
|
||||||
|
&gettext("Reading what you've shared."),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.busy_begin();
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
if !ticket.is_current() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !matches!(result, Ok(Ok(Response::SharedByMe { .. }))) {
|
||||||
|
ui.shared_by_me.files.model.remove_all();
|
||||||
|
}
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::SharedByMe { items })) => {
|
||||||
|
let changed = changed_items(&ui.shared_by_me.items.borrow(), &items);
|
||||||
|
*ui.shared_by_me.items.borrow_mut() = items.clone();
|
||||||
|
repaint_shared_by_me(&ui, &items);
|
||||||
|
rebind_rows(&ui, &changed);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
|
||||||
|
&ui,
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
&gettext("Couldn't read your shares"),
|
||||||
|
&message,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
Ok(Ok(_)) => shared_by_me_status(
|
||||||
|
&ui,
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
&gettext("Couldn't read your shares"),
|
||||||
|
&gettext("Unexpected reply from the Proton Drive service."),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
Ok(Err(_)) | Err(_) => {
|
||||||
|
shared_by_me_unreachable(&ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The daemon didn't answer the Shared (by-me) page.
|
||||||
|
pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
|
||||||
|
service_unreachable(ui, "sharedbyme", shared_by_me_status, load_shared_by_me);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the items that pass the header's filter.
|
||||||
|
pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||||
|
if items.is_empty() {
|
||||||
|
shared_by_me_status(
|
||||||
|
ui,
|
||||||
|
"pdfs-share-symbolic",
|
||||||
|
&gettext("Nothing shared yet"),
|
||||||
|
&gettext("Items you share with people or by link show up here."),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
ui.shared_by_me.files.model.remove_all();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let filter = SHARE_FILTERS
|
||||||
|
.get(ui.shared_by_me.filter.selected() as usize)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(ShareFilter::All);
|
||||||
|
let now = glib::real_time() / 1_000_000;
|
||||||
|
let shown: Vec<DirEntry> = items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| matches_filter(item, filter, now))
|
||||||
|
.map(shared_item_as_entry)
|
||||||
|
.collect();
|
||||||
|
if shown.is_empty() {
|
||||||
|
shared_by_me_status(
|
||||||
|
ui,
|
||||||
|
"pdfs-share-symbolic",
|
||||||
|
&gettext("No Matching Items"),
|
||||||
|
&gettext("None of your shared items match this filter."),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
ui.shared_by_me.files.model.remove_all();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.shared_by_me.content.set_visible_child_name("list");
|
||||||
|
ui.shared_by_me.subtitle.set_subtitle(&ngettext_f(
|
||||||
|
"{n} item",
|
||||||
|
"{n} items",
|
||||||
|
shown.len() as u64,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
// Only the rows that changed are swapped, so a refresh keeps the selection
|
||||||
|
// and the scroll position.
|
||||||
|
replace_items(&ui.shared_by_me.files.model, &shown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The uids in `new` whose people or link differ from `old`. Their rows show
|
||||||
|
/// the same [`DirEntry`] as before, so the list would not repaint them.
|
||||||
|
fn changed_items(old: &[SharedItem], new: &[SharedItem]) -> HashSet<String> {
|
||||||
|
new.iter()
|
||||||
|
.filter(|item| old.iter().find(|o| o.uid == item.uid) != Some(*item))
|
||||||
|
.map(|item| item.uid.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Have the list bind the rows of `uids` again, to repaint their columns.
|
||||||
|
fn rebind_rows(ui: &Rc<Ui>, uids: &HashSet<String>) {
|
||||||
|
let model = &ui.shared_by_me.files.model;
|
||||||
|
for pos in 0..model.n_items() {
|
||||||
|
if entry_at(Some(model), pos).is_some_and(|e| uids.contains(&e.uid)) {
|
||||||
|
model.items_changed(pos, 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shared item's right-click menu: manage it, open it, its link, and Stop
|
||||||
|
/// Sharing. Manage Access opens the Share dialog, which addresses a pathless
|
||||||
|
/// node by uid, so every shared item can be managed from here.
|
||||||
|
fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&gettext("Manage Access…"), move || {
|
||||||
|
open_share_dialog(&ui_c, &entry_c)
|
||||||
|
});
|
||||||
|
// A node the daemon can place in my tree opens like it would in My Files.
|
||||||
|
if !entry.path.is_empty() {
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&pgettext("verb", "Open"), move || {
|
||||||
|
open_shared_by_me(&ui_c, &entry_c)
|
||||||
|
});
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&gettext("Show in My Files"), move || {
|
||||||
|
show_in_my_files(&ui_c, &entry_c)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let now = glib::real_time() / 1_000_000;
|
||||||
|
let url = find_item(&ui.shared_by_me.items, &entry.uid)
|
||||||
|
.and_then(|item| item.link)
|
||||||
|
.filter(|link| !link_expired(link, now))
|
||||||
|
.and_then(|link| link.url);
|
||||||
|
if let Some(url) = url {
|
||||||
|
menu.section();
|
||||||
|
let (ui_c, url_c) = (ui.clone(), url.clone());
|
||||||
|
menu.item(&gettext("Copy Link"), move || {
|
||||||
|
ui_c.stack.clipboard().set_text(&url_c);
|
||||||
|
toast(&ui_c, &gettext("Link copied"));
|
||||||
|
});
|
||||||
|
menu.item(&gettext("Open Link"), move || open_uri(&url));
|
||||||
|
}
|
||||||
|
menu.section();
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&gettext("Stop Sharing…"), move || {
|
||||||
|
prompt_stop_sharing(&ui_c, std::slice::from_ref(&entry_c))
|
||||||
|
});
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_by_me_bulk_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
menu.labelled_section(&ngettext_f(
|
||||||
|
"{n} selected",
|
||||||
|
"{n} selected",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Stop Sharing…"), move || {
|
||||||
|
prompt_stop_sharing(&ui_c, &entries)
|
||||||
|
});
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_by_me_background_menu(ui: &Rc<Ui>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Select All"), move || {
|
||||||
|
ui_c.shared_by_me.files.selection.select_all();
|
||||||
|
});
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c));
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm, then remove every member, invitation and the public link.
|
||||||
|
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
|
let body = match entries {
|
||||||
|
// Translators: {name} is the shared item's name.
|
||||||
|
[one] => gettext_f(
|
||||||
|
"Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.",
|
||||||
|
&[("name", &one.name)],
|
||||||
|
),
|
||||||
|
_ => ngettext_f(
|
||||||
|
"Everyone you invited loses access to {n} item, pending invitations are withdrawn and its public link stops working.",
|
||||||
|
"Everyone you invited loses access to {n} items, pending invitations are withdrawn and their public links stop working.",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Stop Sharing?"))
|
||||||
|
.body(body)
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
|
dialog.add_response("stop", &gettext("Stop Sharing"));
|
||||||
|
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
let done = match entries {
|
||||||
|
// Translators: {name} is the item's name.
|
||||||
|
[one] => gettext_f("Stopped sharing {name}", &[("name", &one.name)]),
|
||||||
|
_ => ngettext_f(
|
||||||
|
"Stopped sharing {n} item",
|
||||||
|
"Stopped sharing {n} items",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||||
|
dialog.connect_response(Some("stop"), move |_, _| {
|
||||||
|
let done = done.clone();
|
||||||
|
run_requests(
|
||||||
|
&ui_c,
|
||||||
|
uids.iter()
|
||||||
|
.map(|uid| Request::StopSharingByUid { uid: uid.clone() })
|
||||||
|
.collect(),
|
||||||
|
gettext_noop("Couldn't stop sharing"),
|
||||||
|
move |ui| {
|
||||||
|
reload_listing(ui);
|
||||||
|
toast(ui, &done);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
dialog.present(ui_window(ui).as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a shared item: a folder in My Files, a file the way My Files would.
|
||||||
|
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
if entry.is_dir {
|
||||||
|
open_in_my_files(ui, entry.path.clone());
|
||||||
|
} else {
|
||||||
|
activate_entry(ui, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Go to the folder holding a shared item in My Files.
|
||||||
|
pub(crate) fn show_in_my_files(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
let parent = entry
|
||||||
|
.path
|
||||||
|
.rsplit_once('/')
|
||||||
|
.map(|(parent, _)| parent.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
open_in_my_files(ui, parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a [`DirEntry`] from a [`SharedItem`], for the file list and the
|
||||||
|
/// Share dialog, which addresses a pathless node by uid.
|
||||||
pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||||
DirEntry {
|
DirEntry {
|
||||||
name: item.name.clone(),
|
name: item.name.clone(),
|
||||||
@@ -369,5 +616,80 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
|||||||
shared_by: String::new(),
|
shared_by: String::new(),
|
||||||
shared_at: 0,
|
shared_at: 0,
|
||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn item(members: usize, invites: usize, link: Option<PublicLinkInfo>) -> SharedItem {
|
||||||
|
SharedItem {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
name: "Budget.ods".into(),
|
||||||
|
is_dir: false,
|
||||||
|
modified: 0,
|
||||||
|
path: "Work/Budget.ods".into(),
|
||||||
|
member_count: members,
|
||||||
|
invite_count: invites,
|
||||||
|
link,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link(expires: Option<i64>, has_password: bool) -> PublicLinkInfo {
|
||||||
|
PublicLinkInfo {
|
||||||
|
id: "l".into(),
|
||||||
|
url: Some("https://drive.proton.me/urls/X#y".into()),
|
||||||
|
role: "viewer".into(),
|
||||||
|
expires,
|
||||||
|
has_password,
|
||||||
|
created: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_says_whether_it_still_works() {
|
||||||
|
assert_eq!(link_label(None, 100), "—");
|
||||||
|
assert_eq!(link_label(Some(&link(None, false)), 100), "Active");
|
||||||
|
assert_eq!(link_label(Some(&link(Some(100), false)), 100), "Expired");
|
||||||
|
assert!(link_label(Some(&link(Some(200), false)), 100).starts_with("Expires "));
|
||||||
|
assert_eq!(
|
||||||
|
link_label(Some(&link(None, true)), 100),
|
||||||
|
"Active · Password protected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_filters_pick_links_people_and_expired_links() {
|
||||||
|
let people = item(2, 0, None);
|
||||||
|
let invited = item(0, 1, None);
|
||||||
|
let live = item(0, 0, Some(link(None, false)));
|
||||||
|
let expired = item(0, 0, Some(link(Some(50), false)));
|
||||||
|
let pick = |filter| {
|
||||||
|
[&people, &invited, &live, &expired]
|
||||||
|
.iter()
|
||||||
|
.map(|i| matches_filter(i, filter, 100))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(pick(ShareFilter::All), [true, true, true, true]);
|
||||||
|
assert_eq!(pick(ShareFilter::Links), [false, false, true, true]);
|
||||||
|
assert_eq!(pick(ShareFilter::People), [true, true, false, false]);
|
||||||
|
assert_eq!(pick(ShareFilter::Expired), [false, false, false, true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_location_is_the_folder_in_my_files() {
|
||||||
|
assert_eq!(location_label("Work/Budget.ods"), "Work");
|
||||||
|
assert_eq!(location_label("Budget.ods"), "My files");
|
||||||
|
assert_eq!(location_label(""), "—");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn people_counts_members_and_pending_invitations() {
|
||||||
|
assert_eq!(people_label(&item(0, 0, None)), "—");
|
||||||
|
assert_eq!(people_label(&item(1, 0, None)), "1 person");
|
||||||
|
assert_eq!(people_label(&item(2, 3, None)), "2 people · 3 pending");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,8 @@ pub(crate) struct StatusState {
|
|||||||
/// Sidebar footer: the one-line sync state. The mount is driven by the
|
/// Sidebar footer: the one-line sync state. The mount is driven by the
|
||||||
/// systemd user service, not by the user — so this only reports.
|
/// systemd user service, not by the user — so this only reports.
|
||||||
pub(crate) status_icon: gtk4::Image,
|
pub(crate) status_icon: gtk4::Image,
|
||||||
|
/// Stands in for [`Self::status_icon`] until the first status arrives.
|
||||||
|
pub(crate) status_spinner: Spinner,
|
||||||
pub(crate) status_title: gtk4::Label,
|
pub(crate) status_title: gtk4::Label,
|
||||||
pub(crate) status_detail: gtk4::Label,
|
pub(crate) status_detail: gtk4::Label,
|
||||||
pub(crate) cache_bar: gtk4::ProgressBar,
|
pub(crate) cache_bar: gtk4::ProgressBar,
|
||||||
@@ -44,8 +46,10 @@ pub(crate) struct StatusState {
|
|||||||
pub(crate) download_limit_row: adw::SpinRow,
|
pub(crate) download_limit_row: adw::SpinRow,
|
||||||
/// Shows where the primary mount lives; its Change button picks a new one.
|
/// Shows where the primary mount lives; its Change button picks a new one.
|
||||||
pub(crate) mountpoint_row: adw::ActionRow,
|
pub(crate) mountpoint_row: adw::ActionRow,
|
||||||
/// "Proton purple accent" toggle. Guarded by [`Self::settings_suppress`].
|
/// "Proton theme" toggle. Guarded by [`Self::settings_suppress`].
|
||||||
pub(crate) accent_row: adw::SwitchRow,
|
pub(crate) theme_row: adw::SwitchRow,
|
||||||
|
/// Street map on the Places map. Guarded by [`Self::settings_suppress`].
|
||||||
|
pub(crate) online_map_row: adw::SwitchRow,
|
||||||
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
|
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
|
||||||
pub(crate) tray_row: adw::SwitchRow,
|
pub(crate) tray_row: adw::SwitchRow,
|
||||||
/// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by
|
/// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by
|
||||||
@@ -80,12 +84,29 @@ pub(crate) struct StatusState {
|
|||||||
/// How many transfers were in flight on the previous poll. A drop to zero is
|
/// How many transfers were in flight on the previous poll. A drop to zero is
|
||||||
/// what "sync complete" means; there's no completion event on the wire.
|
/// what "sync complete" means; there's no completion event on the wire.
|
||||||
pub(crate) active_transfers: Cell<usize>,
|
pub(crate) active_transfers: Cell<usize>,
|
||||||
|
/// The run of transfers since the count last left zero, for deciding
|
||||||
|
/// whether its end is worth a "Sync complete" notification.
|
||||||
|
pub(crate) transfer_batch: RefCell<Option<TransferBatch>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One run of transfers, from the first file to the count falling back to 0.
|
||||||
|
pub(crate) struct TransferBatch {
|
||||||
|
started: Instant,
|
||||||
|
/// Every file seen in flight during the run, so the notification can count
|
||||||
|
/// files rather than the handful still moving on the last poll.
|
||||||
|
names: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A run of transfers earns a "Sync complete" notification only when it moved
|
||||||
|
/// at least this many files or lasted at least [`SYNC_NOTIFY_MIN_DURATION`].
|
||||||
|
/// Saving one document at a time would otherwise notify on every save.
|
||||||
|
const SYNC_NOTIFY_MIN_FILES: usize = 5;
|
||||||
|
const SYNC_NOTIFY_MIN_DURATION: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's
|
/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's
|
||||||
/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
|
/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
|
||||||
pub(crate) struct PinRow {
|
pub(crate) struct PinRow {
|
||||||
pub(crate) row: adw::ActionRow,
|
pub(crate) row: gtk4::ListBoxRow,
|
||||||
/// The unpin button, absent on the placeholder row.
|
/// The unpin button, absent on the placeholder row.
|
||||||
pub(crate) unpin: Option<gtk4::Button>,
|
pub(crate) unpin: Option<gtk4::Button>,
|
||||||
}
|
}
|
||||||
@@ -119,6 +140,7 @@ pub(crate) struct MainWidgets {
|
|||||||
pub(crate) account_name: gtk4::Label,
|
pub(crate) account_name: gtk4::Label,
|
||||||
pub(crate) avatar: adw::Avatar,
|
pub(crate) avatar: adw::Avatar,
|
||||||
pub(crate) status_icon: gtk4::Image,
|
pub(crate) status_icon: gtk4::Image,
|
||||||
|
pub(crate) status_spinner: Spinner,
|
||||||
pub(crate) status_title: gtk4::Label,
|
pub(crate) status_title: gtk4::Label,
|
||||||
pub(crate) status_detail: gtk4::Label,
|
pub(crate) status_detail: gtk4::Label,
|
||||||
pub(crate) quota_box: gtk4::Box,
|
pub(crate) quota_box: gtk4::Box,
|
||||||
@@ -139,7 +161,8 @@ pub(crate) struct MainWidgets {
|
|||||||
/// Shows the active mountpoint; its suffix button picks a new one.
|
/// Shows the active mountpoint; its suffix button picks a new one.
|
||||||
pub(crate) mountpoint_row: adw::ActionRow,
|
pub(crate) mountpoint_row: adw::ActionRow,
|
||||||
pub(crate) mountpoint_button: gtk4::Button,
|
pub(crate) mountpoint_button: gtk4::Button,
|
||||||
pub(crate) accent_row: adw::SwitchRow,
|
pub(crate) theme_row: adw::SwitchRow,
|
||||||
|
pub(crate) online_map_row: adw::SwitchRow,
|
||||||
pub(crate) tray_row: adw::SwitchRow,
|
pub(crate) tray_row: adw::SwitchRow,
|
||||||
pub(crate) language_row: adw::ComboRow,
|
pub(crate) language_row: adw::ComboRow,
|
||||||
}
|
}
|
||||||
@@ -175,9 +198,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
mountpoint_row.add_css_class("property");
|
mountpoint_row.add_css_class("property");
|
||||||
let mountpoint_button = gtk4::Button::builder()
|
let mountpoint_button = gtk4::Button::builder()
|
||||||
.label(gettext("Change…"))
|
.label(gettext("Change…"))
|
||||||
.tooltip_text(gettext(
|
.tooltip_text(gettext("Choose where My files appears on this computer"))
|
||||||
"Choose a different folder for the Proton Drive mount",
|
|
||||||
))
|
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
mountpoint_button.add_css_class("flat");
|
mountpoint_button.add_css_class("flat");
|
||||||
@@ -207,13 +228,20 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
let appearance_group = adw::PreferencesGroup::builder()
|
let appearance_group = adw::PreferencesGroup::builder()
|
||||||
.title(gettext("Appearance"))
|
.title(gettext("Appearance"))
|
||||||
.build();
|
.build();
|
||||||
let accent_row = adw::SwitchRow::builder()
|
let theme_row = adw::SwitchRow::builder()
|
||||||
.title(gettext("Proton purple accent"))
|
.title(gettext("Proton theme"))
|
||||||
.subtitle(gettext(
|
.subtitle(gettext(
|
||||||
"Use the Proton brand color instead of the system accent color",
|
"Use Proton's colors instead of the system theme and accent color",
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
appearance_group.add(&accent_row);
|
appearance_group.add(&theme_row);
|
||||||
|
let online_map_row = adw::SwitchRow::builder()
|
||||||
|
.title(gettext("Street map for Places"))
|
||||||
|
.subtitle(gettext(
|
||||||
|
"Show streets and town names from OpenFreeMap. It learns which parts of the world you look at.",
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
appearance_group.add(&online_map_row);
|
||||||
let tray_row = adw::SwitchRow::builder()
|
let tray_row = adw::SwitchRow::builder()
|
||||||
.title(gettext("Show tray icon"))
|
.title(gettext("Show tray icon"))
|
||||||
.subtitle(gettext("Sync status and quick actions in the panel"))
|
.subtitle(gettext("Sync status and quick actions in the panel"))
|
||||||
@@ -300,7 +328,10 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
prefs.add(&storage);
|
prefs.add(&storage);
|
||||||
|
|
||||||
// ---- Sidebar footer
|
// ---- Sidebar footer
|
||||||
let status_icon = gtk4::Image::from_icon_name("content-loading-symbolic");
|
// Until the first status arrives there is no state to show, only that the
|
||||||
|
// app is still asking.
|
||||||
|
let status_icon = gtk4::Image::builder().visible(false).build();
|
||||||
|
let status_spinner = spinner();
|
||||||
let status_title = gtk4::Label::builder()
|
let status_title = gtk4::Label::builder()
|
||||||
.label(gettext("Connecting…"))
|
.label(gettext("Connecting…"))
|
||||||
.halign(gtk4::Align::Start)
|
.halign(gtk4::Align::Start)
|
||||||
@@ -319,6 +350,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
status_text.append(&status_title);
|
status_text.append(&status_title);
|
||||||
status_text.append(&status_detail);
|
status_text.append(&status_detail);
|
||||||
let status_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
let status_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||||
|
status_content.append(&status_spinner);
|
||||||
status_content.append(&status_icon);
|
status_content.append(&status_icon);
|
||||||
status_content.append(&status_text);
|
status_content.append(&status_text);
|
||||||
let status_button = gtk4::Button::builder()
|
let status_button = gtk4::Button::builder()
|
||||||
@@ -380,6 +412,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
account_name,
|
account_name,
|
||||||
avatar,
|
avatar,
|
||||||
status_icon,
|
status_icon,
|
||||||
|
status_spinner,
|
||||||
status_title,
|
status_title,
|
||||||
status_detail,
|
status_detail,
|
||||||
quota_box,
|
quota_box,
|
||||||
@@ -395,7 +428,8 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
purge_button,
|
purge_button,
|
||||||
mountpoint_row,
|
mountpoint_row,
|
||||||
mountpoint_button,
|
mountpoint_button,
|
||||||
accent_row,
|
theme_row,
|
||||||
|
online_map_row,
|
||||||
tray_row,
|
tray_row,
|
||||||
language_row,
|
language_row,
|
||||||
}
|
}
|
||||||
@@ -455,7 +489,7 @@ pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
|
|||||||
pub(crate) const MIB: f64 = 1024.0 * 1024.0;
|
pub(crate) const MIB: f64 = 1024.0 * 1024.0;
|
||||||
|
|
||||||
/// Wire the Preferences controls: the cache-budget editor, the purge button, the
|
/// Wire the Preferences controls: the cache-budget editor, the purge button, the
|
||||||
/// start-on-login switch, the mountpoint chooser and the accent toggle. Initial widget state is
|
/// start-on-login switch, the mountpoint chooser and the theme toggle. Initial widget state is
|
||||||
/// read once from config / systemd here (the refresh loop owns only the live
|
/// read once from config / systemd here (the refresh loop owns only the live
|
||||||
/// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the
|
/// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the
|
||||||
/// programmatic populate so the change handlers don't fire on it.
|
/// programmatic populate so the change handlers don't fire on it.
|
||||||
@@ -480,10 +514,21 @@ pub(crate) fn wire_settings(
|
|||||||
ui.status
|
ui.status
|
||||||
.mountpoint_row
|
.mountpoint_row
|
||||||
.set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string());
|
.set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string());
|
||||||
ui.status.autostart_row.set_active(service::is_enabled());
|
// systemctl can take a while, so ask off the main thread and fill the
|
||||||
|
// switch in when it answers, without firing its change handler.
|
||||||
|
let ui_auto = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let enabled = gio::spawn_blocking(service::is_enabled)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
let suppress = ui_auto.status.settings_suppress.replace(true);
|
||||||
|
ui_auto.status.autostart_row.set_active(enabled);
|
||||||
|
ui_auto.status.settings_suppress.set(suppress);
|
||||||
|
});
|
||||||
ui.status
|
ui.status
|
||||||
.accent_row
|
.theme_row
|
||||||
.set_active(config.proton_accent.unwrap_or(false));
|
.set_active(config.proton_theme.unwrap_or(false));
|
||||||
|
ui.status.online_map_row.set_active(config.online_map);
|
||||||
ui.status.tray_row.set_active(!config.tray_hidden);
|
ui.status.tray_row.set_active(!config.tray_hidden);
|
||||||
ui.status
|
ui.status
|
||||||
.language_row
|
.language_row
|
||||||
@@ -575,30 +620,60 @@ pub(crate) fn wire_settings(
|
|||||||
if ui_auto.status.settings_suppress.get() {
|
if ui_auto.status.settings_suppress.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if row.is_active() {
|
let enable = row.is_active();
|
||||||
service::enable();
|
gio::spawn_blocking(move || {
|
||||||
} else {
|
if enable {
|
||||||
service::disable();
|
service::enable();
|
||||||
}
|
} else {
|
||||||
|
service::disable();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_mp = ui.clone();
|
let ui_mp = ui.clone();
|
||||||
mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
|
mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
|
||||||
|
|
||||||
// Accent: saved with the rest of the config, applied right away.
|
// Theme: saved with the rest of the config, applied right away.
|
||||||
let ui_accent = ui.clone();
|
let ui_theme = ui.clone();
|
||||||
ui.status.accent_row.connect_active_notify(move |row| {
|
ui.status.theme_row.connect_active_notify(move |row| {
|
||||||
if ui_accent.status.settings_suppress.get() {
|
if ui_theme.status.settings_suppress.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let on = row.is_active();
|
let on = row.is_active();
|
||||||
set_proton_accent(on);
|
set_proton_theme(on);
|
||||||
let mut config = ui_accent.dirs.load_config();
|
// Folders swap their icon with the theme; have every row bound again.
|
||||||
config.proton_accent = Some(on);
|
for list in file_lists(&ui_theme) {
|
||||||
if let Err(e) = ui_accent.dirs.save_config(&config) {
|
let rows = list.model.n_items();
|
||||||
|
list.model.items_changed(0, rows, rows);
|
||||||
|
}
|
||||||
|
let mut config = ui_theme.dirs.load_config();
|
||||||
|
config.proton_theme = Some(on);
|
||||||
|
if let Err(e) = ui_theme.dirs.save_config(&config) {
|
||||||
toast_error(
|
toast_error(
|
||||||
&ui_accent,
|
&ui_theme,
|
||||||
&gettext("Couldn't save the accent color"),
|
&gettext("Couldn't save the theme"),
|
||||||
|
&e.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Street map: saved, and the Places map switches over right away.
|
||||||
|
let ui_map = ui.clone();
|
||||||
|
ui.status.online_map_row.connect_active_notify(move |row| {
|
||||||
|
if ui_map.status.settings_suppress.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let on = row.is_active();
|
||||||
|
ui_map
|
||||||
|
.gallery
|
||||||
|
.places_map
|
||||||
|
.set_online(on, ui_map.dirs.cache_dir());
|
||||||
|
let mut config = ui_map.dirs.load_config();
|
||||||
|
config.online_map = on;
|
||||||
|
if let Err(e) = ui_map.dirs.save_config(&config) {
|
||||||
|
toast_error(
|
||||||
|
&ui_map,
|
||||||
|
&gettext("Couldn't save the map setting"),
|
||||||
&e.to_string(),
|
&e.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -669,7 +744,11 @@ pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: String, failed:
|
|||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, &failed, &message, kind)
|
toast_failure(&ui, &failed, &message, kind)
|
||||||
}
|
}
|
||||||
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&failed,
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -679,7 +758,7 @@ pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: String, failed:
|
|||||||
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
let dialog = gtk4::FileDialog::builder()
|
let dialog = gtk4::FileDialog::builder()
|
||||||
.title(gettext("Choose mountpoint folder"))
|
.title(gettext("Choose Proton Drive Folder"))
|
||||||
.build();
|
.build();
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
|
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
|
||||||
@@ -691,34 +770,34 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
|||||||
let mut config = ui.dirs.load_config();
|
let mut config = ui.dirs.load_config();
|
||||||
config.mountpoint = Some(path_str.clone());
|
config.mountpoint = Some(path_str.clone());
|
||||||
if let Err(e) = ui.dirs.save_config(&config) {
|
if let Err(e) = ui.dirs.save_config(&config) {
|
||||||
toast_error(&ui, &gettext("Couldn't save mountpoint"), &e.to_string());
|
toast_error(&ui, &gettext("Couldn't save the folder"), &e.to_string());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.status.mountpoint_row.set_subtitle(&path_str);
|
ui.status.mountpoint_row.set_subtitle(&path_str);
|
||||||
// The Locations page shows the same path as a row title; its cached rows
|
// The Locations page shows the same path as a row title. Any other page
|
||||||
// are now stale whether or not it is the visible page.
|
// reads it again on the next visit.
|
||||||
ui.locations.loaded_at.set(None);
|
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
||||||
load_locations(&ui);
|
load_locations(&ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
||||||
let confirm = adw::AlertDialog::builder()
|
let confirm = adw::AlertDialog::builder()
|
||||||
.heading(gettext("Restart to apply"))
|
.heading(gettext("Restart Proton Drive?"))
|
||||||
// Translators: {path} is the folder the drive is mounted at.
|
// Translators: {path} is the folder the drive is mounted at.
|
||||||
.body(gettext_f(
|
.body(gettext_f(
|
||||||
"The mountpoint is now “{path}”. Restart the Drive mount to use it?",
|
"My files now appears in “{path}” after Proton Drive restarts.",
|
||||||
&[("path", &path_str)],
|
&[("path", &path_str)],
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
confirm.add_response("later", &gettext("Later"));
|
confirm.add_response("later", &gettext("Later"));
|
||||||
confirm.add_response("restart", &gettext("Restart now"));
|
confirm.add_response("restart", &gettext("Restart Now"));
|
||||||
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
|
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
|
||||||
confirm.set_default_response(Some("restart"));
|
confirm.set_default_response(Some("restart"));
|
||||||
confirm.set_close_response("later");
|
confirm.set_close_response("later");
|
||||||
confirm.connect_response(None, |_, resp| {
|
let ui_restart = ui.clone();
|
||||||
|
confirm.connect_response(None, move |_, resp| {
|
||||||
if resp == "restart" {
|
if resp == "restart" {
|
||||||
service::restart();
|
restart_service_then(&ui_restart, |_| {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
confirm.present(ui_window(&ui).as_ref());
|
confirm.present(ui_window(&ui).as_ref());
|
||||||
@@ -730,26 +809,27 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
|||||||
/// reload the page.
|
/// reload the page.
|
||||||
pub(crate) fn wire_retry(ui: &Rc<Ui>) {
|
pub(crate) fn wire_retry(ui: &Rc<Ui>) {
|
||||||
let ui_browser = ui.clone();
|
let ui_browser = ui.clone();
|
||||||
ui.browser.retry.clone().connect_clicked(move |_| {
|
ui.browser
|
||||||
service::restart();
|
.retry
|
||||||
load_browser(&ui_browser);
|
.clone()
|
||||||
});
|
.connect_clicked(move |_| restart_service_then(&ui_browser, load_browser));
|
||||||
let ui_gallery = ui.clone();
|
let ui_gallery = ui.clone();
|
||||||
ui.gallery.retry.clone().connect_clicked(move |_| {
|
ui.gallery
|
||||||
service::restart();
|
.retry
|
||||||
load_gallery(&ui_gallery, false);
|
.clone()
|
||||||
});
|
.connect_clicked(move |_| restart_service_then(&ui_gallery, |ui| load_gallery(ui, false)));
|
||||||
let ui_trash = ui.clone();
|
let ui_trash = ui.clone();
|
||||||
ui.trash.retry.clone().connect_clicked(move |_| {
|
ui.trash
|
||||||
service::restart();
|
.retry
|
||||||
load_trash(&ui_trash);
|
.clone()
|
||||||
});
|
.connect_clicked(move |_| restart_service_then(&ui_trash, load_trash));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Repaint the window from the cached login identity, then kick an async mount-
|
/// Repaint the window from the cached login identity, then ask the daemon for
|
||||||
/// status fetch. Runs on the 2s tick: the identity check is instant (no keyring),
|
/// its status and transfers. Runs at startup, after a login and after a service
|
||||||
/// and the status round-trip is offloaded to a worker so the main loop never
|
/// restart; the daemon's event feed keeps both current in between. The identity
|
||||||
/// blocks on a slow or wedged daemon.
|
/// check is instant (no keyring), and the round-trips run on workers so the
|
||||||
|
/// main loop never blocks on a slow or wedged daemon.
|
||||||
pub(crate) fn refresh(ui: &Rc<Ui>) {
|
pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||||
// Login identity decides which page is shown. Read the cached session — set
|
// Login identity decides which page is shown. Read the cached session — set
|
||||||
// at startup and on login/logout — never the keyring.
|
// at startup and on login/logout — never the keyring.
|
||||||
@@ -782,38 +862,14 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
|||||||
|
|
||||||
refresh_status(ui);
|
refresh_status(ui);
|
||||||
refresh_transfers(ui);
|
refresh_transfers(ui);
|
||||||
// Both of these pages show work as it happens, so they follow the tick while
|
|
||||||
// they are on screen. Every other page loads on navigation only.
|
|
||||||
// A Takeout import outlives the page that started it, so it is polled
|
|
||||||
// wherever the user has navigated to — otherwise leaving the page means
|
|
||||||
// never being told it finished.
|
|
||||||
ui.gallery
|
|
||||||
.import_banner
|
|
||||||
.set_revealed(ui.takeout.running.get());
|
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("takeout") || ui.takeout.running.get() {
|
|
||||||
refresh_takeout(ui);
|
|
||||||
}
|
|
||||||
// The quota sits in the sidebar footer, visible on every page; its TTL
|
|
||||||
// keeps this from asking more than once a minute.
|
|
||||||
refresh_quota(ui);
|
|
||||||
match ui.stack.visible_child_name().as_deref() {
|
|
||||||
Some("locations") => {
|
|
||||||
refresh_locations(ui);
|
|
||||||
refresh_queue(ui);
|
|
||||||
refresh_conflicts(ui, false);
|
|
||||||
}
|
|
||||||
Some("activity") => refresh_activity(ui),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long a quota reading stays fresh. Account storage barely moves, so the
|
/// How long a quota reading stays fresh. Account storage barely moves, so a
|
||||||
/// active-page tick refetches it only this often rather than every 2s.
|
/// status change reads it again only this often.
|
||||||
const QUOTA_TTL: Duration = Duration::from_secs(60);
|
const QUOTA_TTL: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
/// Fetch the account quota (if the last reading is stale) and paint both the
|
/// Fetch the account quota (if the last reading is stale) and paint the
|
||||||
/// sidebar footer and Files status bar. A failed fetch leaves the last
|
/// sidebar footer. A failed fetch leaves the last good reading in place.
|
||||||
/// good reading in place.
|
|
||||||
pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
||||||
if ui.status.quota_inflight.get() {
|
if ui.status.quota_inflight.get() {
|
||||||
return;
|
return;
|
||||||
@@ -837,10 +893,6 @@ pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
|||||||
ui.status.quota_checked_at.set(Some(Instant::now()));
|
ui.status.quota_checked_at.set(Some(Instant::now()));
|
||||||
paint_account_quota(&ui, max_space, used_space);
|
paint_account_quota(&ui, max_space, used_space);
|
||||||
ui.status.quota_box.set_visible(true);
|
ui.status.quota_box.set_visible(true);
|
||||||
} else if ui.status.quota_checked_at.get().is_none() {
|
|
||||||
// Match Dolphin: capacity information does not occupy the bar until
|
|
||||||
// the backing observer has real figures.
|
|
||||||
ui.browser.quota_box.set_visible(false);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -849,15 +901,6 @@ fn paint_account_quota(ui: &Rc<Ui>, max_space: i64, used_space: i64) {
|
|||||||
let (fraction, text) = quota_display(max_space, used_space);
|
let (fraction, text) = quota_display(max_space, used_space);
|
||||||
ui.status.quota_bar.set_fraction(fraction);
|
ui.status.quota_bar.set_fraction(fraction);
|
||||||
ui.status.quota_label.set_text(&text);
|
ui.status.quota_label.set_text(&text);
|
||||||
if let Some((fraction, free_text, tooltip)) = quota_status_display(max_space, used_space) {
|
|
||||||
ui.browser.quota.set_fraction(fraction);
|
|
||||||
ui.browser.quota.set_tooltip_text(Some(&tooltip));
|
|
||||||
ui.browser.quota_text.set_label(&free_text);
|
|
||||||
ui.browser.quota_text.set_tooltip_text(Some(&tooltip));
|
|
||||||
ui.browser.quota_box.set_visible(true);
|
|
||||||
} else {
|
|
||||||
ui.browser.quota_box.set_visible(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
|
fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
|
||||||
@@ -887,34 +930,6 @@ fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dolphin's status bar shows a bare capacity bar followed by “X free”; the
|
|
||||||
/// full free/total/percentage sentence is a tooltip rather than inline bar text.
|
|
||||||
fn quota_status_display(max_space: i64, used_space: i64) -> Option<(f64, String, String)> {
|
|
||||||
if max_space <= 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let total = max_space as u64;
|
|
||||||
let used = (used_space.max(0) as u64).min(total);
|
|
||||||
let free = total.saturating_sub(used);
|
|
||||||
let fraction = used as f64 / total as f64;
|
|
||||||
let pct = (fraction * 100.0).round() as u64;
|
|
||||||
Some((
|
|
||||||
fraction,
|
|
||||||
// Translators: {size} is an amount of storage, such as "1.2 GiB".
|
|
||||||
gettext_f("{size} free", &[("size", &human_bytes(free))]),
|
|
||||||
// Translators: account storage. {free} and {total} are sizes such as
|
|
||||||
// "1.2 GiB"; {percent} is a whole number, followed by the percent sign.
|
|
||||||
gettext_f(
|
|
||||||
"{free} free out of {total} ({percent}% used)",
|
|
||||||
&[
|
|
||||||
("free", &human_bytes(free)),
|
|
||||||
("total", &human_bytes(total)),
|
|
||||||
("percent", &pct.to_string()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record the mount state seen by the last status poll: gate every control that
|
/// Record the mount state seen by the last status poll: gate every control that
|
||||||
/// needs a live daemon, and notify the desktop when the state actually flips.
|
/// needs a live daemon, and notify the desktop when the state actually flips.
|
||||||
///
|
///
|
||||||
@@ -944,20 +959,20 @@ pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
|||||||
notify(
|
notify(
|
||||||
"mount-state",
|
"mount-state",
|
||||||
&gettext("Proton Drive connected"),
|
&gettext("Proton Drive connected"),
|
||||||
&gettext("Your Drive is mounted and available."),
|
&gettext("Your files are available."),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
notify(
|
notify(
|
||||||
"mount-state",
|
"mount-state",
|
||||||
&gettext("Proton Drive disconnected"),
|
&gettext("Proton Drive disconnected"),
|
||||||
&gettext("The mount service stopped. Files aren't available until it restarts."),
|
&gettext("The Proton Drive service stopped. Files aren't available until it restarts."),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll the daemon's in-flight transfers on a worker thread and repaint the
|
/// Ask the daemon for its in-flight transfers on a worker thread and repaint the
|
||||||
/// Activity group. Independently inflight-guarded from [`refresh_status`] so the
|
/// Activity group. Independently inflight-guarded from [`refresh_status`] so the
|
||||||
/// two cheap polls on the 2s tick don't gate each other. The group hides itself
|
/// two requests don't gate each other; the event feed pushes later snapshots. The group hides itself
|
||||||
/// when nothing is moving, so an idle account shows no Activity section.
|
/// when nothing is moving, so an idle account shows no Activity section.
|
||||||
pub(crate) fn refresh_transfers(ui: &Rc<Ui>) {
|
pub(crate) fn refresh_transfers(ui: &Rc<Ui>) {
|
||||||
if ui.status.transfers_inflight.get() {
|
if ui.status.transfers_inflight.get() {
|
||||||
@@ -999,17 +1014,35 @@ pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[Job
|
|||||||
takeout_progress(ui, jobs);
|
takeout_progress(ui, jobs);
|
||||||
|
|
||||||
let previous = ui.status.active_transfers.replace(items.len());
|
let previous = ui.status.active_transfers.replace(items.len());
|
||||||
|
if !items.is_empty() {
|
||||||
|
let mut batch = ui.status.transfer_batch.borrow_mut();
|
||||||
|
let batch = batch.get_or_insert_with(|| TransferBatch {
|
||||||
|
started: Instant::now(),
|
||||||
|
names: HashSet::new(),
|
||||||
|
});
|
||||||
|
batch
|
||||||
|
.names
|
||||||
|
.extend(items.iter().map(|item| item.name.clone()));
|
||||||
|
}
|
||||||
if items.is_empty() && previous > 0 {
|
if items.is_empty() && previous > 0 {
|
||||||
notify(
|
// Nobody needs a notification for what they are watching happen.
|
||||||
"sync-complete",
|
let watching = ui_window(ui).is_some_and(|window| window.is_active());
|
||||||
&gettext("Sync complete"),
|
if let Some(batch) = ui.status.transfer_batch.take()
|
||||||
&ngettext_f(
|
&& !watching
|
||||||
"{n} file finished transferring.",
|
&& (batch.names.len() >= SYNC_NOTIFY_MIN_FILES
|
||||||
"{n} files finished transferring.",
|
|| batch.started.elapsed() >= SYNC_NOTIFY_MIN_DURATION)
|
||||||
previous as u64,
|
{
|
||||||
&[],
|
notify(
|
||||||
),
|
"sync-complete",
|
||||||
);
|
&gettext("Sync complete"),
|
||||||
|
&ngettext_f(
|
||||||
|
"{n} file finished transferring.",
|
||||||
|
"{n} files finished transferring.",
|
||||||
|
batch.names.len() as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
// A just-finished batch may have added files (bulk upload) the current
|
// A just-finished batch may have added files (bulk upload) the current
|
||||||
// listing doesn't show yet; refresh whichever listing is on screen.
|
// listing doesn't show yet; refresh whichever listing is on screen.
|
||||||
reload_listing(ui);
|
reload_listing(ui);
|
||||||
@@ -1132,7 +1165,10 @@ pub(crate) fn transfer_line(t: &TransferItem) -> ActivityLine {
|
|||||||
/// the mount line, cache bar and pin list on the reply. The daemon owns the cache
|
/// the mount line, cache bar and pin list on the reply. The daemon owns the cache
|
||||||
/// stats now (`used`/`budget`/`pins` ride along on [`Response::Status`]), so the
|
/// stats now (`used`/`budget`/`pins` ride along on [`Response::Status`]), so the
|
||||||
/// GUI never opens the on-disk cache itself. Skipped while a fetch is in flight so
|
/// GUI never opens the on-disk cache itself. Skipped while a fetch is in flight so
|
||||||
/// the tick can't stack threads on a stalled daemon.
|
/// a stalled daemon can't pile up threads.
|
||||||
|
///
|
||||||
|
/// The event feed pushes every later change through [`paint_status`]; this asks
|
||||||
|
/// only when the feed (re)connects or loses the daemon.
|
||||||
pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||||
if ui.status.status_inflight.get() {
|
if ui.status.status_inflight.get() {
|
||||||
return;
|
return;
|
||||||
@@ -1144,75 +1180,102 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
|||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
ui.status.status_inflight.set(false);
|
ui.status.status_inflight.set(false);
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::Status {
|
Ok(Ok(status @ Response::Status { .. })) => paint_status(&ui, status),
|
||||||
mountpoint,
|
// The daemon is up but stuck. Say so, and keep the mount state:
|
||||||
used,
|
// announcing "disconnected" for a slow reply would be wrong, and
|
||||||
budget,
|
// the feed asks again when it reconnects.
|
||||||
pins,
|
Ok(Err(e)) if e.is_timeout() => paint_sync_status(&ui, SyncState::NotResponding),
|
||||||
online,
|
_ => paint_disconnected(&ui),
|
||||||
pending_uploads,
|
|
||||||
pending_changes,
|
|
||||||
failing_ops,
|
|
||||||
failing_error,
|
|
||||||
paused,
|
|
||||||
paused_until,
|
|
||||||
..
|
|
||||||
})) => {
|
|
||||||
set_mounted(&ui, true);
|
|
||||||
// The queue is the more useful thing to say when it has anything
|
|
||||||
// in it: it is why a file that looks saved is not on the remote
|
|
||||||
// yet, and offline is usually the reason it is still queued.
|
|
||||||
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
|
||||||
let state = if paused {
|
|
||||||
SyncState::Paused {
|
|
||||||
until: paused_until,
|
|
||||||
queued,
|
|
||||||
}
|
|
||||||
} else if failing_ops > 0 {
|
|
||||||
SyncState::Attention {
|
|
||||||
count: failing_ops,
|
|
||||||
error: failing_error,
|
|
||||||
}
|
|
||||||
} else if !online {
|
|
||||||
SyncState::Offline { queued }
|
|
||||||
} else if let Some(queued) = queued {
|
|
||||||
SyncState::Syncing { queued }
|
|
||||||
} else {
|
|
||||||
SyncState::UpToDate { mountpoint }
|
|
||||||
};
|
|
||||||
paint_sync_status(&ui, state);
|
|
||||||
let fraction = if budget == 0 {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
(used as f64 / budget as f64).min(1.0)
|
|
||||||
};
|
|
||||||
ui.status.cache_bar.set_fraction(fraction);
|
|
||||||
// A 0 budget means *unlimited*, not a zero-byte cap — "of 0 B
|
|
||||||
// used" reads as a broken read-out, and there is no fraction to
|
|
||||||
// draw against no limit.
|
|
||||||
ui.status.cache_label.set_text(&if budget == 0 {
|
|
||||||
format!("{} cached — no limit set", human_bytes(used))
|
|
||||||
} else {
|
|
||||||
format!("{} of {} used", human_bytes(used), human_bytes(budget))
|
|
||||||
});
|
|
||||||
repaint_pins(&ui, &pins, true);
|
|
||||||
}
|
|
||||||
// Daemon unreachable (still starting, or down): report not-mounted and
|
|
||||||
// grey out the unpin buttons in place, but leave the last-known pin
|
|
||||||
// rows and cache read-out so the page doesn't flicker on a blip.
|
|
||||||
_ => {
|
|
||||||
set_mounted(&ui, false);
|
|
||||||
paint_sync_status(&ui, SyncState::Disconnected);
|
|
||||||
for r in ui.status.pin_rows.borrow().iter() {
|
|
||||||
if let Some(b) = &r.unpin {
|
|
||||||
b.set_sensitive(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paint a [`Response::Status`], fetched or pushed: the mount state, the sync
|
||||||
|
/// status, the cache bar and the pin list.
|
||||||
|
pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
||||||
|
let Response::Status {
|
||||||
|
mountpoint,
|
||||||
|
used,
|
||||||
|
budget,
|
||||||
|
pins,
|
||||||
|
online,
|
||||||
|
pending_uploads,
|
||||||
|
pending_changes,
|
||||||
|
failing_ops,
|
||||||
|
failing_error,
|
||||||
|
paused,
|
||||||
|
paused_until,
|
||||||
|
..
|
||||||
|
} = status
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
set_mounted(ui, true);
|
||||||
|
// The queue is the more useful thing to say when it has anything
|
||||||
|
// in it: it is why a file that looks saved is not on the remote
|
||||||
|
// yet, and offline is usually the reason it is still queued.
|
||||||
|
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
||||||
|
let state = if paused {
|
||||||
|
SyncState::Paused {
|
||||||
|
until: paused_until,
|
||||||
|
queued,
|
||||||
|
}
|
||||||
|
} else if failing_ops > 0 {
|
||||||
|
SyncState::Attention {
|
||||||
|
count: failing_ops,
|
||||||
|
error: failing_error,
|
||||||
|
}
|
||||||
|
} else if !online {
|
||||||
|
SyncState::Offline { queued }
|
||||||
|
} else if let Some(queued) = queued {
|
||||||
|
SyncState::Syncing { queued }
|
||||||
|
} else {
|
||||||
|
SyncState::UpToDate { mountpoint }
|
||||||
|
};
|
||||||
|
paint_sync_status(ui, state);
|
||||||
|
ui.locations.failing.set(failing_ops);
|
||||||
|
paint_sync_banner(ui);
|
||||||
|
let fraction = if budget == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(used as f64 / budget as f64).min(1.0)
|
||||||
|
};
|
||||||
|
ui.status.cache_bar.set_fraction(fraction);
|
||||||
|
// A 0 budget means *unlimited*, not a zero-byte cap — "of 0 B
|
||||||
|
// used" reads as a broken read-out, and there is no fraction to
|
||||||
|
// draw against no limit.
|
||||||
|
ui.status.cache_label.set_text(&if budget == 0 {
|
||||||
|
// Translators: {used} is a size such as "1.2 GiB".
|
||||||
|
gettext_f(
|
||||||
|
"{used} cached — no limit set",
|
||||||
|
&[("used", &human_bytes(used))],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Translators: {used} and {limit} are sizes such as "1.2 GiB".
|
||||||
|
gettext_f(
|
||||||
|
"{used} of {limit} used",
|
||||||
|
&[
|
||||||
|
("used", &human_bytes(used)),
|
||||||
|
("limit", &human_bytes(budget)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
});
|
||||||
|
repaint_pins(ui, &pins, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Daemon unreachable (still starting, or down): report not-mounted and grey
|
||||||
|
/// out the unpin buttons in place, but leave the last-known pin rows and cache
|
||||||
|
/// read-out so the page doesn't flicker on a blip.
|
||||||
|
pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
||||||
|
set_mounted(ui, false);
|
||||||
|
paint_sync_status(ui, SyncState::Disconnected);
|
||||||
|
for r in ui.status.pin_rows.borrow().iter() {
|
||||||
|
if let Some(b) = &r.unpin {
|
||||||
|
b.set_sensitive(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What the sidebar's status strip reports, most urgent first.
|
/// What the sidebar's status strip reports, most urgent first.
|
||||||
pub(crate) enum SyncState {
|
pub(crate) enum SyncState {
|
||||||
/// The user paused syncing; nothing goes up until it resumes. Outranks
|
/// The user paused syncing; nothing goes up until it resumes. Outranks
|
||||||
@@ -1240,13 +1303,15 @@ pub(crate) enum SyncState {
|
|||||||
},
|
},
|
||||||
/// No mount daemon answered: still starting, or the service is down.
|
/// No mount daemon answered: still starting, or the service is down.
|
||||||
Disconnected,
|
Disconnected,
|
||||||
|
/// The daemon is running but didn't answer in time.
|
||||||
|
NotResponding,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paint the sidebar status strip and the Sync page's status card: icon,
|
/// Paint the sidebar status strip and the Sync page's status card: icon,
|
||||||
/// one-line state, and a detail line.
|
/// one-line state, and a detail line.
|
||||||
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||||
let paused = matches!(state, SyncState::Paused { .. });
|
let paused = matches!(state, SyncState::Paused { .. });
|
||||||
let connected = !matches!(state, SyncState::Disconnected);
|
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
||||||
let (icon, class, title, detail) = match state {
|
let (icon, class, title, detail) = match state {
|
||||||
SyncState::Paused { until, queued } => (
|
SyncState::Paused { until, queued } => (
|
||||||
"media-playback-pause-symbolic",
|
"media-playback-pause-symbolic",
|
||||||
@@ -1284,13 +1349,13 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
|||||||
Some(queued.unwrap_or_else(|| gettext("Cached files only"))),
|
Some(queued.unwrap_or_else(|| gettext("Cached files only"))),
|
||||||
),
|
),
|
||||||
SyncState::Syncing { queued } => (
|
SyncState::Syncing { queued } => (
|
||||||
"emblem-synchronizing-symbolic",
|
"pdfs-sync-symbolic",
|
||||||
None,
|
None,
|
||||||
pgettext("state", "Syncing"),
|
pgettext("state", "Syncing"),
|
||||||
Some(queued),
|
Some(queued),
|
||||||
),
|
),
|
||||||
SyncState::UpToDate { mountpoint } => (
|
SyncState::UpToDate { mountpoint } => (
|
||||||
"emblem-ok-symbolic",
|
"pdfs-synced-symbolic",
|
||||||
Some("success"),
|
Some("success"),
|
||||||
gettext("Up to date"),
|
gettext("Up to date"),
|
||||||
Some(mountpoint),
|
Some(mountpoint),
|
||||||
@@ -1301,9 +1366,17 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
|||||||
gettext("Not connected"),
|
gettext("Not connected"),
|
||||||
Some(gettext("Proton Drive isn't running")),
|
Some(gettext("Proton Drive isn't running")),
|
||||||
),
|
),
|
||||||
|
SyncState::NotResponding => (
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
Some("warning"),
|
||||||
|
gettext("Not responding"),
|
||||||
|
Some(gettext("Proton Drive is taking too long to answer")),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
let image = &ui.status.status_icon;
|
let image = &ui.status.status_icon;
|
||||||
image.set_icon_name(Some(icon));
|
image.set_icon_name(Some(icon));
|
||||||
|
image.set_visible(true);
|
||||||
|
ui.status.status_spinner.set_visible(false);
|
||||||
for c in ["success", "warning", "error"] {
|
for c in ["success", "warning", "error"] {
|
||||||
image.remove_css_class(c);
|
image.remove_css_class(c);
|
||||||
}
|
}
|
||||||
@@ -1382,10 +1455,10 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
|||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
ui.status.pins_group.add(&row);
|
ui.status.pins_group.add(&row);
|
||||||
ui.status
|
ui.status.pin_rows.borrow_mut().push(PinRow {
|
||||||
.pin_rows
|
row: row.upcast(),
|
||||||
.borrow_mut()
|
unpin: None,
|
||||||
.push(PinRow { row, unpin: None });
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1437,7 +1510,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
|||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&ui,
|
&ui,
|
||||||
&gettext("Couldn't make it online only"),
|
&gettext("Couldn't make it online only"),
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1446,32 +1519,29 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
|||||||
|
|
||||||
ui.status.pins_group.add(&row);
|
ui.status.pins_group.add(&row);
|
||||||
ui.status.pin_rows.borrow_mut().push(PinRow {
|
ui.status.pin_rows.borrow_mut().push(PinRow {
|
||||||
row,
|
row: row.upcast(),
|
||||||
unpin: Some(unpin),
|
unpin: Some(unpin),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if pins.len() > PINS_COLLAPSED {
|
if pins.len() > PINS_COLLAPSED {
|
||||||
let row = adw::ActionRow::builder()
|
let title = if expanded {
|
||||||
.title(if expanded {
|
gettext("Show fewer")
|
||||||
gettext("Show fewer")
|
} else {
|
||||||
} else {
|
ngettext_f(
|
||||||
ngettext_f(
|
"Show all {n} file",
|
||||||
"Show all {n} file",
|
"Show all {n} files",
|
||||||
"Show all {n} files",
|
pins.len() as u64,
|
||||||
pins.len() as u64,
|
&[],
|
||||||
&[],
|
)
|
||||||
)
|
};
|
||||||
})
|
let icon = if expanded {
|
||||||
.activatable(true)
|
|
||||||
.build();
|
|
||||||
row.add_suffix(>k4::Image::from_icon_name(if expanded {
|
|
||||||
"go-up-symbolic"
|
"go-up-symbolic"
|
||||||
} else {
|
} else {
|
||||||
"go-down-symbolic"
|
"go-down-symbolic"
|
||||||
}));
|
};
|
||||||
let ui_more = ui.clone();
|
let ui_more = ui.clone();
|
||||||
row.connect_activated(move |_| {
|
let row = button_row(&title, icon, move || {
|
||||||
ui_more
|
ui_more
|
||||||
.status
|
.status
|
||||||
.pins_expanded
|
.pins_expanded
|
||||||
@@ -1492,7 +1562,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{quota_display, quota_status_display};
|
use super::quota_display;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn quota_display_reports_used_total_and_percentage() {
|
fn quota_display_reports_used_total_and_percentage() {
|
||||||
@@ -1507,18 +1577,4 @@ mod tests {
|
|||||||
assert_eq!(quota_display(0, -1), (0.0, "0 bytes used".to_string()));
|
assert_eq!(quota_display(0, -1), (0.0, "0 bytes used".to_string()));
|
||||||
assert_eq!(quota_display(100, 150).0, 1.0);
|
assert_eq!(quota_display(100, 150).0, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn quota_status_display_matches_dolphin_wording() {
|
|
||||||
let gib = 1024_i64.pow(3);
|
|
||||||
assert_eq!(
|
|
||||||
quota_status_display(4 * gib, gib),
|
|
||||||
Some((
|
|
||||||
0.25,
|
|
||||||
"3.0 GiB free".to_string(),
|
|
||||||
"3.0 GiB free out of 4.0 GiB (25% used)".to_string()
|
|
||||||
))
|
|
||||||
);
|
|
||||||
assert_eq!(quota_status_display(0, 0), None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -15,16 +15,16 @@
|
|||||||
//! Import — staging is local, so a mis-drop costs nothing.
|
//! Import — staging is local, so a mis-drop costs nothing.
|
||||||
//!
|
//!
|
||||||
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
|
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
|
||||||
//! stages paths, launches, polls [`Request::ImportStatus`] for the final report,
|
//! stages paths, launches, asks [`Request::ImportStatus`] for the final report,
|
||||||
//! and reads live progress off the import's job in the transfer snapshot the
|
//! and reads live progress off the import's job in the transfer snapshot the
|
||||||
//! status tick already fetches (see [`takeout_progress`]).
|
//! daemon already pushes (see [`takeout_progress`]).
|
||||||
|
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
|
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
|
||||||
/// Matching on it is how this page borrows the progress the status tick is
|
/// Matching on it is how this page borrows the progress the daemon already
|
||||||
/// already polling instead of adding a second poll of its own.
|
/// pushes instead of asking for it separately.
|
||||||
const IMPORT_JOB: &str = "Importing Google Photos";
|
pub(crate) const IMPORT_JOB: &str = "Importing Google Photos";
|
||||||
|
|
||||||
/// What the archive list says before anything is staged. `repaint_archives`
|
/// What the archive list says before anything is staged. `repaint_archives`
|
||||||
/// swaps in the count and total once there is something to report, and swaps
|
/// swaps in the count and total once there is something to report, and swaps
|
||||||
@@ -384,7 +384,7 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
|||||||
let count = ui_import.takeout.archives.borrow().len();
|
let count = ui_import.takeout.archives.borrow().len();
|
||||||
let ui = ui_import.clone();
|
let ui = ui_import.clone();
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(gettext("Import this export?"))
|
.heading(gettext("Import This Export?"))
|
||||||
.body(ngettext_f(
|
.body(ngettext_f(
|
||||||
"{n} archive will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.",
|
"{n} archive will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.",
|
||||||
"{n} archives will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.",
|
"{n} archives will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.",
|
||||||
@@ -421,7 +421,7 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
|||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&ui,
|
&ui,
|
||||||
&gettext("Couldn't stop the import"),
|
&gettext("Couldn't stop the import"),
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -589,6 +589,7 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
|||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::Ok { .. })) => {
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
ui.takeout.running.set(true);
|
ui.takeout.running.set(true);
|
||||||
|
ui.gallery.import_banner.set_revealed(true);
|
||||||
ui.takeout.summary_group.set_visible(false);
|
ui.takeout.summary_group.set_visible(false);
|
||||||
ui.takeout.progress_group.set_visible(true);
|
ui.takeout.progress_group.set_visible(true);
|
||||||
ui.takeout.progress_bar.set_fraction(0.0);
|
ui.takeout.progress_bar.set_fraction(0.0);
|
||||||
@@ -613,15 +614,15 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
|||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&ui,
|
&ui,
|
||||||
&gettext("Couldn't start the import"),
|
&gettext("Couldn't start the import"),
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll the daemon for the import's state while the page is on screen, and paint
|
/// Ask the daemon for the import's state, and paint the report once a run has
|
||||||
/// the report once a run has finished. Cheap and inflight-guarded, like the other
|
/// finished. Asked on arrival and while import progress keeps arriving. Cheap
|
||||||
/// tick polls.
|
/// and inflight-guarded.
|
||||||
pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
||||||
if ui.takeout.inflight.get() {
|
if ui.takeout.inflight.get() {
|
||||||
return;
|
return;
|
||||||
@@ -636,6 +637,7 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let was_running = ui.takeout.running.replace(running);
|
let was_running = ui.takeout.running.replace(running);
|
||||||
|
ui.gallery.import_banner.set_revealed(running);
|
||||||
if running {
|
if running {
|
||||||
ui.takeout.progress_group.set_visible(true);
|
ui.takeout.progress_group.set_visible(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -776,8 +778,8 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
|||||||
ui.takeout.summary_group.set_visible(true);
|
ui.takeout.summary_group.set_visible(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paint the import's progress from the transfer snapshot the status tick
|
/// Paint the import's progress from the transfer snapshot the daemon pushed,
|
||||||
/// already fetched, so this page costs no extra poll. A job with no known total
|
/// so this page costs no extra request. A job with no known total
|
||||||
/// pulses instead of showing a fraction, the same as the Activity list.
|
/// pulses instead of showing a fraction, the same as the Activity list.
|
||||||
pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
|
pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
|
||||||
let Some(job) = jobs.iter().find(|job| job.title == IMPORT_JOB) else {
|
let Some(job) = jobs.iter().find(|job| job.title == IMPORT_JOB) else {
|
||||||
|
|||||||
@@ -3,55 +3,51 @@ use crate::*;
|
|||||||
pub(crate) struct TrashState {
|
pub(crate) struct TrashState {
|
||||||
// Trash page. Trashed nodes are addressed by uid, not by path, so this page
|
// Trash page. Trashed nodes are addressed by uid, not by path, so this page
|
||||||
// keeps no current-directory state — it re-lists from the daemon on show.
|
// keeps no current-directory state — it re-lists from the daemon on show.
|
||||||
pub(crate) model: gio::ListStore,
|
pub(crate) files: FileList,
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
/// Empties the trash; insensitive while it is empty (or unread).
|
/// Empties the trash; hidden while it is empty (or unread).
|
||||||
pub(crate) empty: gtk4::Button,
|
pub(crate) empty: gtk4::Button,
|
||||||
/// "12 items" under the page title.
|
/// "12 items" under the page title.
|
||||||
pub(crate) subtitle: adw::WindowTitle,
|
pub(crate) subtitle: adw::WindowTitle,
|
||||||
pub(crate) selection: gtk4::MultiSelection,
|
|
||||||
/// The bottom bar acting on the selection; revealed while anything is selected.
|
/// The bottom bar acting on the selection; revealed while anything is selected.
|
||||||
pub(crate) selection_bar: gtk4::Revealer,
|
pub(crate) selection_bar: gtk4::Revealer,
|
||||||
pub(crate) selection_label: gtk4::Label,
|
pub(crate) selection_label: gtk4::Label,
|
||||||
|
/// Runs the loads; the rows stay up while one runs.
|
||||||
|
pub(crate) loader: Rc<Loader>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The widgets of the Trash page that a load repaints.
|
/// The widgets of the Trash page that a load repaints.
|
||||||
pub(crate) struct TrashWidgets {
|
pub(crate) struct TrashWidgets {
|
||||||
pub(crate) model: gio::ListStore,
|
pub(crate) files: FileList,
|
||||||
pub(crate) list: gtk4::ListView,
|
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) empty: gtk4::Button,
|
pub(crate) empty: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
|
||||||
pub(crate) subtitle: adw::WindowTitle,
|
pub(crate) subtitle: adw::WindowTitle,
|
||||||
pub(crate) selection: gtk4::MultiSelection,
|
|
||||||
pub(crate) selection_bar: gtk4::Revealer,
|
pub(crate) selection_bar: gtk4::Revealer,
|
||||||
pub(crate) selection_label: gtk4::Label,
|
pub(crate) selection_label: gtk4::Label,
|
||||||
pub(crate) restore_selected: gtk4::Button,
|
pub(crate) restore_selected: gtk4::Button,
|
||||||
pub(crate) delete_selected: gtk4::Button,
|
pub(crate) delete_selected: gtk4::Button,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Trash page: a flat list of everything Drive is holding in the trash, each
|
/// The Trash page: everything Drive is holding in the trash, as a list with
|
||||||
/// row offering Restore and Delete Forever, with Empty Trash in the header. A
|
/// the folder each item came from and when it was deleted, or as a grid, where
|
||||||
/// selection (click, Shift/Ctrl+click, Ctrl+A) gets the same two actions in a
|
/// photos are easier to recognise. Restore and Delete Permanently sit in the
|
||||||
/// bottom bar.
|
/// right-click menu and, for a selection, in a bottom bar; Empty Trash is in
|
||||||
|
/// the header while there is something to empty.
|
||||||
///
|
///
|
||||||
/// A trashed node has no path inside the mount — the daemon forgets it when it is
|
/// A trashed node has no path inside the mount — the daemon forgets it when it
|
||||||
/// trashed — so unlike the Files page this one addresses entries by uid and always
|
/// is trashed — so unlike the Files page this one addresses entries by uid. The
|
||||||
/// re-lists from the server rather than from a cached listing. Row rendering needs
|
/// list's factories need the [`Ui`] handle, so they are installed in
|
||||||
/// the [`Ui`] handle for its buttons, so the factory is installed in [`wire_trash`].
|
/// [`wire_trash`].
|
||||||
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
|
||||||
|
|
||||||
let empty = gtk4::Button::builder()
|
let empty = gtk4::Button::builder()
|
||||||
.label(gettext("Empty Trash…"))
|
.label(gettext("Empty Trash…"))
|
||||||
.tooltip_text(gettext("Permanently delete everything in the Trash"))
|
.tooltip_text(gettext("Permanently delete everything in the Trash"))
|
||||||
.sensitive(false)
|
.visible(false)
|
||||||
.build();
|
.build();
|
||||||
let refresh = refresh_button();
|
|
||||||
|
|
||||||
let retry = gtk4::Button::builder()
|
let retry = gtk4::Button::builder()
|
||||||
.label(gettext("Retry"))
|
.label(gettext("Retry"))
|
||||||
@@ -67,28 +63,27 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
.build();
|
.build();
|
||||||
status.add_css_class("compact");
|
status.add_css_class("compact");
|
||||||
|
|
||||||
// Bulk Delete Forever always confirms with the count, so a stale selection
|
// The list first: where an item came from and when it went are what tell
|
||||||
// can't delete anything silently.
|
// two copies of a file apart here.
|
||||||
let selection = gtk4::MultiSelection::new(Some(model.clone()));
|
let files = FileList::new();
|
||||||
let list = gtk4::ListView::builder().model(&selection).build();
|
files.show_list(true);
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let layout = layout_button(&files);
|
||||||
.vexpand(true)
|
|
||||||
.child(&list)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
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(&scroll, Some("list"));
|
content.add_named(&files.views, Some("list"));
|
||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(18);
|
inner.set_margin_top(12);
|
||||||
inner.set_margin_bottom(18);
|
inner.set_margin_bottom(12);
|
||||||
inner.set_margin_start(18);
|
inner.set_margin_start(12);
|
||||||
inner.set_margin_end(18);
|
inner.set_margin_end(12);
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
|
|
||||||
|
// Bulk Delete Permanently always confirms with the count, so a stale
|
||||||
|
// selection can't delete anything silently.
|
||||||
let selection_label = gtk4::Label::new(None);
|
let selection_label = gtk4::Label::new(None);
|
||||||
let restore_selected = gtk4::Button::with_label(&pgettext("verb", "Restore"));
|
let restore_selected = gtk4::Button::with_label(&pgettext("verb", "Restore"));
|
||||||
let delete_selected = gtk4::Button::with_label(&gettext("Delete Permanently…"));
|
let delete_selected = gtk4::Button::with_label(&gettext("Delete Permanently…"));
|
||||||
@@ -105,20 +100,17 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
|
|
||||||
let (frame, header, subtitle) = page_frame(&gettext("Trash"), &inner);
|
let (frame, header, subtitle) = page_frame(&gettext("Trash"), &inner);
|
||||||
header.pack_start(&empty);
|
header.pack_start(&empty);
|
||||||
header.pack_end(&refresh);
|
header.pack_end(&layout);
|
||||||
|
|
||||||
(
|
(
|
||||||
frame.upcast(),
|
frame.upcast(),
|
||||||
TrashWidgets {
|
TrashWidgets {
|
||||||
model,
|
files,
|
||||||
list,
|
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
retry,
|
retry,
|
||||||
empty,
|
empty,
|
||||||
refresh,
|
|
||||||
subtitle,
|
subtitle,
|
||||||
selection,
|
|
||||||
selection_bar,
|
selection_bar,
|
||||||
selection_label,
|
selection_label,
|
||||||
restore_selected,
|
restore_selected,
|
||||||
@@ -127,142 +119,144 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the row factory and the Empty Trash button. The row's two buttons read
|
/// Install the list's factories and columns, the Empty Trash button and the
|
||||||
/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a
|
/// selection bar.
|
||||||
/// captured copy, so a recycled row always acts on the item it currently shows.
|
|
||||||
pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
|
pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
|
||||||
let list = &widgets.list;
|
let files = &ui.trash.files;
|
||||||
let empty = &widgets.empty;
|
files.wire(
|
||||||
let factory = gtk4::SignalListItemFactory::new();
|
ui,
|
||||||
let ui_setup = ui.clone();
|
FileListBehavior {
|
||||||
factory.connect_setup(move |_, item| {
|
activate: activate_trashed,
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
entry_menu: trash_entry_menu,
|
||||||
|
bulk_menu: trash_bulk_menu,
|
||||||
let thumbnail = file_thumbnail_widget(40, 32);
|
background_menu: trash_background_menu,
|
||||||
let name = gtk4::Label::builder()
|
badges: false,
|
||||||
.halign(gtk4::Align::Start)
|
drag_and_drop: false,
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
},
|
||||||
.build();
|
);
|
||||||
let meta = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
files
|
||||||
meta.add_css_class("dim-label");
|
.column_view
|
||||||
meta.add_css_class("caption");
|
.append_column(&text_column(&gettext("Original Location"), |e| {
|
||||||
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
trashed_from_label(e)
|
||||||
text.set_hexpand(true);
|
}));
|
||||||
text.set_valign(gtk4::Align::Center);
|
files
|
||||||
text.append(&name);
|
.column_view
|
||||||
text.append(&meta);
|
.append_column(&text_column(&pgettext("column", "Deleted"), |e| {
|
||||||
|
if e.trashed_at > 0 {
|
||||||
let restore = gtk4::Button::builder()
|
dates::relative(e.trashed_at)
|
||||||
.icon_name("edit-undo-symbolic")
|
} else {
|
||||||
.tooltip_text(gettext("Restore to its original folder"))
|
"—".to_string()
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
restore.add_css_class("flat");
|
|
||||||
let purge = gtk4::Button::builder()
|
|
||||||
.icon_name("edit-delete-symbolic")
|
|
||||||
.tooltip_text(gettext("Delete permanently"))
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
purge.add_css_class("flat");
|
|
||||||
|
|
||||||
let ui_restore = ui_setup.clone();
|
|
||||||
let item_restore = item.clone();
|
|
||||||
restore.connect_clicked(move |_| {
|
|
||||||
if let Some(entry) = bound_entry(&item_restore) {
|
|
||||||
restore_entry(&ui_restore, &entry);
|
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
let ui_purge = ui_setup.clone();
|
files
|
||||||
let item_purge = item.clone();
|
.column_view
|
||||||
purge.connect_clicked(move |_| {
|
.append_column(&text_column(&pgettext("column", "Size"), |e| {
|
||||||
if let Some(entry) = bound_entry(&item_purge) {
|
if e.is_dir {
|
||||||
prompt_delete_forever(&ui_purge, std::slice::from_ref(&entry));
|
"—".to_string()
|
||||||
|
} else {
|
||||||
|
human_bytes(e.size)
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
|
||||||
row.set_margin_top(6);
|
|
||||||
row.set_margin_bottom(6);
|
|
||||||
row.set_margin_start(6);
|
|
||||||
row.set_margin_end(6);
|
|
||||||
row.append(&thumbnail);
|
|
||||||
row.append(&text);
|
|
||||||
row.append(&restore);
|
|
||||||
row.append(&purge);
|
|
||||||
item.set_child(Some(&row));
|
|
||||||
});
|
|
||||||
factory.connect_bind({
|
|
||||||
let ui = ui.clone();
|
|
||||||
move |_, item| {
|
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
||||||
let Some(entry) = bound_entry(item) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
|
||||||
let Some(thumbnail) = row.first_child().and_downcast::<gtk4::Overlay>() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
|
|
||||||
let Some(text) = thumbnail.next_sibling().and_downcast::<gtk4::Box>() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
|
||||||
name.set_label(&entry.name);
|
|
||||||
}
|
|
||||||
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
|
|
||||||
let kind = if entry.is_dir {
|
|
||||||
gettext("Folder")
|
|
||||||
} else {
|
|
||||||
human_bytes(entry.size)
|
|
||||||
};
|
|
||||||
let modified = format_modified(entry.modified);
|
|
||||||
// Translators: {kind} is "Folder" or a file size; {modified} is a date.
|
|
||||||
let text = gettext_f(
|
|
||||||
"{kind} · {modified}",
|
|
||||||
&[("kind", &kind), ("modified", &modified)],
|
|
||||||
);
|
|
||||||
meta.set_label(&text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
list.set_factory(Some(&factory));
|
|
||||||
|
|
||||||
let ui_empty = ui.clone();
|
let ui_empty = ui.clone();
|
||||||
empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
|
widgets
|
||||||
|
.empty
|
||||||
|
.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
|
||||||
|
|
||||||
let ui_sel = ui.clone();
|
let ui_sel = ui.clone();
|
||||||
widgets
|
files
|
||||||
.selection
|
.selection
|
||||||
.connect_selection_changed(move |_, _, _| sync_trash_selection(&ui_sel));
|
.connect_selection_changed(move |_, _, _| sync_trash_selection(&ui_sel));
|
||||||
let ui_restore = ui.clone();
|
let ui_restore = ui.clone();
|
||||||
widgets.restore_selected.connect_clicked(move |_| {
|
widgets.restore_selected.connect_clicked(move |_| {
|
||||||
let entries = selected_trash(&ui_restore);
|
let entries = ui_restore.trash.files.selected();
|
||||||
if !entries.is_empty() {
|
if !entries.is_empty() {
|
||||||
restore_entries(&ui_restore, &entries);
|
restore_entries(&ui_restore, &entries);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let ui_delete = ui.clone();
|
let ui_delete = ui.clone();
|
||||||
widgets.delete_selected.connect_clicked(move |_| {
|
widgets.delete_selected.connect_clicked(move |_| {
|
||||||
let entries = selected_trash(&ui_delete);
|
let entries = ui_delete.trash.files.selected();
|
||||||
if !entries.is_empty() {
|
if !entries.is_empty() {
|
||||||
prompt_delete_forever(&ui_delete, &entries);
|
prompt_delete_forever(&ui_delete, &entries);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The trashed entries currently selected, in list order.
|
/// Where a trashed entry goes back to on restore, for the Original Location
|
||||||
fn selected_trash(ui: &Rc<Ui>) -> Vec<DirEntry> {
|
/// column.
|
||||||
let selected = ui.trash.selection.selection();
|
fn trashed_from_label(entry: &DirEntry) -> String {
|
||||||
(0..selected.size())
|
match entry.trashed_from.as_deref() {
|
||||||
.filter_map(|i| ui.trash.model.item(selected.nth(i as u32)))
|
None => "—".to_string(),
|
||||||
.filter_map(|obj| obj.downcast::<BoxedAnyObject>().ok())
|
Some("") => gettext("My files"),
|
||||||
.map(|obj| obj.borrow::<DirEntry>().clone())
|
Some(path) => path.to_string(),
|
||||||
.collect()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trashed file can't be opened; say what would let it be.
|
||||||
|
fn activate_trashed(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
toast(
|
||||||
|
ui,
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
&gettext_f("Restore “{name}” to open it", &[("name", &entry.name)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trash_entry_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&pgettext("verb", "Restore"), move || {
|
||||||
|
restore_entries(&ui_c, std::slice::from_ref(&entry_c))
|
||||||
|
});
|
||||||
|
menu.section();
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&gettext("Delete Permanently…"), move || {
|
||||||
|
prompt_delete_forever(&ui_c, std::slice::from_ref(&entry_c))
|
||||||
|
});
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trash_bulk_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
menu.labelled_section(&ngettext_f(
|
||||||
|
"{n} selected",
|
||||||
|
"{n} selected",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||||
|
menu.item(&pgettext("verb", "Restore"), move || {
|
||||||
|
restore_entries(&ui_c, &batch)
|
||||||
|
});
|
||||||
|
menu.section();
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Delete Permanently…"), move || {
|
||||||
|
prompt_delete_forever(&ui_c, &entries)
|
||||||
|
});
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trash_background_menu(ui: &Rc<Ui>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Select All"), move || {
|
||||||
|
ui_c.trash.files.selection.select_all();
|
||||||
|
});
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c));
|
||||||
|
if ui.trash.files.model.n_items() > 0 {
|
||||||
|
menu.section();
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Empty Trash…"), move || {
|
||||||
|
prompt_empty_trash(&ui_c)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
menu
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reveal the selection bar while anything is selected and count what is.
|
/// Reveal the selection bar while anything is selected and count what is.
|
||||||
fn sync_trash_selection(ui: &Rc<Ui>) {
|
fn sync_trash_selection(ui: &Rc<Ui>) {
|
||||||
let count = ui.trash.selection.selection().size() as usize;
|
let count = ui.trash.files.selection.selection().size() as usize;
|
||||||
ui.trash.selection_bar.set_reveal_child(count > 0);
|
ui.trash.selection_bar.set_reveal_child(count > 0);
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
ui.trash.selection_label.set_label(&ngettext_f(
|
ui.trash.selection_label.set_label(&ngettext_f(
|
||||||
@@ -274,29 +268,24 @@ fn sync_trash_selection(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound
|
|
||||||
/// (recycled) row.
|
|
||||||
pub(crate) fn bound_entry(item: >k4::ListItem) -> Option<DirEntry> {
|
|
||||||
let obj = item.item().and_downcast::<BoxedAnyObject>()?;
|
|
||||||
let entry = obj.borrow::<DirEntry>().clone();
|
|
||||||
Some(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch the trash listing and repaint the page.
|
/// Fetch the trash listing and repaint the page.
|
||||||
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||||
cancel_file_thumbnails(ui);
|
cancel_file_thumbnails(ui);
|
||||||
// Drop the old rows first: a stale row here would offer Restore on something
|
// The rows stay up while the trash is read again. Trashed items are
|
||||||
// that may already be gone.
|
// addressed by uid, so acting on one that has gone meanwhile fails
|
||||||
ui.trash.model.remove_all();
|
// cleanly rather than hitting something else.
|
||||||
ui.trash.empty.set_sensitive(false);
|
let ui_p = ui.clone();
|
||||||
sync_trash_selection(ui);
|
let ticket = ui.trash.loader.refresh(move || {
|
||||||
trash_status(
|
clear_trash(&ui_p);
|
||||||
ui,
|
ui_p.trash.empty.set_sensitive(false);
|
||||||
"user-trash-symbolic",
|
trash_status(
|
||||||
&gettext("Loading…"),
|
&ui_p,
|
||||||
&gettext("Reading the trash."),
|
"user-trash-symbolic",
|
||||||
false,
|
&gettext("Loading…"),
|
||||||
);
|
&gettext("Reading the trash."),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash);
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash);
|
||||||
@@ -304,6 +293,12 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
|
if !ticket.is_current() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !matches!(result, Ok(Ok(Response::Entries { .. }))) {
|
||||||
|
clear_trash(&ui);
|
||||||
|
}
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries),
|
Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries),
|
||||||
Ok(Ok(Response::Error { message, .. })) => trash_status(
|
Ok(Ok(Response::Error { message, .. })) => trash_status(
|
||||||
@@ -317,7 +312,7 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
|||||||
&ui,
|
&ui,
|
||||||
"dialog-warning-symbolic",
|
"dialog-warning-symbolic",
|
||||||
&gettext("Couldn't read the trash"),
|
&gettext("Couldn't read the trash"),
|
||||||
&gettext("Unexpected reply from the mount service."),
|
&gettext("Unexpected reply from the Proton Drive service."),
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
Ok(Err(_)) | Err(_) => trash_unreachable(&ui),
|
Ok(Err(_)) | Err(_) => trash_unreachable(&ui),
|
||||||
@@ -328,29 +323,7 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
|||||||
/// The daemon didn't answer. Same split as the Files page: still starting (poll
|
/// The daemon didn't answer. Same split as the Files page: still starting (poll
|
||||||
/// again, no button) versus actually down (Retry, which restarts the service).
|
/// again, no button) versus actually down (Retry, which restarts the service).
|
||||||
pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
|
pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
|
||||||
if service::is_failed() || !service::is_active() {
|
service_unreachable(ui, "trash", trash_status, load_trash);
|
||||||
trash_status(
|
|
||||||
ui,
|
|
||||||
"network-offline-symbolic",
|
|
||||||
&gettext("Not connected"),
|
|
||||||
&gettext("The Proton Drive mount service isn't running."),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
trash_status(
|
|
||||||
ui,
|
|
||||||
"folder-remote-symbolic",
|
|
||||||
&gettext("Connecting…"),
|
|
||||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
let ui = ui.clone();
|
|
||||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("trash") {
|
|
||||||
load_trash(&ui);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show a status page in place of the trash list.
|
/// Show a status page in place of the trash list.
|
||||||
@@ -363,12 +336,13 @@ pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &s
|
|||||||
ui.trash.subtitle.set_subtitle("");
|
ui.trash.subtitle.set_subtitle("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Repopulate the trash list, most recently modified first — the order in which a
|
/// Repopulate the trash, most recently deleted first: the order in which a user
|
||||||
/// user looks for what they just deleted.
|
/// looks for what they just deleted.
|
||||||
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
ui.trash.model.remove_all();
|
// An empty Trash has nothing to empty, so the button goes away rather than
|
||||||
sync_trash_selection(ui);
|
// sitting there greyed out.
|
||||||
ui.trash.empty.set_sensitive(!entries.is_empty());
|
ui.trash.empty.set_sensitive(true);
|
||||||
|
ui.trash.empty.set_visible(!entries.is_empty());
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
trash_status(
|
trash_status(
|
||||||
ui,
|
ui,
|
||||||
@@ -377,6 +351,7 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
&gettext("Items you delete from Proton Drive show up here."),
|
&gettext("Items you delete from Proton Drive show up here."),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
clear_trash(ui);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.trash.content.set_visible_child_name("list");
|
ui.trash.content.set_visible_child_name("list");
|
||||||
@@ -388,38 +363,69 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let mut sorted = entries.to_vec();
|
let mut sorted = entries.to_vec();
|
||||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
|
sort_trash(&mut sorted);
|
||||||
for entry in sorted {
|
// Only the rows that changed are swapped, so a refresh keeps the
|
||||||
ui.trash.model.append(&BoxedAnyObject::new(entry));
|
// selection and the scroll position.
|
||||||
}
|
replace_items(&ui.trash.files.model, &sorted);
|
||||||
|
sync_trash_selection(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore one trashed entry to the folder it was trashed from.
|
/// Newest deletion first. Entries with no known deletion time go last, newest
|
||||||
pub(crate) fn restore_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
/// modification first among themselves.
|
||||||
restore_entries(ui, std::slice::from_ref(entry));
|
fn sort_trash(entries: &mut [DirEntry]) {
|
||||||
|
entries.sort_by_key(|e| {
|
||||||
|
(
|
||||||
|
e.trashed_at == 0,
|
||||||
|
std::cmp::Reverse(e.trashed_at),
|
||||||
|
std::cmp::Reverse(e.modified),
|
||||||
|
)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore trashed entries to the folders they were trashed from.
|
/// Drop every row, and with them the selection and its bar.
|
||||||
|
fn clear_trash(ui: &Rc<Ui>) {
|
||||||
|
ui.trash.files.model.remove_all();
|
||||||
|
sync_trash_selection(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore trashed entries to the folders they were trashed from. When they
|
||||||
|
/// all came from one known folder, the toast offers to show it.
|
||||||
pub(crate) fn restore_entries(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
pub(crate) fn restore_entries(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
run_mutation(
|
let message = match entries {
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
[one] => gettext_f("Restored “{name}”", &[("name", &one.name)]),
|
||||||
|
_ => ngettext_f(
|
||||||
|
"Restored {n} item",
|
||||||
|
"Restored {n} items",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let folder = common_origin(entries);
|
||||||
|
run_mutation_then(
|
||||||
ui,
|
ui,
|
||||||
Request::Restore {
|
Request::Restore {
|
||||||
uids: entries.iter().map(|e| e.uid.clone()).collect(),
|
uids: entries.iter().map(|e| e.uid.clone()).collect(),
|
||||||
},
|
},
|
||||||
match entries {
|
|
||||||
// Translators: {name} is a file or folder name.
|
|
||||||
[one] => gettext_f("Restored “{name}”", &[("name", &one.name)]),
|
|
||||||
_ => ngettext_f(
|
|
||||||
"Restored {n} item",
|
|
||||||
"Restored {n} items",
|
|
||||||
entries.len() as u64,
|
|
||||||
&[],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
gettext_noop("Couldn't restore"),
|
gettext_noop("Couldn't restore"),
|
||||||
|
move |ui| match folder {
|
||||||
|
Some(folder) => toast_action(ui, &message, &gettext("Show"), move |ui| {
|
||||||
|
open_in_my_files(ui, folder.clone())
|
||||||
|
}),
|
||||||
|
None => toast(ui, &message),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The folder every entry goes back to, when that is one known folder.
|
||||||
|
fn common_origin(entries: &[DirEntry]) -> Option<String> {
|
||||||
|
let first = entries.first()?.trashed_from.clone()?;
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.all(|e| e.trashed_from.as_deref() == Some(first.as_str()))
|
||||||
|
.then_some(first)
|
||||||
|
}
|
||||||
|
|
||||||
/// Confirm, then permanently delete trashed entries. Irreversible, so it asks.
|
/// Confirm, then permanently delete trashed entries. Irreversible, so it asks.
|
||||||
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
@@ -451,7 +457,7 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(gettext("Delete Permanently"))
|
.heading(gettext("Delete Permanently?"))
|
||||||
.body(body)
|
.body(body)
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("cancel", &gettext("Cancel"));
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
@@ -477,9 +483,9 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
/// Confirm, then permanently delete everything in the trash.
|
/// Confirm, then permanently delete everything in the trash.
|
||||||
pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
let count = ui.trash.model.n_items();
|
let count = ui.trash.files.model.n_items();
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(gettext("Empty Trash"))
|
.heading(gettext("Empty Trash?"))
|
||||||
.body(ngettext_f(
|
.body(ngettext_f(
|
||||||
"Permanently delete all {n} item in the trash? This cannot be undone.",
|
"Permanently delete all {n} item in the trash? This cannot be undone.",
|
||||||
"Permanently delete all {n} items in the trash? This cannot be undone.",
|
"Permanently delete all {n} items in the trash? This cannot be undone.",
|
||||||
@@ -506,3 +512,59 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
|||||||
});
|
});
|
||||||
dialog.present(win.as_ref());
|
dialog.present(win.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn trashed(name: &str, trashed_at: i64, modified: i64, from: Option<&str>) -> DirEntry {
|
||||||
|
DirEntry {
|
||||||
|
name: name.into(),
|
||||||
|
is_dir: false,
|
||||||
|
size: 0,
|
||||||
|
modified,
|
||||||
|
pinned: false,
|
||||||
|
cached: false,
|
||||||
|
uid: name.into(),
|
||||||
|
path: String::new(),
|
||||||
|
role: String::new(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
|
trashed_at,
|
||||||
|
trashed_from: from.map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trash_lists_newest_deletion_first_and_undated_last() {
|
||||||
|
let mut entries = vec![
|
||||||
|
trashed("undated-old", 0, 1, None),
|
||||||
|
trashed("older", 10, 50, None),
|
||||||
|
trashed("undated-new", 0, 9, None),
|
||||||
|
trashed("newest", 20, 5, None),
|
||||||
|
];
|
||||||
|
sort_trash(&mut entries);
|
||||||
|
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
|
||||||
|
assert_eq!(names, ["newest", "older", "undated-new", "undated-old"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_offers_a_folder_only_when_all_share_one() {
|
||||||
|
let docs = trashed("a", 1, 1, Some("Documents"));
|
||||||
|
let docs2 = trashed("b", 1, 1, Some("Documents"));
|
||||||
|
let root = trashed("c", 1, 1, Some(""));
|
||||||
|
let unknown = trashed("d", 1, 1, None);
|
||||||
|
assert_eq!(
|
||||||
|
common_origin(&[docs.clone(), docs2]).as_deref(),
|
||||||
|
Some("Documents")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
common_origin(std::slice::from_ref(&root)).as_deref(),
|
||||||
|
Some("")
|
||||||
|
);
|
||||||
|
assert_eq!(common_origin(&[docs.clone(), root]), None);
|
||||||
|
assert_eq!(common_origin(&[docs, unknown]), None);
|
||||||
|
assert_eq!(common_origin(&[]), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
//! Reloading a page without flashing it.
|
||||||
|
//!
|
||||||
|
//! Pages used to swap their content for a "Loading…" status page on every
|
||||||
|
//! load, so every sidebar click and every folder change blinked. A [`Loader`]
|
||||||
|
//! keeps the last content on screen while the request runs and shows the
|
||||||
|
//! loading state only if the reply is slow. [`replace_items`] then swaps in the
|
||||||
|
//! new rows by changing only the ones that differ, so selection and scroll
|
||||||
|
//! position survive a refresh.
|
||||||
|
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gtk4::gio;
|
||||||
|
use gtk4::glib;
|
||||||
|
use gtk4::glib::BoxedAnyObject;
|
||||||
|
use gtk4::glib::object::Cast;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
|
||||||
|
/// How long a load may run before the page gives up its old content for a
|
||||||
|
/// loading state. Most replies arrive well within this, so the page updates in
|
||||||
|
/// place.
|
||||||
|
const LOADING_GRACE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
|
/// One page's loads. Each [`Loader::refresh`] or [`Loader::replace`]
|
||||||
|
/// supersedes the one before it.
|
||||||
|
pub(crate) struct Loader {
|
||||||
|
content: gtk4::Widget,
|
||||||
|
generation: Cell<u64>,
|
||||||
|
placeholder: RefCell<Option<glib::SourceId>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Loader {
|
||||||
|
/// A loader for the page whose content is `content`.
|
||||||
|
pub(crate) fn new(content: &impl IsA<gtk4::Widget>) -> Rc<Self> {
|
||||||
|
Rc::new(Loader {
|
||||||
|
content: content.clone().upcast(),
|
||||||
|
generation: Cell::new(0),
|
||||||
|
placeholder: RefCell::new(None),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a load of what the page already shows. The content stays up and
|
||||||
|
/// usable; `placeholder` runs if the load is still going after
|
||||||
|
/// [`LOADING_GRACE`].
|
||||||
|
pub(crate) fn refresh(self: &Rc<Self>, placeholder: impl FnOnce() + 'static) -> LoadTicket {
|
||||||
|
self.begin(false, placeholder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a load of something else, such as another folder. The old
|
||||||
|
/// content stays up but insensitive, since acting on it would act on the
|
||||||
|
/// wrong thing; `placeholder` runs if the load is still going after
|
||||||
|
/// [`LOADING_GRACE`].
|
||||||
|
pub(crate) fn replace(self: &Rc<Self>, placeholder: impl FnOnce() + 'static) -> LoadTicket {
|
||||||
|
self.begin(true, placeholder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin(self: &Rc<Self>, stale: bool, placeholder: impl FnOnce() + 'static) -> LoadTicket {
|
||||||
|
let generation = self.generation.get().wrapping_add(1);
|
||||||
|
self.generation.set(generation);
|
||||||
|
self.cancel_placeholder();
|
||||||
|
self.content.set_sensitive(!stale);
|
||||||
|
let loader = Rc::downgrade(self);
|
||||||
|
let source = glib::timeout_add_local_once(LOADING_GRACE, move || {
|
||||||
|
let Some(loader) = loader.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
loader.placeholder.borrow_mut().take();
|
||||||
|
loader.content.set_sensitive(true);
|
||||||
|
placeholder();
|
||||||
|
});
|
||||||
|
*self.placeholder.borrow_mut() = Some(source);
|
||||||
|
LoadTicket {
|
||||||
|
loader: self.clone(),
|
||||||
|
generation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_placeholder(&self) {
|
||||||
|
if let Some(source) = self.placeholder.borrow_mut().take() {
|
||||||
|
source.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A load in flight. Dropping it ends the load: the pending loading state is
|
||||||
|
/// cancelled and the content is usable again, unless a newer load has taken
|
||||||
|
/// over in the meantime.
|
||||||
|
pub(crate) struct LoadTicket {
|
||||||
|
loader: Rc<Loader>,
|
||||||
|
generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoadTicket {
|
||||||
|
/// Whether no newer load has started since this one. A superseded load's
|
||||||
|
/// reply is stale and must not be painted.
|
||||||
|
pub(crate) fn is_current(&self) -> bool {
|
||||||
|
self.loader.generation.get() == self.generation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LoadTicket {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.is_current() {
|
||||||
|
self.loader.cancel_placeholder();
|
||||||
|
self.loader.content.set_sensitive(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `model`, a list of [`BoxedAnyObject`]s holding `T`, hold `items`,
|
||||||
|
/// replacing only the run of items that changed, in one `items-changed`
|
||||||
|
/// signal. Rows before and after that run keep their objects, so the views
|
||||||
|
/// keep their widgets, the selection and the scroll position.
|
||||||
|
pub(crate) fn replace_items<T: Clone + PartialEq + 'static>(model: &gio::ListStore, items: &[T]) {
|
||||||
|
let held = |i: usize, item: &T| {
|
||||||
|
model
|
||||||
|
.item(i as u32)
|
||||||
|
.and_downcast::<BoxedAnyObject>()
|
||||||
|
.is_some_and(|object| *object.borrow::<T>() == *item)
|
||||||
|
};
|
||||||
|
let Some((start, removed, added)) = changed_run(model.n_items() as usize, items, held) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let added: Vec<BoxedAnyObject> = items[added]
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(BoxedAnyObject::new)
|
||||||
|
.collect();
|
||||||
|
model.splice(start as u32, removed as u32, &added);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one run that differs between an old list of `old_len` items and `new`:
|
||||||
|
/// where it starts, how many old items it covers, and which new items replace
|
||||||
|
/// them. `held(i, item)` says whether old item `i` equals `item`. `None` when
|
||||||
|
/// the lists are equal.
|
||||||
|
fn changed_run<T>(
|
||||||
|
old_len: usize,
|
||||||
|
new: &[T],
|
||||||
|
held: impl Fn(usize, &T) -> bool,
|
||||||
|
) -> Option<(usize, usize, std::ops::Range<usize>)> {
|
||||||
|
let shared = old_len.min(new.len());
|
||||||
|
let prefix = (0..shared).take_while(|&i| held(i, &new[i])).count();
|
||||||
|
let suffix = (0..shared - prefix)
|
||||||
|
.take_while(|&k| held(old_len - 1 - k, &new[new.len() - 1 - k]))
|
||||||
|
.count();
|
||||||
|
let removed = old_len - prefix - suffix;
|
||||||
|
let added = prefix..new.len() - suffix;
|
||||||
|
(removed > 0 || !added.is_empty()).then_some((prefix, removed, added))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::changed_run;
|
||||||
|
|
||||||
|
fn run(old: &[u32], new: &[u32]) -> Option<(usize, usize, Vec<u32>)> {
|
||||||
|
changed_run(old.len(), new, |i, item| old[i] == *item)
|
||||||
|
.map(|(start, removed, added)| (start, removed, new[added].to_vec()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equal_lists_change_nothing() {
|
||||||
|
assert_eq!(run(&[1, 2, 3], &[1, 2, 3]), None);
|
||||||
|
assert_eq!(run(&[], &[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_differing_middle_is_replaced() {
|
||||||
|
assert_eq!(run(&[1, 2, 3, 4], &[1, 9, 4]), Some((1, 2, vec![9])));
|
||||||
|
assert_eq!(run(&[1, 2, 3], &[1, 2, 5, 3]), Some((2, 0, vec![5])));
|
||||||
|
assert_eq!(run(&[1, 2, 3], &[1, 3]), Some((1, 1, vec![])));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn growing_or_shrinking_at_either_end() {
|
||||||
|
assert_eq!(run(&[1, 2], &[0, 1, 2]), Some((0, 0, vec![0])));
|
||||||
|
assert_eq!(run(&[1, 2], &[1, 2, 3]), Some((2, 0, vec![3])));
|
||||||
|
assert_eq!(run(&[1, 2, 3], &[]), Some((0, 3, vec![])));
|
||||||
|
assert_eq!(run(&[], &[1]), Some((0, 0, vec![1])));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_items_are_not_counted_twice() {
|
||||||
|
// The prefix takes both 1s; the suffix must not reuse them.
|
||||||
|
assert_eq!(run(&[1, 1], &[1, 1, 1]), Some((2, 0, vec![1])));
|
||||||
|
assert_eq!(run(&[1, 1, 1], &[1, 1]), Some((2, 1, vec![])));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
//! The app's side of `src/compat.rs`: widgets libadwaita 1.6–1.8 brought, with
|
||||||
|
//! a stand-in for 1.5. [`has_adw_1_8`] picks the real ones at run time.
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// A segmented control: a row of options of which exactly one is on, told
|
||||||
|
/// apart by index in the order they were given. `AdwToggleGroup` on
|
||||||
|
/// libadwaita 1.8, linked toggle buttons before.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct ToggleGroup {
|
||||||
|
widget: gtk4::Widget,
|
||||||
|
options: Options,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum Options {
|
||||||
|
/// The group's `AdwToggle`s, reached by property name.
|
||||||
|
Toggles(Rc<[glib::Object]>),
|
||||||
|
Buttons(Rc<[gtk4::ToggleButton]>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToggleGroup {
|
||||||
|
/// A group of `(label, tooltip)` options, with the first one on.
|
||||||
|
pub(crate) fn new(options: &[(&str, Option<&str>)]) -> Self {
|
||||||
|
if has_adw_1_8() {
|
||||||
|
let children: String = (0..options.len())
|
||||||
|
.map(|i| format!(r#"<child><object class="AdwToggle" id="toggle{i}"/></child>"#))
|
||||||
|
.collect();
|
||||||
|
let builder = build_ui(&format!(
|
||||||
|
r#"<object class="AdwToggleGroup" id="group">{children}</object>"#
|
||||||
|
));
|
||||||
|
let toggles = options
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (label, tooltip))| {
|
||||||
|
let toggle: glib::Object = built(&builder, &format!("toggle{i}"));
|
||||||
|
toggle.set_property("label", *label);
|
||||||
|
if let Some(tooltip) = tooltip {
|
||||||
|
toggle.set_property("tooltip", *tooltip);
|
||||||
|
}
|
||||||
|
toggle
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let widget: gtk4::Widget = built(&builder, "group");
|
||||||
|
// A toggle group starts with nothing on.
|
||||||
|
widget.set_property("active", 0u32);
|
||||||
|
Self {
|
||||||
|
widget,
|
||||||
|
options: Options::Toggles(toggles),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||||
|
bar.add_css_class("linked");
|
||||||
|
let buttons: Rc<[gtk4::ToggleButton]> = options
|
||||||
|
.iter()
|
||||||
|
.map(|(label, tooltip)| {
|
||||||
|
let button = gtk4::ToggleButton::with_label(label);
|
||||||
|
button.set_tooltip_text(*tooltip);
|
||||||
|
bar.append(&button);
|
||||||
|
button
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Some((first, rest)) = buttons.split_first() {
|
||||||
|
first.set_active(true);
|
||||||
|
// Chaining each to the first is what GTK turns into mutual
|
||||||
|
// exclusion.
|
||||||
|
for button in rest {
|
||||||
|
button.set_group(Some(first));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
widget: bar.upcast(),
|
||||||
|
options: Options::Buttons(buttons),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The widget to pack.
|
||||||
|
pub(crate) fn widget(&self) -> gtk4::Widget {
|
||||||
|
self.widget.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give the options pill-shaped ends.
|
||||||
|
pub(crate) fn set_round(&self) {
|
||||||
|
match &self.options {
|
||||||
|
Options::Toggles(_) => self.widget.add_css_class("round"),
|
||||||
|
Options::Buttons(buttons) => {
|
||||||
|
for button in buttons.iter() {
|
||||||
|
button.add_css_class("pill");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The option that is on.
|
||||||
|
pub(crate) fn active(&self) -> u32 {
|
||||||
|
match &self.options {
|
||||||
|
Options::Toggles(_) => self.widget.property("active"),
|
||||||
|
Options::Buttons(buttons) => {
|
||||||
|
buttons.iter().position(|b| b.is_active()).unwrap_or(0) as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn option `index` on, which turns the one that was on off.
|
||||||
|
pub(crate) fn set_active(&self, index: u32) {
|
||||||
|
match &self.options {
|
||||||
|
Options::Toggles(_) => self.widget.set_property("active", index),
|
||||||
|
Options::Buttons(buttons) => {
|
||||||
|
if let Some(button) = buttons.get(index as usize) {
|
||||||
|
button.set_active(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_label(&self, index: u32, label: &str) {
|
||||||
|
match &self.options {
|
||||||
|
Options::Toggles(toggles) => {
|
||||||
|
if let Some(toggle) = toggles.get(index as usize) {
|
||||||
|
toggle.set_property("label", label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Options::Buttons(buttons) => {
|
||||||
|
if let Some(button) = buttons.get(index as usize) {
|
||||||
|
button.set_label(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Let option `index` be picked, or not.
|
||||||
|
pub(crate) fn set_enabled(&self, index: u32, enabled: bool) {
|
||||||
|
match &self.options {
|
||||||
|
Options::Toggles(toggles) => {
|
||||||
|
if let Some(toggle) = toggles.get(index as usize) {
|
||||||
|
toggle.set_property("enabled", enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Options::Buttons(buttons) => {
|
||||||
|
if let Some(button) = buttons.get(index as usize) {
|
||||||
|
button.set_sensitive(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call `f` with the option that has just come on, whether the user or
|
||||||
|
/// [`Self::set_active`] turned it on.
|
||||||
|
pub(crate) fn connect_changed(&self, f: impl Fn(u32) + 'static) {
|
||||||
|
match &self.options {
|
||||||
|
Options::Toggles(_) => {
|
||||||
|
self.widget
|
||||||
|
.connect_notify_local(Some("active"), move |group, _| {
|
||||||
|
f(group.property("active"))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Options::Buttons(buttons) => {
|
||||||
|
let f = Rc::new(f);
|
||||||
|
for (index, button) in buttons.iter().enumerate() {
|
||||||
|
let f = f.clone();
|
||||||
|
// The group also fires `toggled` for the button going off;
|
||||||
|
// only the one coming on counts.
|
||||||
|
button.connect_toggled(move |button| {
|
||||||
|
if button.is_active() {
|
||||||
|
f(index as u32);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A list row that is a button: a centred title and an icon after it.
|
||||||
|
/// `AdwButtonRow` on libadwaita 1.8, an activatable `adw::ActionRow` before.
|
||||||
|
pub(crate) fn button_row(
|
||||||
|
title: &str,
|
||||||
|
end_icon: &str,
|
||||||
|
on_activated: impl Fn() + 'static,
|
||||||
|
) -> gtk4::ListBoxRow {
|
||||||
|
if has_adw_1_8() {
|
||||||
|
let builder = build_ui(r#"<object class="AdwButtonRow" id="row"/>"#);
|
||||||
|
let row: adw::PreferencesRow = built(&builder, "row");
|
||||||
|
row.set_title(title);
|
||||||
|
row.set_property("end-icon-name", end_icon);
|
||||||
|
row.connect_local("activated", false, move |_| {
|
||||||
|
on_activated();
|
||||||
|
None
|
||||||
|
});
|
||||||
|
row.upcast()
|
||||||
|
} else {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(title)
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
row.add_suffix(>k4::Image::from_icon_name(end_icon));
|
||||||
|
row.connect_activated(move |_| on_activated());
|
||||||
|
row.upcast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the keyboard shortcuts window over `window`. See [`shortcuts_dialog`].
|
||||||
|
pub(crate) fn present_shortcuts(
|
||||||
|
window: &impl IsA<gtk4::Widget>,
|
||||||
|
groups: &[(String, Vec<(&str, String)>)],
|
||||||
|
) {
|
||||||
|
shortcuts_dialog(groups).present(Some(window));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keyboard shortcuts window: `groups` of `(accelerator, what it does)`
|
||||||
|
/// under a heading each, all already translated. `AdwShortcutsDialog` on
|
||||||
|
/// libadwaita 1.8, a preferences page of rows before.
|
||||||
|
fn shortcuts_dialog(groups: &[(String, Vec<(&str, String)>)]) -> adw::Dialog {
|
||||||
|
if has_adw_1_8() {
|
||||||
|
let sections: String = groups
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(s, (_, keys))| {
|
||||||
|
let items: String = (0..keys.len())
|
||||||
|
.map(|k| {
|
||||||
|
format!(r#"<child><object class="AdwShortcutsItem" id="item{s}_{k}"/></child>"#)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
r#"<child><object class="AdwShortcutsSection" id="section{s}">{items}</object></child>"#
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let builder = build_ui(&format!(
|
||||||
|
r#"<object class="AdwShortcutsDialog" id="dialog">{sections}</object>"#
|
||||||
|
));
|
||||||
|
for (s, (title, keys)) in groups.iter().enumerate() {
|
||||||
|
let section: glib::Object = built(&builder, &format!("section{s}"));
|
||||||
|
section.set_property("title", title.as_str());
|
||||||
|
for (k, (accel, action)) in keys.iter().enumerate() {
|
||||||
|
let item: glib::Object = built(&builder, &format!("item{s}_{k}"));
|
||||||
|
item.set_property("title", action.as_str());
|
||||||
|
item.set_property("accelerator", *accel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return built(&builder, "dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = adw::PreferencesPage::new();
|
||||||
|
for (title, keys) in groups {
|
||||||
|
let group = adw::PreferencesGroup::builder().title(title).build();
|
||||||
|
for (accel, action) in keys {
|
||||||
|
let row = adw::ActionRow::builder().title(action).build();
|
||||||
|
row.add_suffix(
|
||||||
|
>k4::ShortcutLabel::builder()
|
||||||
|
.accelerator(*accel)
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
group.add(&row);
|
||||||
|
}
|
||||||
|
page.add(&group);
|
||||||
|
}
|
||||||
|
|
||||||
|
adw::Dialog::builder()
|
||||||
|
.title(gettext("Keyboard Shortcuts"))
|
||||||
|
.content_width(460)
|
||||||
|
.content_height(620)
|
||||||
|
.child(&{
|
||||||
|
let toolbar = adw::ToolbarView::new();
|
||||||
|
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||||
|
toolbar.set_content(Some(&page));
|
||||||
|
toolbar
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The newer widgets are made by class name and set up by property and
|
||||||
|
/// signal name, so a typo in any of those only shows when they are built.
|
||||||
|
/// Needs a display and libadwaita 1.8; passes trivially without either.
|
||||||
|
#[test]
|
||||||
|
fn newer_widgets_build_by_class_name() {
|
||||||
|
if adw::init().is_err() || !has_adw_1_8() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let group = ToggleGroup::new(&[("One", None), ("Two", Some("The second"))]);
|
||||||
|
group.set_round();
|
||||||
|
group.set_label(1, "Deux");
|
||||||
|
group.set_enabled(1, true);
|
||||||
|
assert_eq!(group.widget().type_().name(), "AdwToggleGroup");
|
||||||
|
assert_eq!(group.active(), 0);
|
||||||
|
let changed = Rc::new(Cell::new(None));
|
||||||
|
group.connect_changed({
|
||||||
|
let changed = changed.clone();
|
||||||
|
move |index| changed.set(Some(index))
|
||||||
|
});
|
||||||
|
group.set_active(1);
|
||||||
|
assert_eq!((group.active(), changed.get()), (1, Some(1)));
|
||||||
|
|
||||||
|
let row = button_row("Row", "go-down-symbolic", || {});
|
||||||
|
assert_eq!(row.type_().name(), "AdwButtonRow");
|
||||||
|
assert_eq!(spinner().type_().name(), "AdwSpinner");
|
||||||
|
|
||||||
|
let dialog = shortcuts_dialog(&[(
|
||||||
|
"General".to_owned(),
|
||||||
|
vec![("<Control>q", "Quit".to_owned())],
|
||||||
|
)]);
|
||||||
|
assert_eq!(dialog.type_().name(), "AdwShortcutsDialog");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ pub(crate) struct DetailsWidgets {
|
|||||||
/// [`DirEntry`] and [`wire_details`] connects the buttons.
|
/// [`DirEntry`] and [`wire_details`] connects the buttons.
|
||||||
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||||
let toggle = gtk4::ToggleButton::builder()
|
let toggle = gtk4::ToggleButton::builder()
|
||||||
.icon_name("sidebar-show-right-symbolic")
|
.icon_name("pdfs-details-pane-symbolic")
|
||||||
.tooltip_text(gettext("Details (Alt+Enter)"))
|
.tooltip_text(gettext("Details (Alt+Enter)"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
|||||||
let toggle_off = toggle.clone();
|
let toggle_off = toggle.clone();
|
||||||
empty_close.connect_clicked(move |_| toggle_off.set_active(false));
|
empty_close.connect_clicked(move |_| toggle_off.set_active(false));
|
||||||
let empty_status = adw::StatusPage::builder()
|
let empty_status = adw::StatusPage::builder()
|
||||||
.icon_name("sidebar-show-right-symbolic")
|
.icon_name("pdfs-details-pane-symbolic")
|
||||||
.title(gettext("No item selected"))
|
.title(gettext("No item selected"))
|
||||||
.description(gettext("Select a file or folder to see its details."))
|
.description(gettext("Select a file or folder to see its details."))
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
@@ -359,7 +359,7 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
|||||||
pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
ui.details.details_suppress.set(true);
|
ui.details.details_suppress.set(true);
|
||||||
let d = &ui.details.details;
|
let d = &ui.details.details;
|
||||||
d.icon.set_icon_name(Some(icon_base_for(entry)));
|
d.icon.set_icon_name(Some(icon_for(entry)));
|
||||||
d.name.set_label(&entry.name);
|
d.name.set_label(&entry.name);
|
||||||
d.kind.set_label(&if entry.is_dir {
|
d.kind.set_label(&if entry.is_dir {
|
||||||
gettext("Folder")
|
gettext("Folder")
|
||||||
@@ -373,7 +373,7 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
});
|
});
|
||||||
d.size_row.set_visible(!entry.is_dir);
|
d.size_row.set_visible(!entry.is_dir);
|
||||||
d.modified_row
|
d.modified_row
|
||||||
.set_subtitle(&format_modified(entry.modified));
|
.set_subtitle(&dates::short_date(entry.modified));
|
||||||
let rel = entry_rel(ui, entry);
|
let rel = entry_rel(ui, entry);
|
||||||
let parent = match rel.rfind('/') {
|
let parent = match rel.rfind('/') {
|
||||||
Some(i) => &rel[..i],
|
Some(i) => &rel[..i],
|
||||||
@@ -494,13 +494,13 @@ pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
|
|||||||
match (items.len(), latest) {
|
match (items.len(), latest) {
|
||||||
(0, _) | (_, None) => gettext("No earlier versions"),
|
(0, _) | (_, None) => gettext("No earlier versions"),
|
||||||
// Translators: {date} is the date of the only version.
|
// Translators: {date} is the date of the only version.
|
||||||
(1, Some(t)) => gettext_f("1 version · {date}", &[("date", &format_modified(t))]),
|
(1, Some(t)) => gettext_f("1 version · {date}", &[("date", &dates::short_date(t))]),
|
||||||
// Translators: {date} is the date of the newest version.
|
// Translators: {date} is the date of the newest version.
|
||||||
(n, Some(t)) => ngettext_f(
|
(n, Some(t)) => ngettext_f(
|
||||||
"{n} version · latest {date}",
|
"{n} version · latest {date}",
|
||||||
"{n} versions · latest {date}",
|
"{n} versions · latest {date}",
|
||||||
n as u64,
|
n as u64,
|
||||||
&[("date", &format_modified(t))],
|
&[("date", &dates::short_date(t))],
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
//! The file list every file page shows: an icon grid and a column list over one
|
||||||
|
//! model and one selection, with thumbnails, context menus, rubberband selection
|
||||||
|
//! and click-to-deselect. My files, Trash, Shared with me and Shared by me build
|
||||||
|
//! one each and differ only in their columns, menus and what activating an entry
|
||||||
|
//! does (see [`FileListBehavior`]).
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// Where a page's entries go when the user acts on them. Plain function pointers:
|
||||||
|
/// each page has one of these, fixed for the life of the window.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct FileListBehavior {
|
||||||
|
/// Double-click or Enter on an entry.
|
||||||
|
pub(crate) activate: fn(&Rc<Ui>, &DirEntry),
|
||||||
|
/// The right-click menu for one entry.
|
||||||
|
pub(crate) entry_menu: fn(&Rc<Ui>, &DirEntry) -> ActionMenu,
|
||||||
|
/// The right-click menu for a multi-selection the clicked entry is part of.
|
||||||
|
pub(crate) bulk_menu: fn(&Rc<Ui>, Vec<DirEntry>) -> ActionMenu,
|
||||||
|
/// The right-click menu on empty space.
|
||||||
|
pub(crate) background_menu: fn(&Rc<Ui>) -> ActionMenu,
|
||||||
|
/// Paint the offline/cached/online-only badge. Only entries in the mounted
|
||||||
|
/// tree have a local state worth showing.
|
||||||
|
pub(crate) badges: bool,
|
||||||
|
/// Entries can be dragged onto folders to move them.
|
||||||
|
pub(crate) drag_and_drop: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A realised grid cell's thumbnail and name label.
|
||||||
|
type GridTile = (glib::WeakRef<gtk4::Overlay>, glib::WeakRef<gtk4::Label>);
|
||||||
|
|
||||||
|
/// One page's grid and list. Cheap to clone: every field is a GObject or an
|
||||||
|
/// `Rc`, so a clone is the same list.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct FileList {
|
||||||
|
/// The entries, as [`BoxedAnyObject`]-wrapped [`DirEntry`]s.
|
||||||
|
pub(crate) model: gio::ListStore,
|
||||||
|
/// One selection for both views: switching layout keeps what is selected.
|
||||||
|
pub(crate) selection: gtk4::MultiSelection,
|
||||||
|
pub(crate) grid: gtk4::GridView,
|
||||||
|
pub(crate) column_view: gtk4::ColumnView,
|
||||||
|
/// The two views, named "grid" and "list".
|
||||||
|
pub(crate) views: gtk4::Stack,
|
||||||
|
thumbnail_size: Rc<Cell<i32>>,
|
||||||
|
/// Weak references to realised grid cells. A new thumbnail size resizes only
|
||||||
|
/// these visible, recycled surfaces instead of invalidating the whole model.
|
||||||
|
tiles: Rc<RefCell<Vec<GridTile>>>,
|
||||||
|
behavior: Rc<Cell<Option<FileListBehavior>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileList {
|
||||||
|
/// Assemble the empty views. The factories need the [`Ui`] handle, so they
|
||||||
|
/// are installed later by [`FileList::wire`].
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||||
|
// Multi-select: acting on a batch is the common case, and doing it one
|
||||||
|
// confirmation dialog at a time is not a workflow. Rubberband drags a
|
||||||
|
// selection box from empty space, as a file manager does.
|
||||||
|
let selection = gtk4::MultiSelection::new(Some(model.clone()));
|
||||||
|
let grid = gtk4::GridView::builder()
|
||||||
|
.model(&selection)
|
||||||
|
.min_columns(2)
|
||||||
|
.max_columns(32)
|
||||||
|
.enable_rubberband(true)
|
||||||
|
.build();
|
||||||
|
grid.add_css_class("file-grid");
|
||||||
|
let grid_scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&grid)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let column_view = gtk4::ColumnView::builder()
|
||||||
|
.model(&selection)
|
||||||
|
.enable_rubberband(true)
|
||||||
|
.build();
|
||||||
|
column_view.add_css_class("data-table");
|
||||||
|
let column_scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&column_view)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let views = gtk4::Stack::new();
|
||||||
|
views.set_vexpand(true);
|
||||||
|
views.add_named(&grid_scroll, Some("grid"));
|
||||||
|
views.add_named(&column_scroll, Some("list"));
|
||||||
|
|
||||||
|
FileList {
|
||||||
|
model,
|
||||||
|
selection,
|
||||||
|
grid,
|
||||||
|
column_view,
|
||||||
|
views,
|
||||||
|
thumbnail_size: Rc::new(Cell::new(GRID_THUMB_DEFAULT)),
|
||||||
|
tiles: Rc::new(RefCell::new(Vec::new())),
|
||||||
|
behavior: Rc::new(Cell::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the grid tiles, the Name column, activation, the context menus
|
||||||
|
/// and click-to-deselect. The page appends its other columns afterwards.
|
||||||
|
pub(crate) fn wire(&self, ui: &Rc<Ui>, behavior: FileListBehavior) {
|
||||||
|
self.behavior.set(Some(behavior));
|
||||||
|
for view in [
|
||||||
|
self.grid.clone().upcast::<gtk4::Widget>(),
|
||||||
|
self.column_view.clone().upcast(),
|
||||||
|
] {
|
||||||
|
self.attach_background_menu(ui, &view, behavior);
|
||||||
|
self.attach_background_deselect(&view);
|
||||||
|
}
|
||||||
|
|
||||||
|
let factory = gtk4::SignalListItemFactory::new();
|
||||||
|
factory.connect_setup({
|
||||||
|
let (ui, list) = (ui.clone(), self.clone());
|
||||||
|
move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let size = list.thumbnail_size.get();
|
||||||
|
let thumbnail = file_thumbnail_widget(size, grid_fallback_size(size));
|
||||||
|
// Keep the sync-state badge inside the thumbnail surface.
|
||||||
|
let badge = gtk4::Image::builder()
|
||||||
|
.pixel_size(18)
|
||||||
|
.halign(gtk4::Align::End)
|
||||||
|
.valign(gtk4::Align::Start)
|
||||||
|
.margin_top(2)
|
||||||
|
.margin_end(2)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
badge.add_css_class("file-badge");
|
||||||
|
thumbnail.add_overlay(&badge);
|
||||||
|
// `WordChar` rather than the default `Word`: a name with no
|
||||||
|
// spaces offers no word-break opportunity, so word wrapping
|
||||||
|
// cannot break it at all and the label asks for its full natural
|
||||||
|
// width instead — one tile stretches to the width of the window
|
||||||
|
// and the grid collapses to a single column. Allowing a mid-word
|
||||||
|
// break is what keeps the two-line-then-ellipsis budget below
|
||||||
|
// enforceable for *every* name rather than only the ones that
|
||||||
|
// happen to have spaces.
|
||||||
|
let label = gtk4::Label::builder()
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
|
.justify(gtk4::Justification::Center)
|
||||||
|
.max_width_chars(13)
|
||||||
|
.width_chars(13)
|
||||||
|
.wrap(true)
|
||||||
|
.wrap_mode(gtk4::pango::WrapMode::WordChar)
|
||||||
|
.lines(2)
|
||||||
|
.build();
|
||||||
|
list.tiles
|
||||||
|
.borrow_mut()
|
||||||
|
.push((thumbnail.downgrade(), label.downgrade()));
|
||||||
|
let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||||
|
tile.add_css_class("file-tile");
|
||||||
|
tile.append(&thumbnail);
|
||||||
|
tile.append(&label);
|
||||||
|
list.attach_context_menu(&ui, item, &tile, behavior);
|
||||||
|
if behavior.drag_and_drop {
|
||||||
|
attach_drag(&ui, item, &tile);
|
||||||
|
attach_drop(&ui, item, &tile);
|
||||||
|
}
|
||||||
|
item.set_child(Some(&tile));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
factory.connect_bind({
|
||||||
|
let (ui, list) = (ui.clone(), self.clone());
|
||||||
|
move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let tile = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||||
|
let thumbnail = tile.first_child().and_downcast::<gtk4::Overlay>().unwrap();
|
||||||
|
let badge = thumbnail
|
||||||
|
.last_child()
|
||||||
|
.and_downcast::<gtk4::Image>()
|
||||||
|
.unwrap();
|
||||||
|
let label = thumbnail
|
||||||
|
.next_sibling()
|
||||||
|
.and_downcast::<gtk4::Label>()
|
||||||
|
.unwrap();
|
||||||
|
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||||
|
let entry = obj.borrow::<DirEntry>();
|
||||||
|
resize_grid_tile(&thumbnail, &label, list.thumbnail_size.get());
|
||||||
|
bind_file_thumbnail(&ui, &thumbnail, &entry, false);
|
||||||
|
label.set_label(&entry.name);
|
||||||
|
label.set_tooltip_text(Some(&entry.name));
|
||||||
|
if behavior.badges {
|
||||||
|
apply_badge(&badge, &entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.grid.set_factory(Some(&factory));
|
||||||
|
|
||||||
|
let ui_grid = ui.clone();
|
||||||
|
self.grid.connect_activate(move |grid, pos| {
|
||||||
|
if let Some(entry) = entry_at(grid.model().as_ref(), pos) {
|
||||||
|
(behavior.activate)(&ui_grid, &entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.column_view
|
||||||
|
.append_column(&self.name_column(ui, behavior));
|
||||||
|
let ui_col = ui.clone();
|
||||||
|
self.column_view.connect_activate(move |view, pos| {
|
||||||
|
if let Some(entry) = entry_at(view.model().as_ref(), pos) {
|
||||||
|
(behavior.activate)(&ui_col, &entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every highlighted entry, in model order.
|
||||||
|
///
|
||||||
|
/// Walks the model rather than the selection bitset: a listing is at most a
|
||||||
|
/// few thousand rows, and asking each position whether it is selected keeps
|
||||||
|
/// this free of bitset-iterator lifetimes for no measurable cost.
|
||||||
|
pub(crate) fn selected(&self) -> Vec<DirEntry> {
|
||||||
|
(0..self.selection.n_items())
|
||||||
|
.filter(|i| self.selection.is_selected(*i))
|
||||||
|
.filter_map(|i| entry_at(Some(&self.selection), i))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the grid, or the list.
|
||||||
|
pub(crate) fn show_list(&self, list: bool) {
|
||||||
|
self.views
|
||||||
|
.set_visible_child_name(if list { "list" } else { "grid" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the list, rather than the grid, is on screen.
|
||||||
|
pub(crate) fn shows_list(&self) -> bool {
|
||||||
|
self.views.visible_child_name().as_deref() == Some("list")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize the grid's thumbnails. Only realised cells are touched; the rest
|
||||||
|
/// pick the size up when they are bound.
|
||||||
|
pub(crate) fn set_thumbnail_size(&self, size: i32) {
|
||||||
|
if self.thumbnail_size.replace(size) == size {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.tiles
|
||||||
|
.borrow_mut()
|
||||||
|
.retain(|(thumbnail_ref, label_ref)| {
|
||||||
|
let (Some(thumbnail), Some(label)) = (thumbnail_ref.upgrade(), label_ref.upgrade())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
resize_grid_tile(&thumbnail, &label, size);
|
||||||
|
true
|
||||||
|
});
|
||||||
|
self.grid.queue_resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Menu key or Shift+F10: the menu for the selection, or the background
|
||||||
|
/// menu when nothing is selected, opened at the focused item.
|
||||||
|
pub(crate) fn popup_keyboard_menu(&self, ui: &Rc<Ui>) {
|
||||||
|
let Some(behavior) = self.behavior.get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let view = self
|
||||||
|
.views
|
||||||
|
.visible_child()
|
||||||
|
.unwrap_or_else(|| self.views.clone().upcast());
|
||||||
|
// The focused cell is where the eye is; without one, the middle of the view.
|
||||||
|
let (x, y) = view
|
||||||
|
.root()
|
||||||
|
.and_then(|root| root.focus())
|
||||||
|
.filter(|focus| focus.is_ancestor(&view))
|
||||||
|
.and_then(|focus| focus.compute_bounds(&view))
|
||||||
|
.map(|b| {
|
||||||
|
(
|
||||||
|
(b.x() + b.width() / 2.0) as f64,
|
||||||
|
(b.y() + b.height() / 2.0) as f64,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or((view.width() as f64 / 2.0, view.height() as f64 / 2.0));
|
||||||
|
let selected = self.selected();
|
||||||
|
let menu = match selected.len() {
|
||||||
|
0 => (behavior.background_menu)(ui),
|
||||||
|
1 => (behavior.entry_menu)(ui, &selected[0]),
|
||||||
|
_ => (behavior.bulk_menu)(ui, selected),
|
||||||
|
};
|
||||||
|
menu.popup_at(&view, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Name column: a small thumbnail with its local-state badge overlaid,
|
||||||
|
/// followed by the name and the same right-click menu the grid tiles carry.
|
||||||
|
fn name_column(&self, ui: &Rc<Ui>, behavior: FileListBehavior) -> gtk4::ColumnViewColumn {
|
||||||
|
let factory = gtk4::SignalListItemFactory::new();
|
||||||
|
factory.connect_setup({
|
||||||
|
let (ui, list) = (ui.clone(), self.clone());
|
||||||
|
move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let thumbnail = file_thumbnail_widget(28, 16);
|
||||||
|
let badge = gtk4::Image::builder()
|
||||||
|
.pixel_size(14)
|
||||||
|
.halign(gtk4::Align::End)
|
||||||
|
.valign(gtk4::Align::Start)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
badge.add_css_class("file-badge");
|
||||||
|
thumbnail.add_overlay(&badge);
|
||||||
|
// Ellipsized so the Name column can be *narrower* than its
|
||||||
|
// longest name. Without it the label's minimum width is the whole
|
||||||
|
// string, the column inherits that minimum, and one long name
|
||||||
|
// pushes the other columns off the right edge of the window for
|
||||||
|
// every row.
|
||||||
|
let label = gtk4::Label::builder()
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
|
.build();
|
||||||
|
let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
|
cell.append(&thumbnail);
|
||||||
|
cell.append(&label);
|
||||||
|
list.attach_context_menu(&ui, item, &cell, behavior);
|
||||||
|
if behavior.drag_and_drop {
|
||||||
|
attach_drag(&ui, item, &cell);
|
||||||
|
attach_drop(&ui, item, &cell);
|
||||||
|
}
|
||||||
|
item.set_child(Some(&cell));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
factory.connect_bind({
|
||||||
|
let ui = ui.clone();
|
||||||
|
move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||||
|
let thumbnail = cell.first_child().and_downcast::<gtk4::Overlay>().unwrap();
|
||||||
|
let badge = thumbnail
|
||||||
|
.last_child()
|
||||||
|
.and_downcast::<gtk4::Image>()
|
||||||
|
.unwrap();
|
||||||
|
let label = thumbnail
|
||||||
|
.next_sibling()
|
||||||
|
.and_downcast::<gtk4::Label>()
|
||||||
|
.unwrap();
|
||||||
|
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||||
|
let entry = obj.borrow::<DirEntry>();
|
||||||
|
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
|
||||||
|
label.set_label(&entry.name);
|
||||||
|
label.set_tooltip_text(Some(&entry.name));
|
||||||
|
if behavior.badges {
|
||||||
|
apply_badge(&badge, &entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Name")), Some(factory));
|
||||||
|
column.set_expand(true);
|
||||||
|
column
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop the menu for whatever entry `item` is bound to when a cell is
|
||||||
|
/// right-clicked. Capturing the [`gtk4::ListItem`] rather than a snapshot of
|
||||||
|
/// the entry keeps the menu right as the view recycles cells while
|
||||||
|
/// scrolling. The click is claimed, so the background menu does not open on
|
||||||
|
/// top of it.
|
||||||
|
fn attach_context_menu(
|
||||||
|
&self,
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
item: >k4::ListItem,
|
||||||
|
anchor: >k4::Box,
|
||||||
|
behavior: FileListBehavior,
|
||||||
|
) {
|
||||||
|
let gesture = gtk4::GestureClick::new();
|
||||||
|
gesture.set_button(gtk4::gdk::BUTTON_SECONDARY);
|
||||||
|
let (ui, list, item, target) = (ui.clone(), self.clone(), item.clone(), anchor.clone());
|
||||||
|
gesture.connect_pressed(move |gesture, _, x, y| {
|
||||||
|
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
||||||
|
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() {
|
||||||
|
let entry = obj.borrow::<DirEntry>().clone();
|
||||||
|
// Right-clicking a row that is part of a multi-selection acts on
|
||||||
|
// the batch; right-clicking outside one acts on the row.
|
||||||
|
let selected = list.selected();
|
||||||
|
if selected.len() > 1 && selected.iter().any(|e| e.uid == entry.uid) {
|
||||||
|
(behavior.bulk_menu)(&ui, selected).popup_at(&target, x, y);
|
||||||
|
} else {
|
||||||
|
(behavior.entry_menu)(&ui, &entry).popup_at(&target, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
anchor.add_controller(gesture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Right-clicking the empty space of a view offers the page's own actions.
|
||||||
|
fn attach_background_menu(&self, ui: &Rc<Ui>, view: >k4::Widget, behavior: FileListBehavior) {
|
||||||
|
let gesture = gtk4::GestureClick::new();
|
||||||
|
gesture.set_button(gtk4::gdk::BUTTON_SECONDARY);
|
||||||
|
let (ui, target) = (ui.clone(), view.clone());
|
||||||
|
gesture.connect_pressed(move |_, _, x, y| {
|
||||||
|
(behavior.background_menu)(&ui).popup_at(&target, x, y);
|
||||||
|
});
|
||||||
|
view.add_controller(gesture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clicking the empty space of a view drops the selection, as in a file
|
||||||
|
/// manager. GTK's list views leave it alone, so a stray highlight otherwise
|
||||||
|
/// sticks around until another item is clicked.
|
||||||
|
fn attach_background_deselect(&self, view: >k4::Widget) {
|
||||||
|
let gesture = gtk4::GestureClick::new();
|
||||||
|
gesture.set_button(gtk4::gdk::BUTTON_PRIMARY);
|
||||||
|
// Capture sees the press before an item's own gesture claims it; this
|
||||||
|
// only looks, so the item still gets its click.
|
||||||
|
gesture.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||||
|
let (selection, target) = (self.selection.clone(), view.clone());
|
||||||
|
gesture.connect_pressed(move |gesture, _, x, y| {
|
||||||
|
let modifiers = gesture.current_event_state();
|
||||||
|
if modifiers.intersects(
|
||||||
|
gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::SHIFT_MASK,
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !on_item(&target, x, y) {
|
||||||
|
selection.unselect_all();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
view.add_controller(gesture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A header button that switches `list` between grid and list. It offers the
|
||||||
|
/// other layout, the way a toggle reads.
|
||||||
|
pub(crate) fn layout_button(list: &FileList) -> gtk4::Button {
|
||||||
|
let button = gtk4::Button::builder().valign(gtk4::Align::Center).build();
|
||||||
|
let sync = |button: >k4::Button, list: bool| {
|
||||||
|
button.set_icon_name(if list {
|
||||||
|
"view-grid-symbolic"
|
||||||
|
} else {
|
||||||
|
"view-list-symbolic"
|
||||||
|
});
|
||||||
|
button.set_tooltip_text(Some(&if list {
|
||||||
|
gettext("Show as grid (Ctrl+1)")
|
||||||
|
} else {
|
||||||
|
gettext("Show as list (Ctrl+2)")
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
sync(&button, list.shows_list());
|
||||||
|
let weak = button.downgrade();
|
||||||
|
list.views.connect_visible_child_name_notify(move |views| {
|
||||||
|
if let Some(button) = weak.upgrade() {
|
||||||
|
sync(
|
||||||
|
&button,
|
||||||
|
views.visible_child_name().as_deref() == Some("list"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let views = list.views.clone();
|
||||||
|
button.connect_clicked(move |_| {
|
||||||
|
let to_list = views.visible_child_name().as_deref() != Some("list");
|
||||||
|
views.set_visible_child_name(if to_list { "list" } else { "grid" });
|
||||||
|
});
|
||||||
|
button
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a trailing text column whose cell text is derived from each [`DirEntry`]
|
||||||
|
/// by `render`.
|
||||||
|
pub(crate) fn text_column(
|
||||||
|
title: &str,
|
||||||
|
render: impl Fn(&DirEntry) -> String + 'static,
|
||||||
|
) -> gtk4::ColumnViewColumn {
|
||||||
|
let factory = gtk4::SignalListItemFactory::new();
|
||||||
|
factory.connect_setup(|_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let label = gtk4::Label::builder()
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
|
.build();
|
||||||
|
label.add_css_class("dim-label");
|
||||||
|
item.set_child(Some(&label));
|
||||||
|
});
|
||||||
|
factory.connect_bind(move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let label = item.child().and_downcast::<gtk4::Label>().unwrap();
|
||||||
|
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||||
|
let entry = obj.borrow::<DirEntry>();
|
||||||
|
let text = render(&entry);
|
||||||
|
label.set_tooltip_text(Some(&text));
|
||||||
|
label.set_label(&text);
|
||||||
|
});
|
||||||
|
gtk4::ColumnViewColumn::new(Some(title), Some(factory))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::Label, size: i32) {
|
||||||
|
resize_file_thumbnail(thumbnail, size, grid_fallback_size(size));
|
||||||
|
let name_width = (size / 6 + 1).clamp(8, 24);
|
||||||
|
label.set_width_chars(name_width);
|
||||||
|
label.set_max_width_chars(name_width);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grid_fallback_size(thumbnail_size: i32) -> i32 {
|
||||||
|
(thumbnail_size * 8 / 9).clamp(24, thumbnail_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `(x, y)` in `view` lands on an item: a grid tile ("child"), a list
|
||||||
|
/// row ("row"), or a column header ("header"), which has clicks of its own.
|
||||||
|
fn on_item(view: >k4::Widget, x: f64, y: f64) -> bool {
|
||||||
|
let mut widget = view.pick(x, y, gtk4::PickFlags::DEFAULT);
|
||||||
|
while let Some(w) = widget {
|
||||||
|
if &w == view {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if matches!(w.css_name().as_str(), "child" | "row" | "header") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
widget = w.parent();
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every page's file list.
|
||||||
|
pub(crate) fn file_lists(ui: &Ui) -> [&FileList; 4] {
|
||||||
|
[
|
||||||
|
&ui.browser.files,
|
||||||
|
&ui.trash.files,
|
||||||
|
&ui.shared.files,
|
||||||
|
&ui.shared_by_me.files,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file list of the page on screen, if it has one.
|
||||||
|
pub(crate) fn visible_file_list(ui: &Ui) -> Option<&FileList> {
|
||||||
|
match ui.stack.visible_child_name()?.as_str() {
|
||||||
|
"browser" => Some(&ui.browser.files),
|
||||||
|
"trash" => Some(&ui.trash.files),
|
||||||
|
"sharedbyme" => Some(&ui.shared_by_me.files),
|
||||||
|
"shared" if ui.shared.views.visible_child_name().as_deref() == Some("files") => {
|
||||||
|
Some(&ui.shared.files)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
/// An action run by an [`ActionMenu`] item.
|
|
||||||
pub(crate) type MenuAction = Box<dyn Fn()>;
|
|
||||||
|
|
||||||
/// A `gio::Menu` whose items run closures, for menus whose contents depend on
|
/// A `gio::Menu` whose items run closures, for menus whose contents depend on
|
||||||
/// what they were opened for (a row, a selection, a photo). Each item gets its
|
/// what they were opened for (a row, a selection, a photo). Each item gets its
|
||||||
/// own action in a private `menu` group, installed on whatever hosts the
|
/// own action in a private `menu` group, installed on whatever hosts the
|
||||||
@@ -148,12 +145,3 @@ fn popover_host(anchor: >k4::Widget) -> gtk4::Widget {
|
|||||||
}
|
}
|
||||||
host
|
host
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A flat ⋮ button whose menu lists `items` as (label, action).
|
|
||||||
pub(crate) fn more_menu_button(items: Vec<(&str, MenuAction)>) -> gtk4::MenuButton {
|
|
||||||
let mut menu = ActionMenu::new();
|
|
||||||
for (label, run) in items {
|
|
||||||
menu.item(label, run);
|
|
||||||
}
|
|
||||||
menu.button()
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
pub(crate) mod compat;
|
||||||
pub(crate) mod details;
|
pub(crate) mod details;
|
||||||
|
pub(crate) mod file_list;
|
||||||
pub(crate) mod menu;
|
pub(crate) mod menu;
|
||||||
|
pub(crate) mod scrubber;
|
||||||
pub(crate) mod share_dialog;
|
pub(crate) mod share_dialog;
|
||||||
pub(crate) mod thumbnails;
|
pub(crate) mod thumbnails;
|
||||||
pub(crate) mod versions_dialog;
|
pub(crate) mod versions_dialog;
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
//! The Photos timeline's month scrubber, laid over the grid's right edge as in
|
||||||
|
//! Google Photos: a thin tick where each year starts, a knob at the month on
|
||||||
|
//! screen, and a bubble naming the month under the pointer. It stays out of
|
||||||
|
//! the way until the pointer nears the edge or the timeline scrolls.
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
use gtk4::cairo;
|
||||||
|
|
||||||
|
/// Width of the strip the scrubber draws in, in px.
|
||||||
|
const STRIP_WIDTH: i32 = 72;
|
||||||
|
/// How close to the right edge the pointer has to come to show the scrubber.
|
||||||
|
const EDGE: f64 = 96.0;
|
||||||
|
/// Space kept free above the first and below the last month, in px.
|
||||||
|
const PAD: f64 = 16.0;
|
||||||
|
/// How long the scrubber stays up after the timeline stops scrolling.
|
||||||
|
const LINGER: Duration = Duration::from_millis(1500);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct State {
|
||||||
|
/// How many months the strip spans, newest at the top.
|
||||||
|
months: usize,
|
||||||
|
/// Where each year starts, as `(month index, label)`.
|
||||||
|
years: Vec<(usize, String)>,
|
||||||
|
/// The month the knob marks.
|
||||||
|
value: usize,
|
||||||
|
/// Whether the timeline has months to move between at all.
|
||||||
|
available: bool,
|
||||||
|
/// The pointer is near the right edge.
|
||||||
|
near: bool,
|
||||||
|
/// The month under the pointer while it is on the strip.
|
||||||
|
hover: Option<usize>,
|
||||||
|
/// Where the running drag started, in the strip's coordinates.
|
||||||
|
drag_from: Option<f64>,
|
||||||
|
/// Keeps the scrubber up for a moment after a scroll.
|
||||||
|
linger: Option<glib::SourceId>,
|
||||||
|
label: Option<Rc<dyn Fn(usize) -> String>>,
|
||||||
|
on_pick: Option<Rc<dyn Fn(usize)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scrubber's widgets and state. Cheap to clone: every clone is the same
|
||||||
|
/// scrubber.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct Scrubber {
|
||||||
|
revealer: gtk4::Revealer,
|
||||||
|
area: gtk4::DrawingArea,
|
||||||
|
knob: gtk4::Box,
|
||||||
|
bubble: gtk4::Label,
|
||||||
|
/// The timeline the scrubber lies over.
|
||||||
|
scroll: Option<gtk4::ScrolledWindow>,
|
||||||
|
state: Rc<RefCell<State>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scrubber {
|
||||||
|
/// Build the scrubber over `host`, whose child is the scrolled timeline.
|
||||||
|
pub(crate) fn new(host: >k4::Overlay) -> Self {
|
||||||
|
let scroll = host.child().and_downcast::<gtk4::ScrolledWindow>();
|
||||||
|
let area = gtk4::DrawingArea::builder()
|
||||||
|
.width_request(STRIP_WIDTH)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
area.add_css_class("photo-scrubber");
|
||||||
|
let knob = gtk4::Box::builder()
|
||||||
|
.width_request(24)
|
||||||
|
.height_request(4)
|
||||||
|
.halign(gtk4::Align::End)
|
||||||
|
.valign(gtk4::Align::Start)
|
||||||
|
.margin_end(4)
|
||||||
|
.can_target(false)
|
||||||
|
.build();
|
||||||
|
knob.add_css_class("photo-scrubber-knob");
|
||||||
|
let strip = gtk4::Overlay::new();
|
||||||
|
strip.set_child(Some(&area));
|
||||||
|
strip.add_overlay(&knob);
|
||||||
|
let revealer = gtk4::Revealer::builder()
|
||||||
|
.transition_type(gtk4::RevealerTransitionType::Crossfade)
|
||||||
|
.transition_duration(150)
|
||||||
|
.halign(gtk4::Align::End)
|
||||||
|
.valign(gtk4::Align::Fill)
|
||||||
|
.can_target(false)
|
||||||
|
.child(&strip)
|
||||||
|
.build();
|
||||||
|
let bubble = gtk4::Label::builder()
|
||||||
|
.halign(gtk4::Align::End)
|
||||||
|
.valign(gtk4::Align::Start)
|
||||||
|
.margin_end(STRIP_WIDTH)
|
||||||
|
.can_target(false)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
bubble.add_css_class("photo-scrubber-bubble");
|
||||||
|
host.add_overlay(&revealer);
|
||||||
|
host.add_overlay(&bubble);
|
||||||
|
|
||||||
|
let scrubber = Self {
|
||||||
|
revealer,
|
||||||
|
area,
|
||||||
|
knob,
|
||||||
|
bubble,
|
||||||
|
scroll,
|
||||||
|
state: Rc::default(),
|
||||||
|
};
|
||||||
|
scrubber.wire(host);
|
||||||
|
scrubber
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wire(&self, host: >k4::Overlay) {
|
||||||
|
let this = self.clone();
|
||||||
|
self.area.set_draw_func(move |area, cr, width, height| {
|
||||||
|
this.draw(area, cr, width, height);
|
||||||
|
});
|
||||||
|
let this = self.clone();
|
||||||
|
self.area.connect_resize(move |_, _, _| this.place_knob());
|
||||||
|
|
||||||
|
// Near the edge is judged on the whole timeline, not the strip: the
|
||||||
|
// strip takes no input while it is hidden.
|
||||||
|
let edge = gtk4::EventControllerMotion::new();
|
||||||
|
let this = self.clone();
|
||||||
|
let host_motion = host.clone();
|
||||||
|
edge.connect_motion(move |_, x, _| {
|
||||||
|
let near = x >= f64::from(host_motion.width()) - EDGE;
|
||||||
|
if this.state.borrow().near != near {
|
||||||
|
this.state.borrow_mut().near = near;
|
||||||
|
this.sync_reveal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let this = self.clone();
|
||||||
|
edge.connect_leave(move |_| {
|
||||||
|
this.state.borrow_mut().near = false;
|
||||||
|
this.sync_reveal();
|
||||||
|
});
|
||||||
|
host.add_controller(edge);
|
||||||
|
|
||||||
|
let hover = gtk4::EventControllerMotion::new();
|
||||||
|
let this = self.clone();
|
||||||
|
hover.connect_motion(move |_, _, y| {
|
||||||
|
let index = this.index_at(y);
|
||||||
|
this.state.borrow_mut().hover = index;
|
||||||
|
this.sync_bubble();
|
||||||
|
});
|
||||||
|
let this = self.clone();
|
||||||
|
hover.connect_leave(move |_| {
|
||||||
|
this.state.borrow_mut().hover = None;
|
||||||
|
this.sync_bubble();
|
||||||
|
});
|
||||||
|
self.area.add_controller(hover);
|
||||||
|
|
||||||
|
// A click is a drag that goes nowhere, so this covers both.
|
||||||
|
let drag = gtk4::GestureDrag::new();
|
||||||
|
let this = self.clone();
|
||||||
|
drag.connect_drag_begin(move |_, _, y| {
|
||||||
|
this.state.borrow_mut().drag_from = Some(y);
|
||||||
|
this.pick(y, true);
|
||||||
|
});
|
||||||
|
let this = self.clone();
|
||||||
|
drag.connect_drag_update(move |_, _, dy| {
|
||||||
|
let from = this.state.borrow().drag_from;
|
||||||
|
if let Some(from) = from {
|
||||||
|
this.pick(from + dy, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let this = self.clone();
|
||||||
|
drag.connect_drag_end(move |_, _, _| {
|
||||||
|
this.state.borrow_mut().drag_from = None;
|
||||||
|
this.sync_reveal();
|
||||||
|
this.sync_bubble();
|
||||||
|
});
|
||||||
|
self.area.add_controller(drag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Span `months` months, newest first, with a tick and a label where each
|
||||||
|
/// of `years` starts. Moves the knob back to the top.
|
||||||
|
pub(crate) fn set_months(&self, months: usize, years: Vec<(usize, String)>) {
|
||||||
|
{
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
state.months = months;
|
||||||
|
state.years = years;
|
||||||
|
state.value = 0;
|
||||||
|
}
|
||||||
|
self.area.queue_draw();
|
||||||
|
self.place_knob();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether there is anything to scrub through. The scrubber never shows
|
||||||
|
/// while there is not.
|
||||||
|
pub(crate) fn set_available(&self, available: bool) {
|
||||||
|
self.state.borrow_mut().available = available;
|
||||||
|
// The scrubber takes the scrollbar's place at the edge; both at once
|
||||||
|
// would fight over the same strip.
|
||||||
|
if let Some(scroll) = &self.scroll {
|
||||||
|
scroll.set_vscrollbar_policy(if available {
|
||||||
|
gtk4::PolicyType::External
|
||||||
|
} else {
|
||||||
|
gtk4::PolicyType::Automatic
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.sync_reveal();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_available(&self) -> bool {
|
||||||
|
self.state.borrow().available
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark month `index`, unless the user has the scrubber in hand: the
|
||||||
|
/// timeline's scroll position must not move the knob out from under them.
|
||||||
|
pub(crate) fn set_value(&self, index: usize) {
|
||||||
|
{
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
if state.drag_from.is_some() || state.hover.is_some() || state.value == index {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.value = index;
|
||||||
|
}
|
||||||
|
self.place_knob();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a month index reads in the bubble, such as "March 2024".
|
||||||
|
pub(crate) fn set_label_func(&self, label: impl Fn(usize) -> String + 'static) {
|
||||||
|
self.state.borrow_mut().label = Some(Rc::new(label));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call `f` with the month the user points the scrubber at.
|
||||||
|
pub(crate) fn connect_pick(&self, f: impl Fn(usize) + 'static) {
|
||||||
|
self.state.borrow_mut().on_pick = Some(Rc::new(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the scrubber for a moment, as the timeline scrolls, so it can be
|
||||||
|
/// found without hunting for the edge — and on a touch screen at all.
|
||||||
|
pub(crate) fn flash(&self) {
|
||||||
|
if !self.is_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(source) = self.state.borrow_mut().linger.take() {
|
||||||
|
source.remove();
|
||||||
|
}
|
||||||
|
let this = self.clone();
|
||||||
|
let source = glib::timeout_add_local_once(LINGER, move || {
|
||||||
|
this.state.borrow_mut().linger = None;
|
||||||
|
this.sync_reveal();
|
||||||
|
});
|
||||||
|
self.state.borrow_mut().linger = Some(source);
|
||||||
|
self.sync_reveal();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_reveal(&self) {
|
||||||
|
let show = {
|
||||||
|
let state = self.state.borrow();
|
||||||
|
state.available && (state.near || state.drag_from.is_some() || state.linger.is_some())
|
||||||
|
};
|
||||||
|
self.revealer.set_reveal_child(show);
|
||||||
|
// A hidden revealer still claims its strip of the grid; let the clicks
|
||||||
|
// through to the tiles underneath.
|
||||||
|
self.revealer.set_can_target(show);
|
||||||
|
if !show {
|
||||||
|
self.state.borrow_mut().hover = None;
|
||||||
|
self.bubble.set_visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bubble names the month being dragged to, or else the one under
|
||||||
|
/// the pointer.
|
||||||
|
fn sync_bubble(&self) {
|
||||||
|
let (index, label) = {
|
||||||
|
let state = self.state.borrow();
|
||||||
|
let index = if state.drag_from.is_some() {
|
||||||
|
Some(state.value)
|
||||||
|
} else {
|
||||||
|
state.hover
|
||||||
|
};
|
||||||
|
(index, state.label.clone())
|
||||||
|
};
|
||||||
|
let (Some(index), Some(label)) = (index, label) else {
|
||||||
|
self.bubble.set_visible(false);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.bubble.set_label(&label(index));
|
||||||
|
let (_, height, _, _) = self.bubble.measure(gtk4::Orientation::Vertical, -1);
|
||||||
|
let y = self.y_of(index) - f64::from(height) / 2.0;
|
||||||
|
self.bubble.set_margin_top(y.max(0.0) as i32);
|
||||||
|
self.bubble.set_visible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the knob to month `y` points at, and tell the page when that is a
|
||||||
|
/// different month — or when a new drag starts, so a click jumps even to
|
||||||
|
/// the month already marked.
|
||||||
|
fn pick(&self, y: f64, starting: bool) {
|
||||||
|
let Some(index) = self.index_at(y) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (changed, on_pick) = {
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
let changed = state.value != index;
|
||||||
|
state.value = index;
|
||||||
|
(changed, state.on_pick.clone())
|
||||||
|
};
|
||||||
|
self.place_knob();
|
||||||
|
self.sync_bubble();
|
||||||
|
if let (true, Some(on_pick)) = (changed || starting, on_pick) {
|
||||||
|
on_pick(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn place_knob(&self) {
|
||||||
|
let value = self.state.borrow().value;
|
||||||
|
let y = self.y_of(value) - 2.0;
|
||||||
|
self.knob.set_margin_top(y.max(0.0) as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where month `index` sits on the strip.
|
||||||
|
fn y_of(&self, index: usize) -> f64 {
|
||||||
|
let months = self.state.borrow().months;
|
||||||
|
month_y(index, months, f64::from(self.area.height()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The month at height `y` on the strip, if it spans any.
|
||||||
|
fn index_at(&self, y: f64) -> Option<usize> {
|
||||||
|
let months = self.state.borrow().months;
|
||||||
|
month_at(y, months, f64::from(self.area.height()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&self, area: >k4::DrawingArea, cr: &cairo::Context, width: i32, height: i32) {
|
||||||
|
let state = self.state.borrow();
|
||||||
|
let color = area.color();
|
||||||
|
let (width, height) = (f64::from(width), f64::from(height));
|
||||||
|
let attrs = gtk4::pango::AttrList::new();
|
||||||
|
attrs.insert(gtk4::pango::AttrFloat::new_scale(0.8));
|
||||||
|
attrs.insert(gtk4::pango::AttrInt::new_weight(gtk4::pango::Weight::Bold));
|
||||||
|
// The bottom of the last label drawn: a year crowded against the one
|
||||||
|
// above it keeps its tick but not its label.
|
||||||
|
let mut clear_below = f64::NEG_INFINITY;
|
||||||
|
for (index, year) in &state.years {
|
||||||
|
let y = month_y(*index, state.months, height);
|
||||||
|
cr.set_source_rgba(
|
||||||
|
f64::from(color.red()),
|
||||||
|
f64::from(color.green()),
|
||||||
|
f64::from(color.blue()),
|
||||||
|
0.5,
|
||||||
|
);
|
||||||
|
cr.rectangle(width - 12.0, y.round() - 0.5, 8.0, 1.0);
|
||||||
|
let _ = cr.fill();
|
||||||
|
|
||||||
|
let layout = area.create_pango_layout(Some(year));
|
||||||
|
layout.set_attributes(Some(&attrs));
|
||||||
|
let (_, logical) = layout.pixel_extents();
|
||||||
|
let (w, h) = (f64::from(logical.width()), f64::from(logical.height()));
|
||||||
|
let top = y - h / 2.0;
|
||||||
|
if top < clear_below {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
clear_below = top + h + 2.0;
|
||||||
|
cr.set_source_rgba(
|
||||||
|
f64::from(color.red()),
|
||||||
|
f64::from(color.green()),
|
||||||
|
f64::from(color.blue()),
|
||||||
|
0.8,
|
||||||
|
);
|
||||||
|
cr.move_to(width - 16.0 - w, top);
|
||||||
|
pangocairo::functions::show_layout(cr, &layout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where month `index` of `months` sits on a strip `height` px tall: evenly
|
||||||
|
/// spaced, newest at the top.
|
||||||
|
fn month_y(index: usize, months: usize, height: f64) -> f64 {
|
||||||
|
let span = (height - 2.0 * PAD).max(0.0);
|
||||||
|
if months < 2 {
|
||||||
|
return PAD;
|
||||||
|
}
|
||||||
|
PAD + span * index as f64 / (months - 1) as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The month nearest height `y` on a strip `height` px tall.
|
||||||
|
fn month_at(y: f64, months: usize, height: f64) -> Option<usize> {
|
||||||
|
let last = months.checked_sub(1)?;
|
||||||
|
let span = height - 2.0 * PAD;
|
||||||
|
if span <= 0.0 {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
let fraction = ((y - PAD) / span).clamp(0.0, 1.0);
|
||||||
|
Some((fraction * last as f64).round() as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_scrubber_maps_heights_back_to_their_months() {
|
||||||
|
let height = 432.0;
|
||||||
|
for index in 0..12 {
|
||||||
|
assert_eq!(
|
||||||
|
month_at(month_y(index, 12, height), 12, height),
|
||||||
|
Some(index)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_scrubber_clamps_past_either_end() {
|
||||||
|
assert_eq!(month_at(-50.0, 12, 432.0), Some(0));
|
||||||
|
assert_eq!(month_at(900.0, 12, 432.0), Some(11));
|
||||||
|
assert_eq!(month_at(10.0, 0, 432.0), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ impl Busy {
|
|||||||
pub(crate) fn start(button: >k4::Button) -> Self {
|
pub(crate) fn start(button: >k4::Button) -> Self {
|
||||||
let label = button.label();
|
let label = button.label();
|
||||||
let icon = button.icon_name();
|
let icon = button.icon_name();
|
||||||
let spinner = gtk4::Spinner::builder().spinning(true).build();
|
let spinner = spinner();
|
||||||
match &label {
|
match &label {
|
||||||
Some(text) => {
|
Some(text) => {
|
||||||
let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||||
@@ -407,6 +407,14 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Shared by me lists what this dialog changes: reload it when it is the
|
||||||
|
// page underneath. Any other page reads it again on the next visit.
|
||||||
|
let ui_closed = ui.clone();
|
||||||
|
dialog.connect_closed(move |_| {
|
||||||
|
if ui_closed.stack.visible_child_name().as_deref() == Some("sharedbyme") {
|
||||||
|
load_shared_by_me(&ui_closed);
|
||||||
|
}
|
||||||
|
});
|
||||||
share_dialog_reload(&state);
|
share_dialog_reload(&state);
|
||||||
dialog.present(ui_window(ui).as_ref());
|
dialog.present(ui_window(ui).as_ref());
|
||||||
}
|
}
|
||||||
@@ -427,7 +435,7 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
|||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&state.ui,
|
&state.ui,
|
||||||
&gettext("Couldn't load sharing"),
|
&gettext("Couldn't load sharing"),
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -582,6 +590,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
let url_copy = url.clone();
|
let url_copy = url.clone();
|
||||||
copy.connect_clicked(move |btn| {
|
copy.connect_clicked(move |btn| {
|
||||||
btn.clipboard().set_text(&url_copy);
|
btn.clipboard().set_text(&url_copy);
|
||||||
|
flash_copied(btn, "edit-copy-symbolic");
|
||||||
toast(&state_copy.ui, &gettext("Link copied"));
|
toast(&state_copy.ui, &gettext("Link copied"));
|
||||||
});
|
});
|
||||||
row.add_suffix(©);
|
row.add_suffix(©);
|
||||||
@@ -718,7 +727,7 @@ pub(crate) fn share_dialog_create_link(
|
|||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&state.ui,
|
&state.ui,
|
||||||
&gettext("Couldn't create the link"),
|
&gettext("Couldn't create the link"),
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -756,7 +765,7 @@ pub(crate) fn share_dialog_op(
|
|||||||
toast_error(
|
toast_error(
|
||||||
&state.ui,
|
&state.ui,
|
||||||
&failed,
|
&failed,
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
);
|
);
|
||||||
share_dialog_reload(&state);
|
share_dialog_reload(&state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ pub(crate) fn bind_file_thumbnail(
|
|||||||
let fallback_name = if symbolic_fallback {
|
let fallback_name = if symbolic_fallback {
|
||||||
format!("{}-symbolic", icon_base_for(entry))
|
format!("{}-symbolic", icon_base_for(entry))
|
||||||
} else {
|
} else {
|
||||||
icon_base_for(entry).to_string()
|
icon_for(entry).to_string()
|
||||||
};
|
};
|
||||||
fallback.set_icon_name(Some(&fallback_name));
|
fallback.set_icon_name(Some(&fallback_name));
|
||||||
fallback.set_visible(true);
|
fallback.set_visible(true);
|
||||||
@@ -214,19 +214,6 @@ pub(crate) fn bind_file_thumbnail(
|
|||||||
schedule_file_thumbs(ui);
|
schedule_file_thumbs(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create and immediately bind a thumbnail for a non-recycled row.
|
|
||||||
pub(crate) fn file_thumbnail(
|
|
||||||
ui: &Rc<Ui>,
|
|
||||||
entry: &DirEntry,
|
|
||||||
size: i32,
|
|
||||||
fallback_size: i32,
|
|
||||||
symbolic_fallback: bool,
|
|
||||||
) -> gtk4::Overlay {
|
|
||||||
let widget = file_thumbnail_widget(size, fallback_size);
|
|
||||||
bind_file_thumbnail(ui, &widget, entry, symbolic_fallback);
|
|
||||||
widget
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the file name denotes an image format for which Proton or the local
|
/// Whether the file name denotes an image format for which Proton or the local
|
||||||
/// decoder can reasonably provide a thumbnail.
|
/// decoder can reasonably provide a thumbnail.
|
||||||
pub(crate) fn is_image_name(name: &str) -> bool {
|
pub(crate) fn is_image_name(name: &str) -> bool {
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ pub(crate) fn versions_dialog_reload(state: &Rc<VersionsDialog>) {
|
|||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&state.ui,
|
&state.ui,
|
||||||
&gettext("Couldn't load versions"),
|
&gettext("Couldn't load versions"),
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -204,13 +204,13 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
|||||||
gettext_f(
|
gettext_f(
|
||||||
"{time} · {size}",
|
"{time} · {size}",
|
||||||
&[
|
&[
|
||||||
("time", &activity_time(item.created)),
|
("time", &dates::relative(item.created)),
|
||||||
("size", &human_bytes(size)),
|
("size", &human_bytes(size)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(activity_time(item.created), human_bytes(size))
|
(dates::relative(item.created), human_bytes(size))
|
||||||
};
|
};
|
||||||
if let Some(email) = &item.signed_by {
|
if let Some(email) = &item.signed_by {
|
||||||
subtitle.push_str(" · ");
|
subtitle.push_str(" · ");
|
||||||
@@ -266,7 +266,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
|||||||
/// every device on the account, not only this one.
|
/// every device on the account, not only this one.
|
||||||
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(gettext("Restore version"))
|
.heading(gettext("Restore Version?"))
|
||||||
// Translators: {name} is a file name.
|
// Translators: {name} is a file name.
|
||||||
.body(gettext_f(
|
.body(gettext_f(
|
||||||
"Make this version the current content of “{name}”? The version it replaces stays in the history.",
|
"Make this version the current content of “{name}”? The version it replaces stays in the history.",
|
||||||
@@ -301,7 +301,7 @@ fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
|||||||
/// Confirm, then permanently delete one revision.
|
/// Confirm, then permanently delete one revision.
|
||||||
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(gettext("Delete version"))
|
.heading(gettext("Delete Version?"))
|
||||||
.body(gettext(
|
.body(gettext(
|
||||||
"Delete this version permanently? Its content can't be recovered.",
|
"Delete this version permanently? Its content can't be recovered.",
|
||||||
))
|
))
|
||||||
@@ -377,7 +377,7 @@ fn versions_dialog_op(state: &Rc<VersionsDialog>, request: Request, done: &str,
|
|||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&state.ui,
|
&state.ui,
|
||||||
&failed,
|
&failed,
|
||||||
&gettext("The mount service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//! Widgets that newer libadwaita releases bring, with a stand-in built from
|
||||||
|
//! what the oldest supported release (1.5, as on Ubuntu 24.04) has.
|
||||||
|
//!
|
||||||
|
//! The app builds against 1.5 and picks at run time: when the libadwaita it
|
||||||
|
//! runs with is 1.8 or newer, the real widgets are made by class name through
|
||||||
|
//! a `gtk4::Builder`, since the 1.5 bindings have no types for them. Their
|
||||||
|
//! properties and signals are then reached by name. Call sites never care
|
||||||
|
//! which one they got.
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
|
||||||
|
/// Set to anything to use the stand-ins even on a new libadwaita, to see the
|
||||||
|
/// app as it looks on the oldest supported release.
|
||||||
|
const STAND_INS_ENV: &str = "PDFS_STAND_IN_WIDGETS";
|
||||||
|
|
||||||
|
/// Whether the libadwaita the app runs with has the 1.8 widgets.
|
||||||
|
pub(crate) fn has_adw_1_8() -> bool {
|
||||||
|
static HAS: OnceLock<bool> = OnceLock::new();
|
||||||
|
*HAS.get_or_init(|| {
|
||||||
|
std::env::var_os(STAND_INS_ENV).is_none()
|
||||||
|
&& (adw::major_version(), adw::minor_version()) >= (1, 8)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build `xml`, a GtkBuilder interface naming classes the bindings lack. It
|
||||||
|
/// is only ever one this module wrote, so a failure is a bug here.
|
||||||
|
pub(crate) fn build_ui(xml: &str) -> gtk4::Builder {
|
||||||
|
gtk4::Builder::from_string(&format!("<interface>{xml}</interface>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The object `id` from a builder [`build_ui`] made.
|
||||||
|
pub(crate) fn built<T: IsA<gtk4::glib::Object>>(builder: >k4::Builder, id: &str) -> T {
|
||||||
|
builder
|
||||||
|
.object(id)
|
||||||
|
.unwrap_or_else(|| panic!("the compat UI has no object {id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A busy spinner. It spins whenever it is shown: show and hide it with
|
||||||
|
/// `set_visible` rather than starting and stopping it.
|
||||||
|
pub(crate) type Spinner = gtk4::Widget;
|
||||||
|
|
||||||
|
/// A new [`Spinner`], shown: `AdwSpinner` on libadwaita 1.8, a spinning
|
||||||
|
/// `gtk4::Spinner` before.
|
||||||
|
pub(crate) fn spinner() -> Spinner {
|
||||||
|
if has_adw_1_8() {
|
||||||
|
let builder = build_ui(r#"<object class="AdwSpinner" id="spinner"/>"#);
|
||||||
|
built(&builder, "spinner")
|
||||||
|
} else {
|
||||||
|
// Spinning costs nothing while hidden: an unmapped widget draws no
|
||||||
|
// frames.
|
||||||
|
gtk4::Spinner::builder().spinning(true).build().upcast()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -198,6 +198,18 @@ pub(crate) fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`, or
|
||||||
|
/// `512 bytes` below one KiB).
|
||||||
|
///
|
||||||
|
/// GLib does the formatting, so the units, the plural of "bytes" and the
|
||||||
|
/// decimal separator follow the user's language from GLib's own catalog. GLib
|
||||||
|
/// separates number and unit with a no-break space; it is turned back into a
|
||||||
|
/// plain space so the text matches what the rest of the UI builds around it.
|
||||||
|
pub(crate) fn human_bytes(bytes: u64) -> String {
|
||||||
|
gtk4::glib::format_size_full(bytes, gtk4::glib::FormatSizeFlags::IEC_UNITS)
|
||||||
|
.replace('\u{a0}', " ")
|
||||||
|
}
|
||||||
|
|
||||||
/// gettext takes the count as `u32`. Counts past that pick the same form as
|
/// gettext takes the count as `u32`. Counts past that pick the same form as
|
||||||
/// the largest one, which is the plural in every shipped language.
|
/// the largest one, which is the plural in every shipped language.
|
||||||
fn plural_count(n: u64) -> u32 {
|
fn plural_count(n: u64) -> u32 {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use ksni::blocking::TrayMethods;
|
use ksni::blocking::TrayMethods;
|
||||||
@@ -21,14 +21,17 @@ use ksni::menu::StandardItem;
|
|||||||
use ksni::{MenuItem, Status, ToolTip, Tray};
|
use ksni::{MenuItem, Status, ToolTip, Tray};
|
||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{JobItem, Request, Response, TransferDirection, TransferItem, send};
|
use pdfs_core::control::{
|
||||||
|
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, follow, send,
|
||||||
|
};
|
||||||
use pdfs_core::service;
|
use pdfs_core::service;
|
||||||
|
|
||||||
mod i18n;
|
mod i18n;
|
||||||
use i18n::{gettext, gettext_f, ngettext_f};
|
use i18n::{gettext, gettext_f, ngettext_f};
|
||||||
|
|
||||||
/// How often the tray re-polls the daemon to refresh its menu.
|
/// How long the tray lets a burst of events arrive before it repaints. Progress
|
||||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
/// moves several times a second, which is more than a tray menu can show.
|
||||||
|
const COALESCE: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
/// The one word the icon says about the drive, derived from each poll.
|
/// The one word the icon says about the drive, derived from each poll.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -53,7 +56,7 @@ impl Phase {
|
|||||||
fn icon(self) -> &'static str {
|
fn icon(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Phase::Synced => "folder-remote-symbolic",
|
Phase::Synced => "folder-remote-symbolic",
|
||||||
Phase::Syncing => "emblem-synchronizing-symbolic",
|
Phase::Syncing => "view-refresh-symbolic",
|
||||||
Phase::Paused => "media-playback-pause-symbolic",
|
Phase::Paused => "media-playback-pause-symbolic",
|
||||||
Phase::Offline => "network-offline-symbolic",
|
Phase::Offline => "network-offline-symbolic",
|
||||||
Phase::Attention => "dialog-warning-symbolic",
|
Phase::Attention => "dialog-warning-symbolic",
|
||||||
@@ -139,72 +142,20 @@ struct DriveTray {
|
|||||||
/// menu can still say "Logged in …" / "Not logged in" when no mount is running.
|
/// menu can still say "Logged in …" / "Not logged in" when no mount is running.
|
||||||
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||||
match send(socket, &Request::Status) {
|
match send(socket, &Request::Status) {
|
||||||
Ok(Response::Status {
|
Ok(status @ Response::Status { .. }) => {
|
||||||
mountpoint,
|
// Same daemon is up, so a cheap follow-up request gives the sync line.
|
||||||
pinned,
|
|
||||||
online,
|
|
||||||
pending_uploads,
|
|
||||||
pending_changes,
|
|
||||||
failing_ops,
|
|
||||||
paused,
|
|
||||||
..
|
|
||||||
}) => {
|
|
||||||
// Same daemon is up, so a cheap follow-up poll gives the sync line.
|
|
||||||
let sync = match send(socket, &Request::GetQueueStatus) {
|
let sync = match send(socket, &Request::GetQueueStatus) {
|
||||||
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
};
|
};
|
||||||
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
status_state(&status, sync, default_mountpoint)
|
||||||
let phase = phase_of(
|
|
||||||
paused,
|
|
||||||
failing_ops,
|
|
||||||
online,
|
|
||||||
queued.is_some() || !sync.is_empty(),
|
|
||||||
);
|
|
||||||
DriveState {
|
|
||||||
line: match (online, queued) {
|
|
||||||
_ if paused => gettext("Sync paused"),
|
|
||||||
_ if failing_ops > 0 => ngettext_f(
|
|
||||||
"{n} change needs attention",
|
|
||||||
"{n} changes need attention",
|
|
||||||
failing_ops,
|
|
||||||
&[],
|
|
||||||
),
|
|
||||||
// Translators: {mountpoint} is a folder path; {n} is the number of pinned items.
|
|
||||||
(true, None) => ngettext_f(
|
|
||||||
"Mounted at {mountpoint} ({n} pinned)",
|
|
||||||
"Mounted at {mountpoint} ({n} pinned)",
|
|
||||||
pinned as u64,
|
|
||||||
&[("mountpoint", &mountpoint)],
|
|
||||||
),
|
|
||||||
// Translators: {queued} is a queue summary such as "3 uploads queued"; {n} is the number of pinned items.
|
|
||||||
(true, Some(q)) => ngettext_f(
|
|
||||||
"Syncing — {queued} ({n} pinned)",
|
|
||||||
"Syncing — {queued} ({n} pinned)",
|
|
||||||
pinned as u64,
|
|
||||||
&[("queued", &q)],
|
|
||||||
),
|
|
||||||
// Translators: {n} is the number of pinned items.
|
|
||||||
(false, None) => ngettext_f(
|
|
||||||
"Offline — cached files only ({n} pinned)",
|
|
||||||
"Offline — cached files only ({n} pinned)",
|
|
||||||
pinned as u64,
|
|
||||||
&[],
|
|
||||||
),
|
|
||||||
// Translators: {queued} is a queue summary such as "3 uploads queued".
|
|
||||||
(false, Some(q)) => gettext_f("Offline — {queued}", &[("queued", &q)]),
|
|
||||||
},
|
|
||||||
phase,
|
|
||||||
mounted: true,
|
|
||||||
paused,
|
|
||||||
mountpoint: PathBuf::from(mountpoint),
|
|
||||||
sync,
|
|
||||||
failing: failing_ops,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Socket answered but with something unexpected — treat as up but odd.
|
// Socket answered but with something unexpected — treat as up but odd.
|
||||||
Ok(_) => DriveState {
|
Ok(_) => unexpected_reply(default_mountpoint),
|
||||||
line: gettext("Mount: unexpected daemon response"),
|
// The daemon is up but stuck: say so rather than "not connected", and
|
||||||
|
// keep the mount actions, which still work on the folder itself.
|
||||||
|
Err(e) if e.is_timeout() => DriveState {
|
||||||
|
line: gettext("Proton Drive isn't responding"),
|
||||||
phase: Phase::Attention,
|
phase: Phase::Attention,
|
||||||
mounted: true,
|
mounted: true,
|
||||||
mountpoint: default_mountpoint.to_path_buf(),
|
mountpoint: default_mountpoint.to_path_buf(),
|
||||||
@@ -218,7 +169,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
Ok(s) => (
|
Ok(s) => (
|
||||||
// Translators: {username} is the Proton account name.
|
// Translators: {username} is the Proton account name.
|
||||||
gettext_f(
|
gettext_f(
|
||||||
"Logged in as {username} — not mounted",
|
"Signed in as {username} — not connected",
|
||||||
&[("username", &s.username)],
|
&[("username", &s.username)],
|
||||||
),
|
),
|
||||||
Phase::Disconnected,
|
Phase::Disconnected,
|
||||||
@@ -243,6 +194,86 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the tray says when the daemon answers with something it did not ask
|
||||||
|
/// for: up, but odd.
|
||||||
|
fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
|
||||||
|
DriveState {
|
||||||
|
line: gettext("Proton Drive sent an unexpected reply"),
|
||||||
|
phase: Phase::Attention,
|
||||||
|
mounted: true,
|
||||||
|
mountpoint: default_mountpoint.to_path_buf(),
|
||||||
|
sync: String::new(),
|
||||||
|
paused: false,
|
||||||
|
failing: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state a running daemon's [`Response::Status`] describes, with `sync`
|
||||||
|
/// saying what it is moving right now.
|
||||||
|
fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState {
|
||||||
|
let Response::Status {
|
||||||
|
mountpoint,
|
||||||
|
pinned,
|
||||||
|
online,
|
||||||
|
pending_uploads,
|
||||||
|
pending_changes,
|
||||||
|
failing_ops,
|
||||||
|
paused,
|
||||||
|
..
|
||||||
|
} = status
|
||||||
|
else {
|
||||||
|
return unexpected_reply(default_mountpoint);
|
||||||
|
};
|
||||||
|
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
|
||||||
|
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
|
||||||
|
let phase = phase_of(
|
||||||
|
paused,
|
||||||
|
failing_ops,
|
||||||
|
online,
|
||||||
|
queued.is_some() || !sync.is_empty(),
|
||||||
|
);
|
||||||
|
DriveState {
|
||||||
|
line: match (online, queued) {
|
||||||
|
_ if paused => gettext("Sync paused"),
|
||||||
|
_ if failing_ops > 0 => ngettext_f(
|
||||||
|
"{n} change needs attention",
|
||||||
|
"{n} changes need attention",
|
||||||
|
failing_ops,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
// Translators: {mountpoint} is a folder path; {n} is the number of pinned items.
|
||||||
|
(true, None) => ngettext_f(
|
||||||
|
"Up to date in {mountpoint} ({n} pinned)",
|
||||||
|
"Up to date in {mountpoint} ({n} pinned)",
|
||||||
|
pinned as u64,
|
||||||
|
&[("mountpoint", mountpoint)],
|
||||||
|
),
|
||||||
|
// Translators: {queued} is a queue summary such as "3 uploads queued"; {n} is the number of pinned items.
|
||||||
|
(true, Some(q)) => ngettext_f(
|
||||||
|
"Syncing — {queued} ({n} pinned)",
|
||||||
|
"Syncing — {queued} ({n} pinned)",
|
||||||
|
pinned as u64,
|
||||||
|
&[("queued", &q)],
|
||||||
|
),
|
||||||
|
// Translators: {n} is the number of pinned items.
|
||||||
|
(false, None) => ngettext_f(
|
||||||
|
"Offline — cached files only ({n} pinned)",
|
||||||
|
"Offline — cached files only ({n} pinned)",
|
||||||
|
pinned as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
// Translators: {queued} is a queue summary such as "3 uploads queued".
|
||||||
|
(false, Some(q)) => gettext_f("Offline — {queued}", &[("queued", &q)]),
|
||||||
|
},
|
||||||
|
phase,
|
||||||
|
mounted: true,
|
||||||
|
paused,
|
||||||
|
mountpoint: PathBuf::from(mountpoint),
|
||||||
|
sync,
|
||||||
|
failing: failing_ops,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The phase a running mount is in. A pause is the user's own doing and says
|
/// The phase a running mount is in. A pause is the user's own doing and says
|
||||||
/// so first; failures outrank everything else the daemon reports.
|
/// so first; failures outrank everything else the daemon reports.
|
||||||
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
|
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
|
||||||
@@ -490,13 +521,53 @@ fn main() -> Result<()> {
|
|||||||
.spawn()
|
.spawn()
|
||||||
.context("start tray service")?;
|
.context("start tray service")?;
|
||||||
|
|
||||||
// Poll the daemon forever, pushing each fresh snapshot into the tray so the
|
// Follow the daemon forever, pushing each fresh state into the tray so the
|
||||||
// menu reflects mount/login changes made elsewhere (e.g. via the CLI).
|
// menu reflects mount/login changes made elsewhere (e.g. via the CLI).
|
||||||
loop {
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::sleep(POLL_INTERVAL);
|
let feed_socket = socket.clone();
|
||||||
let st = poll_state(&socket, &default_mountpoint);
|
std::thread::spawn(move || follow(&feed_socket, |feed| tx.send(feed).is_ok()));
|
||||||
|
let mut heard = Heard::default();
|
||||||
|
while let Ok(feed) = rx.recv() {
|
||||||
|
heard.take(feed);
|
||||||
|
let until = Instant::now() + COALESCE;
|
||||||
|
while let Ok(feed) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
|
||||||
|
heard.take(feed);
|
||||||
|
}
|
||||||
|
let st = match &heard.status {
|
||||||
|
Some(status) => status_state(
|
||||||
|
status,
|
||||||
|
sync_line(&heard.items, &heard.jobs),
|
||||||
|
&default_mountpoint,
|
||||||
|
),
|
||||||
|
// Lost the daemon: ask once, which describes the sign-in instead.
|
||||||
|
None => poll_state(&socket, &default_mountpoint),
|
||||||
|
};
|
||||||
handle.update(move |t: &mut DriveTray| t.state = st);
|
handle.update(move |t: &mut DriveTray| t.state = st);
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the tray has heard from the daemon's event feed.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Heard {
|
||||||
|
/// The last status, or `None` once the daemon has gone.
|
||||||
|
status: Option<Response>,
|
||||||
|
items: Vec<TransferItem>,
|
||||||
|
jobs: Vec<JobItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Heard {
|
||||||
|
fn take(&mut self, feed: Feed) {
|
||||||
|
match feed {
|
||||||
|
Feed::Event(Event::Status(status)) => self.status = Some(*status),
|
||||||
|
Feed::Event(Event::Transfers { items, jobs }) => {
|
||||||
|
self.items = items;
|
||||||
|
self.jobs = jobs;
|
||||||
|
}
|
||||||
|
Feed::Disconnected => *self = Self::default(),
|
||||||
|
Feed::Connected | Feed::Event(Event::Changed { .. } | Event::Heartbeat) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ use pdfs_core::menu::PromptMode;
|
|||||||
use pdfs_core::opener::{self, OpenWith};
|
use pdfs_core::opener::{self, OpenWith};
|
||||||
|
|
||||||
mod activation;
|
mod activation;
|
||||||
|
mod compat;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
use i18n::{gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
|
use compat::{Spinner, spinner};
|
||||||
|
use i18n::{gettext, gettext_f, gettext_noop, human_bytes, ngettext_f, pgettext};
|
||||||
mod dmenu;
|
mod dmenu;
|
||||||
mod fzf;
|
mod fzf;
|
||||||
mod query;
|
mod query;
|
||||||
@@ -235,7 +237,7 @@ struct Ui {
|
|||||||
opener: OpenWith,
|
opener: OpenWith,
|
||||||
|
|
||||||
entry: gtk4::Entry,
|
entry: gtk4::Entry,
|
||||||
spinner: gtk4::Spinner,
|
spinner: Spinner,
|
||||||
stack: gtk4::Stack,
|
stack: gtk4::Stack,
|
||||||
scroller: gtk4::ScrolledWindow,
|
scroller: gtk4::ScrolledWindow,
|
||||||
results: gtk4::Box,
|
results: gtk4::Box,
|
||||||
@@ -542,7 +544,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
|||||||
entry.add_css_class("search-entry");
|
entry.add_css_class("search-entry");
|
||||||
search_bar.append(&entry);
|
search_bar.append(&entry);
|
||||||
|
|
||||||
let spinner = gtk4::Spinner::new();
|
let spinner = spinner();
|
||||||
spinner.set_visible(false);
|
spinner.set_visible(false);
|
||||||
search_bar.append(&spinner);
|
search_bar.append(&spinner);
|
||||||
|
|
||||||
@@ -586,7 +588,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
|||||||
let offline = adw::StatusPage::builder()
|
let offline = adw::StatusPage::builder()
|
||||||
.icon_name("network-offline-symbolic")
|
.icon_name("network-offline-symbolic")
|
||||||
.title(gettext("Proton Drive is not running"))
|
.title(gettext("Proton Drive is not running"))
|
||||||
.description(gettext("Start the mount daemon to search your Drive."))
|
.description(gettext("Start Proton Drive to search your files."))
|
||||||
.build();
|
.build();
|
||||||
offline.add_css_class("compact");
|
offline.add_css_class("compact");
|
||||||
let retry = gtk4::Button::builder()
|
let retry = gtk4::Button::builder()
|
||||||
@@ -824,7 +826,6 @@ impl Ui {
|
|||||||
self.query_id.set(self.query_id.get() + 1);
|
self.query_id.set(self.query_id.get() + 1);
|
||||||
self.pending.set(0);
|
self.pending.set(0);
|
||||||
self.open_pending.set(false);
|
self.open_pending.set(false);
|
||||||
self.spinner.stop();
|
|
||||||
self.spinner.set_visible(false);
|
self.spinner.set_visible(false);
|
||||||
self.suppress_entry_change.set(true);
|
self.suppress_entry_change.set(true);
|
||||||
self.entry.set_text("");
|
self.entry.set_text("");
|
||||||
@@ -876,7 +877,7 @@ impl Ui {
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
ui.entry.set_sensitive(false);
|
ui.entry.set_sensitive(false);
|
||||||
ui.hint.set_label(&gettext("Daemon offline"));
|
ui.hint.set_label(&gettext("Proton Drive isn't running"));
|
||||||
ui.stack.set_visible_child_name("offline");
|
ui.stack.set_visible_child_name("offline");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -887,10 +888,9 @@ impl Ui {
|
|||||||
/// the offline page and lock the entry; the retry button re-runs `connect`.
|
/// the offline page and lock the entry; the retry button re-runs `connect`.
|
||||||
fn go_offline(self: &Rc<Self>) {
|
fn go_offline(self: &Rc<Self>) {
|
||||||
self.pending.set(0);
|
self.pending.set(0);
|
||||||
self.spinner.stop();
|
|
||||||
self.spinner.set_visible(false);
|
self.spinner.set_visible(false);
|
||||||
self.entry.set_sensitive(false);
|
self.entry.set_sensitive(false);
|
||||||
self.hint.set_label(&gettext("Daemon offline"));
|
self.hint.set_label(&gettext("Proton Drive isn't running"));
|
||||||
self.stack.set_visible_child_name("offline");
|
self.stack.set_visible_child_name("offline");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,7 +990,6 @@ impl Ui {
|
|||||||
self.query_id.set(id);
|
self.query_id.set(id);
|
||||||
self.pending.set(requests);
|
self.pending.set(requests);
|
||||||
self.spinner.set_visible(true);
|
self.spinner.set_visible(true);
|
||||||
self.spinner.start();
|
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1003,7 +1002,6 @@ impl Ui {
|
|||||||
let left = self.pending.get().saturating_sub(1);
|
let left = self.pending.get().saturating_sub(1);
|
||||||
self.pending.set(left);
|
self.pending.set(left);
|
||||||
if left == 0 {
|
if left == 0 {
|
||||||
self.spinner.stop();
|
|
||||||
self.spinner.set_visible(false);
|
self.spinner.set_visible(false);
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
@@ -1292,7 +1290,6 @@ impl Ui {
|
|||||||
|
|
||||||
self.opening.set(true);
|
self.opening.set(true);
|
||||||
self.spinner.set_visible(true);
|
self.spinner.set_visible(true);
|
||||||
self.spinner.start();
|
|
||||||
// Translators: {name} is a file name.
|
// Translators: {name} is a file name.
|
||||||
let opening = gettext_f("Opening {name}…", &[("name", &drive.name)]);
|
let opening = gettext_f("Opening {name}…", &[("name", &drive.name)]);
|
||||||
self.hint.set_label(&opening);
|
self.hint.set_label(&opening);
|
||||||
@@ -1307,7 +1304,6 @@ impl Ui {
|
|||||||
let ui = self.clone();
|
let ui = self.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let reply = rx.recv().await;
|
let reply = rx.recv().await;
|
||||||
ui.spinner.stop();
|
|
||||||
ui.spinner.set_visible(false);
|
ui.spinner.set_visible(false);
|
||||||
ui.opening.set(false);
|
ui.opening.set(false);
|
||||||
match reply {
|
match reply {
|
||||||
@@ -1322,7 +1318,7 @@ impl Ui {
|
|||||||
&[("message", &message)],
|
&[("message", &message)],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
_ => ui.hint.set_label(&gettext("Could not reach the daemon")),
|
_ => ui.hint.set_label(&gettext("Couldn't reach Proton Drive")),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1370,7 +1366,7 @@ fn build_row(hit: &Hit) -> gtk4::ListBoxRow {
|
|||||||
meta.append(&pin);
|
meta.append(&pin);
|
||||||
}
|
}
|
||||||
if !hit.is_dir() && hit.size() > 0 {
|
if !hit.is_dir() && hit.size() > 0 {
|
||||||
let size = gtk4::Label::new(Some(&format_size(hit.size())));
|
let size = gtk4::Label::new(Some(&human_bytes(hit.size())));
|
||||||
size.add_css_class("result-meta");
|
size.add_css_class("result-meta");
|
||||||
meta.append(&size);
|
meta.append(&size);
|
||||||
}
|
}
|
||||||
@@ -1445,25 +1441,6 @@ fn parent_of(path: &str) -> String {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_size(bytes: u64) -> String {
|
|
||||||
const UNITS: [(u64, &str); 3] = [
|
|
||||||
// Translators: a file size in gigabytes; {size} is a number such as "4.2".
|
|
||||||
(1024 * 1024 * 1024, gettext_noop("{size} GB")),
|
|
||||||
// Translators: a file size in megabytes; {size} is a number such as "4.2".
|
|
||||||
(1024 * 1024, gettext_noop("{size} MB")),
|
|
||||||
// Translators: a file size in kilobytes; {size} is a number such as "4.2".
|
|
||||||
(1024, gettext_noop("{size} KB")),
|
|
||||||
];
|
|
||||||
for (scale, unit) in UNITS {
|
|
||||||
if bytes >= scale {
|
|
||||||
let size = format!("{:.1}", bytes as f64 / scale as f64);
|
|
||||||
return gettext_f(unit, &[("size", &size)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Translators: a file size in bytes; {size} is a whole number.
|
|
||||||
gettext_f("{size} B", &[("size", &bytes.to_string())])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Coarse relative age, in the granularity a launcher row has room for.
|
/// Coarse relative age, in the granularity a launcher row has room for.
|
||||||
fn format_age(epoch_secs: i64) -> String {
|
fn format_age(epoch_secs: i64) -> String {
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//! Every symbolic icon the app window and the search prompt ask for must
|
||||||
|
//! exist on a stock GNOME desktop.
|
||||||
|
//!
|
||||||
|
//! An icon name that no theme ships shows up as a missing-image glyph, and
|
||||||
|
//! nothing fails until someone looks. So each `"…-symbolic"` literal in the
|
||||||
|
//! sources has to be either one of the `pdfs-*` icons bundled in
|
||||||
|
//! `resources/icons` (and listed in the gresource manifest), or on
|
||||||
|
//! [`STOCK`], a list of names checked against Adwaita 50, libadwaita and
|
||||||
|
//! GTK's own built-in icons.
|
||||||
|
//!
|
||||||
|
//! The tray is left out: its icons are drawn by the panel, from the panel's
|
||||||
|
//! theme, not from our resources.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Stock symbolic icons the app may use. Before adding a name, check that
|
||||||
|
/// Adwaita has it outside its `legacy` folder, or that libadwaita or GTK
|
||||||
|
/// ship it themselves.
|
||||||
|
const STOCK: &[&str] = &[
|
||||||
|
// libadwaita
|
||||||
|
"adw-external-link-symbolic",
|
||||||
|
// GTK's built-in icons
|
||||||
|
"info-outline-symbolic",
|
||||||
|
// Adwaita
|
||||||
|
"checkbox-checked-symbolic",
|
||||||
|
"checkbox-symbolic",
|
||||||
|
"computer-symbolic",
|
||||||
|
"dialog-error-symbolic",
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
"document-edit-symbolic",
|
||||||
|
"document-open-recent-symbolic",
|
||||||
|
"document-open-symbolic",
|
||||||
|
"document-save-symbolic",
|
||||||
|
"drive-harddisk-symbolic",
|
||||||
|
"edit-copy-symbolic",
|
||||||
|
"edit-delete-symbolic",
|
||||||
|
"edit-find-symbolic",
|
||||||
|
"edit-undo-symbolic",
|
||||||
|
"folder-download-symbolic",
|
||||||
|
"folder-new-symbolic",
|
||||||
|
"folder-open-symbolic",
|
||||||
|
"folder-remote-symbolic",
|
||||||
|
"folder-symbolic",
|
||||||
|
"go-down-symbolic",
|
||||||
|
"go-jump-symbolic",
|
||||||
|
"go-next-symbolic",
|
||||||
|
"go-previous-symbolic",
|
||||||
|
"go-up-symbolic",
|
||||||
|
"image-missing-symbolic",
|
||||||
|
"image-x-generic-symbolic",
|
||||||
|
"list-add-symbolic",
|
||||||
|
"mark-location-symbolic",
|
||||||
|
"media-playback-pause-symbolic",
|
||||||
|
"media-playback-start-symbolic",
|
||||||
|
"network-idle-symbolic",
|
||||||
|
"network-offline-symbolic",
|
||||||
|
"non-starred-symbolic",
|
||||||
|
"object-select-symbolic",
|
||||||
|
"preferences-system-symbolic",
|
||||||
|
"process-stop-symbolic",
|
||||||
|
"starred-symbolic",
|
||||||
|
"system-search-symbolic",
|
||||||
|
"text-x-generic-symbolic",
|
||||||
|
"user-trash-symbolic",
|
||||||
|
"view-grid-symbolic",
|
||||||
|
"view-list-symbolic",
|
||||||
|
"view-more-symbolic",
|
||||||
|
"view-paged-symbolic",
|
||||||
|
"view-pin-symbolic",
|
||||||
|
"view-refresh-symbolic",
|
||||||
|
"window-close-symbolic",
|
||||||
|
"zoom-fit-best-symbolic",
|
||||||
|
"zoom-in-symbolic",
|
||||||
|
"zoom-out-symbolic",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn crate_dir() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||||
|
for entry in fs::read_dir(dir).unwrap() {
|
||||||
|
let path = entry.unwrap().path();
|
||||||
|
if path.is_dir() {
|
||||||
|
rust_files(&path, out);
|
||||||
|
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `"name-symbolic"` string literal in `source`. Found by suffix rather
|
||||||
|
/// than by pairing quotes, so an escaped quote or a `'"'` elsewhere in the
|
||||||
|
/// file can't hide one.
|
||||||
|
fn symbolic_literals(source: &str) -> Vec<String> {
|
||||||
|
const SUFFIX: &str = "-symbolic\"";
|
||||||
|
let is_name = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-';
|
||||||
|
source
|
||||||
|
.match_indices(SUFFIX)
|
||||||
|
.filter_map(|(at, _)| {
|
||||||
|
let head = &source[..at];
|
||||||
|
let start = head.trim_end_matches(is_name).len();
|
||||||
|
(start > 0 && head[..start].ends_with('"'))
|
||||||
|
.then(|| format!("{}-symbolic", &head[start..]))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `pdfs-*` icons in `resources/icons`, by name.
|
||||||
|
fn bundled() -> BTreeSet<String> {
|
||||||
|
let mut names = BTreeSet::new();
|
||||||
|
let root = crate_dir().join("resources/icons/scalable");
|
||||||
|
for context in fs::read_dir(root).unwrap() {
|
||||||
|
for icon in fs::read_dir(context.unwrap().path()).unwrap() {
|
||||||
|
let path = icon.unwrap().path();
|
||||||
|
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
|
||||||
|
names.insert(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_symbolic_icon_exists_on_a_stock_desktop() {
|
||||||
|
let mut files = vec![crate_dir().join("src/prompt.rs")];
|
||||||
|
rust_files(&crate_dir().join("src/app"), &mut files);
|
||||||
|
let bundled = bundled();
|
||||||
|
let mut unknown = BTreeSet::new();
|
||||||
|
for file in files {
|
||||||
|
let source = fs::read_to_string(&file).unwrap();
|
||||||
|
for name in symbolic_literals(&source) {
|
||||||
|
if !bundled.contains(&name) && !STOCK.contains(&name.as_str()) {
|
||||||
|
let file = file
|
||||||
|
.strip_prefix(crate_dir())
|
||||||
|
.unwrap()
|
||||||
|
.display()
|
||||||
|
.to_string();
|
||||||
|
unknown.insert(format!("{name} ({file})"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
unknown.is_empty(),
|
||||||
|
"icons that are neither bundled nor on the stock list:\n {}",
|
||||||
|
unknown.into_iter().collect::<Vec<_>>().join("\n ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_bundled_icon_is_in_the_resource_manifest() {
|
||||||
|
let manifest = fs::read_to_string(crate_dir().join("resources/pdfs.gresource.xml")).unwrap();
|
||||||
|
let missing: Vec<String> = bundled()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|name| name.starts_with("pdfs-") && !manifest.contains(&format!("/{name}.svg<")))
|
||||||
|
.collect();
|
||||||
|
assert!(missing.is_empty(), "not in pdfs.gresource.xml: {missing:?}");
|
||||||
|
}
|
||||||
@@ -11,6 +11,103 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.5.0] — 2026-09-28
|
||||||
|
|
||||||
|
A polish pass over the desktop app: the daemon pushes changes instead of being polled, pages
|
||||||
|
stay up while they reload, Trash and the shared pages use the same file list as My files, the
|
||||||
|
Sync page is split into views, the Computers page can browse another computer's backup, and
|
||||||
|
Photos gains a timeline scrubber.
|
||||||
|
|
||||||
|
Schema: **35**. The daemon notes when it first sees an item in the trash, since Proton does not
|
||||||
|
report the trash time to this client yet.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Browse other computers.** Opening another computer on the Computers page shows its backup
|
||||||
|
read-only, in the same list and grid as My files, and files open with a double-click.
|
||||||
|
- **Trash shows where items came from and when they were deleted.** The date is when this client
|
||||||
|
first saw the item in the trash until Proton reports the exact time. Restoring offers a Show
|
||||||
|
action, and opening a trashed item explains that it must be restored first.
|
||||||
|
- **Shared with me** shows who shared each item, with a warning for unverified senders, the
|
||||||
|
access it gives and when it was shared. Pending invitations wait in a banner, several items can
|
||||||
|
be left at once, and saved links have their own view.
|
||||||
|
- **Shared by me** gains a filter (links, people, expired links), columns for location, people
|
||||||
|
and link state, a copy button for live links, and stopping several shares at once.
|
||||||
|
- **Sidebar badges** count sync problems and waiting invitations.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The app and the tray no longer poll.** The daemon pushes its status, its transfers and what
|
||||||
|
changed over one control connection (`Request::Subscribe`), and the window reloads the page on
|
||||||
|
screen when that page's data changes, including changes made with the `pdfs` CLI. A hidden
|
||||||
|
window keeps the changes and catches up when it is focused. Front-ends fall back to polling a
|
||||||
|
daemon that predates subscriptions until its service restarts.
|
||||||
|
- **Refresh buttons are gone** from the page headers now that pages follow the daemon. F5 and
|
||||||
|
Ctrl+R still reload the page from the server.
|
||||||
|
- **Photos timeline scrubber.** A strip along the right edge of the timeline shows the years
|
||||||
|
and appears when the pointer comes near or the timeline scrolls. Hovering names the month
|
||||||
|
under the pointer, and dragging jumps to it. It replaces the slider above the grid and the
|
||||||
|
scrollbar.
|
||||||
|
- **Upload is a split button** on the Photos page. Its menu holds "Import from Google Photos…",
|
||||||
|
and the page menu keeps "Find Duplicates…".
|
||||||
|
- **Quieter photo tiles.** Hovering enlarges the thumbnail slightly instead of lifting the tile
|
||||||
|
with a shadow, and tiles no longer show a tooltip. Tiles are reused while scrolling rather than
|
||||||
|
rebuilt for every row.
|
||||||
|
- **Newer libadwaita widgets where available.** On libadwaita 1.8 or newer the app uses its
|
||||||
|
spinner, toggle groups, button rows and shortcuts dialog, and on older releases it keeps
|
||||||
|
look-alike stand-ins. The choice is made at run time, so every package gets them and the app
|
||||||
|
still builds and runs on libadwaita 1.5. Set `PDFS_STAND_IN_WIDGETS=1` to see the stand-ins
|
||||||
|
on a newer system.
|
||||||
|
- **Sync page views.** Overview holds the status, conflicts, transfers and queue; Folders holds
|
||||||
|
the folder list, with a short explanation of the sync modes; History is the former Activity
|
||||||
|
page. A banner points back to Overview when changes fail or conflict. Each folder is one card
|
||||||
|
with a Synced / Online only switch, and switching either way asks first.
|
||||||
|
- **Activity reads better.** Temporary files that editors save through are hidden (a menu shows
|
||||||
|
them again), runs of similar events fold into one expandable row, entries are full translated
|
||||||
|
sentences, and "Show Older" reaches further back.
|
||||||
|
- **Computers** lists every computer in one group, this one first. "Use This Computer's
|
||||||
|
Identity" is now "Continue This Backup Here", with an explanation in its dialog, and "never
|
||||||
|
synced" reads "no backups yet".
|
||||||
|
- **Pages stay up while they reload.** A loading state shows only when a reply takes longer than
|
||||||
|
300 ms, and a refresh keeps the selection, scroll position and thumbnails.
|
||||||
|
- **One glossary.** "Proton Drive service" replaces "mount service" and "daemon", and "Online
|
||||||
|
only" replaces "On-demand"; dialogs use title case, and dates and sizes are formatted one way.
|
||||||
|
- **Bundled icons** replace glyphs that stock icon themes lack or draw ambiguously.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Enabling, disabling or restarting the service no longer freezes the window.**
|
||||||
|
- **A daemon that runs but doesn't answer** reads as "Not responding" instead of disconnected,
|
||||||
|
and requests to it give up after 5 s instead of 120 s.
|
||||||
|
- The Sync and share-link icons drew only half of themselves on recent GTK versions.
|
||||||
|
|
||||||
|
## [2.4.0] — 2026-09-28
|
||||||
|
|
||||||
|
Schema: **34**. Photos gain a `similar_hash` column for the similar-photo finder. It stays
|
||||||
|
empty until the finder is first opened, so updating reads and downloads nothing.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Similar photos.** The duplicate finder gains a Similar switch that also finds resized or
|
||||||
|
re-encoded copies and shots of one moment, by comparing the thumbnails. The first search
|
||||||
|
hashes every thumbnail the server has, with a progress bar; photos without one are not
|
||||||
|
downloaded and are counted as not compared. Every photo in a set starts ticked Keep, since
|
||||||
|
photos that look alike can be different shots, and a photo not kept goes to Trash with the
|
||||||
|
rest of its shot.
|
||||||
|
- **Photos on the map.** Every town is marked with one of its photos and a count, and nearby
|
||||||
|
towns merge into one marker until you zoom in. Zoomed in on a town, each photo gets its own
|
||||||
|
marker where it was taken; clicking one opens it in the viewer, and clicking photos taken
|
||||||
|
on one spot lists them all. "Show on map" in the photo viewer now goes to that spot on the
|
||||||
|
Places map instead of a web browser.
|
||||||
|
- **Street map for Places.** An opt-in setting under Appearance draws the map from
|
||||||
|
[OpenFreeMap](https://openfreemap.org/) (OpenStreetMap data), with streets, water, parks and
|
||||||
|
town names in your language. It is off by default because the tile server learns which parts
|
||||||
|
of the world you look at. Tiles are cached under the cache directory (at most 256 MB), and
|
||||||
|
the built-in outline shows wherever they haven't loaded.
|
||||||
|
- **Proton theme.** The "Proton purple accent" preference is now a whole Proton theme: the
|
||||||
|
Proton web apps' light and dark colours, not just the accent. It also holds when `gtk.css`
|
||||||
|
imports another GTK theme (Catppuccin, Fluent, ...), whose own colours used to show through
|
||||||
|
almost everywhere. Folders get Proton's purple folder icon rather than the icon theme's.
|
||||||
|
Anyone who had the accent turned on gets the theme.
|
||||||
|
- **Country borders on the Places map.** The offline map draws country borders, with sea and
|
||||||
|
land in their own colours.
|
||||||
|
|
||||||
## [2.3.0] — 2026-09-28
|
## [2.3.0] — 2026-09-28
|
||||||
|
|
||||||
Schema: **33**. Photos gain their location; the first timeline refresh after updating reads
|
Schema: **33**. Photos gain their location; the first timeline refresh after updating reads
|
||||||
@@ -1080,6 +1177,8 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
|||||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||||
by a new POSIX compliance suite for the filesystem.
|
by a new POSIX compliance suite for the filesystem.
|
||||||
|
|
||||||
|
[2.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.4.0
|
||||||
|
|
||||||
[2.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.3.0
|
[2.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.3.0
|
||||||
[2.2.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.2
|
[2.2.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.2
|
||||||
[2.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.1
|
[2.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.1
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ stored as `language` in `config.json` and takes effect the next time they start.
|
|||||||
- Keep product names as they are: Proton Drive, Proton, Google Takeout.
|
- Keep product names as they are: Proton Drive, Proton, Google Takeout.
|
||||||
- Use the same term for the same thing across the catalog, and follow the terminology of your
|
- Use the same term for the same thing across the catalog, and follow the terminology of your
|
||||||
desktop (GNOME or KDE) for common actions such as Trash, Preferences and Cancel.
|
desktop (GNOME or KDE) for common actions such as Trash, Preferences and Cancel.
|
||||||
|
- The English UI uses one term per concept. Pick one translation for each and keep it:
|
||||||
|
|
||||||
|
| Concept | English term |
|
||||||
|
|---|---|
|
||||||
|
| The root of the user's Drive | My files |
|
||||||
|
| The background service | Proton Drive service (never "daemon" or "mount") |
|
||||||
|
| A folder with a full copy on this computer | Synced folder |
|
||||||
|
| A folder whose files download when opened | Online-only folder |
|
||||||
|
| A single file kept on this computer | Available offline |
|
||||||
|
| A single file that is not on this computer | Online only |
|
||||||
|
| Where My files appears on disk | Proton Drive folder |
|
||||||
|
| Stop syncing a folder | Stop syncing |
|
||||||
|
| Two versions of one file | Conflict |
|
||||||
|
|
||||||
## Rules for developers
|
## Rules for developers
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,8 @@ Header: `[‹ ›] Path bar (breadcrumbs, scroll to end, Ctrl+L to edit) … [
|
|||||||
| Cloud-only file | Online only | none / `cloud-outline` |
|
| Cloud-only file | Online only | none / `cloud-outline` |
|
||||||
| Pinned file | Available offline | pin/download-filled custom icon, not stars |
|
| Pinned file | Available offline | pin/download-filled custom icon, not stars |
|
||||||
| Upload | Upload files / Upload folder | `document-upload`-style arrow up, custom pair |
|
| Upload | Upload files / Upload folder | `document-upload`-style arrow up, custom pair |
|
||||||
| Folder sync mode | Synced / On-demand | — |
|
| Folder sync mode | Synced / Online only | — |
|
||||||
|
| Where My files appears on disk | Proton Drive folder — never "mountpoint" in UI | — |
|
||||||
| Stop syncing a folder | Stop syncing… | `media-playback-stop-symbolic` in a menu, not trash |
|
| Stop syncing a folder | Stop syncing… | `media-playback-stop-symbolic` in a menu, not trash |
|
||||||
| Remove a computer | Remove computer… | destructive, in menu |
|
| Remove a computer | Remove computer… | destructive, in menu |
|
||||||
| Conflict copy | Conflict | `dialog-warning-symbolic` + warning colour |
|
| Conflict copy | Conflict | `dialog-warning-symbolic` + warning colour |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=2.3.0
|
pkgver=2.5.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-bin
|
pkgbase = proton-drive-for-linux-bin
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||||
pkgver = 2.2.2
|
pkgver = 2.4.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/narrrl/proton-drive-linux
|
url = https://github.com/narrrl/proton-drive-linux
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -21,21 +21,21 @@ pkgbase = proton-drive-for-linux-bin
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=2.2.2
|
provides = proton-drive-for-linux=2.4.0
|
||||||
provides = proton-drive-linux=2.2.2
|
provides = proton-drive-linux=2.4.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !strip
|
options = !strip
|
||||||
options = !debug
|
options = !debug
|
||||||
source = proton-drive-linux-2.2.2-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.2.2/proton-drive-linux-2.2.2-x86_64.tar.gz
|
source = proton-drive-linux-2.4.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.4.0/proton-drive-linux-2.4.0-x86_64.tar.gz
|
||||||
source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/packaging/io.narl.proton-drive-linux.desktop
|
source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.4.0/packaging/io.narl.proton-drive-linux.desktop
|
||||||
source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/packaging/io.narl.proton-drive-linux-tray.desktop
|
source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.4.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||||
source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/packaging/io.narl.proton-drive-linux.svg
|
source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.4.0/packaging/io.narl.proton-drive-linux.svg
|
||||||
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/packaging/proton-drive.service
|
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.4.0/packaging/proton-drive.service
|
||||||
source = LICENSE-2.2.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/LICENSE
|
source = LICENSE-2.4.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.4.0/LICENSE
|
||||||
sha256sums = a8b856464c3c235699def1b22b5b07ad099f61257c557f034bcc10ca9a76a890
|
sha256sums = 00446aa9016466827bb0fe5ea1e1d171f478ab4194845f9005b690a05543fcca
|
||||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-bin
|
pkgname=proton-drive-for-linux-bin
|
||||||
pkgver=2.2.2
|
pkgver=2.4.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -31,7 +31,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$url/releases/download/$_tag/
|
|||||||
"io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
"io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||||
"proton-drive.service::$_raw/packaging/proton-drive.service"
|
"proton-drive.service::$_raw/packaging/proton-drive.service"
|
||||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||||
sha256sums=('a8b856464c3c235699def1b22b5b07ad099f61257c557f034bcc10ca9a76a890'
|
sha256sums=('00446aa9016466827bb0fe5ea1e1d171f478ab4194845f9005b690a05543fcca'
|
||||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-git
|
pkgbase = proton-drive-for-linux-git
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||||
pkgver = 2.2.2.r0.g0b81b76
|
pkgver = 2.4.0.r0.gda35a05
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/narrrl/proton-drive-linux
|
url = https://github.com/narrrl/proton-drive-linux
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -24,8 +24,8 @@ pkgbase = proton-drive-for-linux-git
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=2.2.2.r0.g0b81b76
|
provides = proton-drive-for-linux=2.4.0.r0.gda35a05
|
||||||
provides = proton-drive-linux=2.2.2.r0.g0b81b76
|
provides = proton-drive-linux=2.4.0.r0.gda35a05
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-git
|
pkgname=proton-drive-for-linux-git
|
||||||
pkgver=2.2.2.r0.g0b81b76
|
pkgver=2.4.0.r0.gda35a05
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux
|
pkgbase = proton-drive-for-linux
|
||||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||||
pkgver = 2.2.2
|
pkgver = 2.4.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/narrrl/proton-drive-linux
|
url = https://github.com/narrrl/proton-drive-linux
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -23,13 +23,13 @@ pkgbase = proton-drive-for-linux
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-linux=2.2.2
|
provides = proton-drive-linux=2.4.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux-bin
|
conflicts = proton-drive-for-linux-bin
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !lto
|
options = !lto
|
||||||
source = proton-drive-for-linux-2.2.2.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.2.2.tar.gz
|
source = proton-drive-for-linux-2.4.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.4.0.tar.gz
|
||||||
sha256sums = 23d9a3477f1dccc399bf8e90754c1be64302da86c1a220790e543d2e33de4d26
|
sha256sums = ae0f6daec257e9f7ba464ed6385bab7ac99f4247db0176296b2a58f703c24799
|
||||||
|
|
||||||
pkgname = proton-drive-for-linux
|
pkgname = proton-drive-for-linux
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux
|
pkgname=proton-drive-for-linux
|
||||||
pkgver=2.2.2
|
pkgver=2.4.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -23,7 +23,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
|
|||||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||||
options=('!lto')
|
options=('!lto')
|
||||||
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
sha256sums=('23d9a3477f1dccc399bf8e90754c1be64302da86c1a220790e543d2e33de4d26')
|
sha256sums=('ae0f6daec257e9f7ba464ed6385bab7ac99f4247db0176296b2a58f703c24799')
|
||||||
|
|
||||||
_srcname="proton-drive-linux-$pkgver"
|
_srcname="proton-drive-linux-$pkgver"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
crates/pdfs-gui/src/activation.rs
|
crates/pdfs-gui/src/activation.rs
|
||||||
|
crates/pdfs-gui/src/app/dates.rs
|
||||||
crates/pdfs-gui/src/app/main.rs
|
crates/pdfs-gui/src/app/main.rs
|
||||||
crates/pdfs-gui/src/app/pages/activity.rs
|
crates/pdfs-gui/src/app/pages/activity.rs
|
||||||
|
crates/pdfs-gui/src/app/pages/activity_text.rs
|
||||||
crates/pdfs-gui/src/app/pages/albums.rs
|
crates/pdfs-gui/src/app/pages/albums.rs
|
||||||
crates/pdfs-gui/src/app/pages/browser.rs
|
crates/pdfs-gui/src/app/pages/browser.rs
|
||||||
crates/pdfs-gui/src/app/pages/devices.rs
|
crates/pdfs-gui/src/app/pages/devices.rs
|
||||||
@@ -17,7 +19,9 @@ crates/pdfs-gui/src/app/pages/status.rs
|
|||||||
crates/pdfs-gui/src/app/pages/takeout.rs
|
crates/pdfs-gui/src/app/pages/takeout.rs
|
||||||
crates/pdfs-gui/src/app/pages/trash.rs
|
crates/pdfs-gui/src/app/pages/trash.rs
|
||||||
crates/pdfs-gui/src/app/pages/verify.rs
|
crates/pdfs-gui/src/app/pages/verify.rs
|
||||||
|
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/menu.rs
|
crates/pdfs-gui/src/app/widgets/menu.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
|
||||||
|
|||||||