max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
11 files changed,
+1009 insertions,
-157 deletions
| @@ -17,6 +17,7 @@ | |||
| 17 | 17 | pub mod loose_files_worker; | |
| 18 | 18 | pub mod preview; | |
| 19 | 19 | pub mod state; | |
| 20 | + | pub mod storage_cap; | |
| 20 | 21 | pub mod ui; | |
| 21 | 22 | ||
| 22 | 23 | /// The described screens, behind the off-by-default `quasi` feature. |
| @@ -2241,6 +2241,42 @@ | |||
| 2241 | 2241 | )?; | |
| 2242 | 2242 | Ok((count, total)) | |
| 2243 | 2243 | } | |
| 2244 | + | ||
| 2245 | + | /// Count and total bytes of the samples blob sync would actually upload: | |
| 2246 | + | /// the *union* of every VFS with `sync_files` set. | |
| 2247 | + | /// | |
| 2248 | + | /// A union rather than a sum over [`vfs_storage_stats`](Self::vfs_storage_stats), | |
| 2249 | + | /// because a sample placed in two synced VFSes uploads once. Blobs are | |
| 2250 | + | /// content-addressed and the server dedups on `(app, user, hash)`, so | |
| 2251 | + | /// adding the per-VFS figures would overstate the need and buy the user a | |
| 2252 | + | /// cap they do not require. Reads through `live_samples` for the same | |
| 2253 | + | /// reason the per-VFS query does: a tombstoned sample is not going to | |
| 2254 | + | /// upload. | |
| 2255 | + | /// | |
| 2256 | + | /// Zero synced VFSes gives `(0, 0)`, which is the honest answer — nothing | |
| 2257 | + | /// is set to sync, so nothing would upload. | |
| 2258 | + | pub fn synced_storage_stats(&self) -> Result<(u64, u64), DbError> { | |
| 2259 | + | let (count, total): (u64, u64) = self.conn.query_row( | |
| 2260 | + | "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM live_samples \ | |
| 2261 | + | WHERE hash IN (\ | |
| 2262 | + | SELECT DISTINCT sample_hash FROM vfs_nodes \ | |
| 2263 | + | WHERE sample_hash IS NOT NULL \ | |
| 2264 | + | AND vfs_id IN (SELECT id FROM vfs WHERE sync_files != 0)\ | |
| 2265 | + | )", | |
| 2266 | + | [], | |
| 2267 | + | |row| { | |
| 2268 | + | let count = row.get::<_, i64>(0)?; | |
| 2269 | + | let total = row.get::<_, i64>(1)?; | |
| 2270 | + | Ok(( | |
| 2271 | + | u64::try_from(count) | |
| 2272 | + | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, count))?, | |
| 2273 | + | u64::try_from(total) | |
| 2274 | + | .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, total))?, | |
| 2275 | + | )) | |
| 2276 | + | }, | |
| 2277 | + | )?; | |
| 2278 | + | Ok((count, total)) | |
| 2279 | + | } | |
| 2244 | 2280 | } | |
| 2245 | 2281 | ||
| 2246 | 2282 | #[cfg(test)] | |
| @@ -3018,6 +3054,70 @@ | |||
| 3018 | 3054 | assert_eq!(idx_count, 1); | |
| 3019 | 3055 | } | |
| 3020 | 3056 | ||
| 3057 | + | /// The need blob sync computes is a union over synced VFSes, not a sum. | |
| 3058 | + | /// | |
| 3059 | + | /// The distinction is the whole reason `synced_storage_stats` exists | |
| 3060 | + | /// separately from summing `vfs_storage_stats`: a sample placed in two | |
| 3061 | + | /// synced VFSes uploads once, because blobs are content-addressed. Summing | |
| 3062 | + | /// would report 300 here and propose a cap for storage nobody needs. | |
| 3063 | + | #[test] | |
| 3064 | + | fn synced_storage_counts_a_shared_sample_once() { | |
| 3065 | + | let db = Database::open_in_memory().unwrap(); | |
| 3066 | + | db.conn() | |
| 3067 | + | .execute_batch( | |
| 3068 | + | "INSERT INTO samples | |
| 3069 | + | (hash, original_name, file_extension, file_size, import_date, last_modified) | |
| 3070 | + | VALUES ('shared', 's.wav', 'wav', 100, 0, 0), | |
| 3071 | + | ('only_a', 'a.wav', 'wav', 50, 0, 0), | |
| 3072 | + | ('unsynced', 'u.wav', 'wav', 999, 0, 0); | |
| 3073 | + | INSERT INTO vfs (id, name, created_at, modified_at, sync_files) | |
| 3074 | + | VALUES (1, 'A', 0, 0, 1), (2, 'B', 0, 0, 1), (3, 'Off', 0, 0, 0); | |
| 3075 | + | INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) | |
| 3076 | + | VALUES (1, NULL, 's.wav', 'sample', 'shared', 0), | |
| 3077 | + | (2, NULL, 's.wav', 'sample', 'shared', 0), | |
| 3078 | + | (1, NULL, 'a.wav', 'sample', 'only_a', 0), | |
| 3079 | + | (3, NULL, 'u.wav', 'sample', 'unsynced', 0);", | |
| 3080 | + | ) | |
| 3081 | + | .unwrap(); | |
| 3082 | + | ||
| 3083 | + | let (count, bytes) = db.synced_storage_stats().unwrap(); | |
| 3084 | + | assert_eq!( | |
| 3085 | + | count, 2, | |
| 3086 | + | "the shared sample counts once, the unsynced not at all" | |
| 3087 | + | ); | |
| 3088 | + | assert_eq!(bytes, 150, "100 + 50; summing the two VFSes would say 250"); | |
| 3089 | + | ||
| 3090 | + | // The per-VFS figures are what a naive sum would have used. | |
| 3091 | + | assert_eq!(db.vfs_storage_stats(1).unwrap(), (2, 150)); | |
| 3092 | + | assert_eq!(db.vfs_storage_stats(2).unwrap(), (1, 100)); | |
| 3093 | + | } | |
| 3094 | + | ||
| 3095 | + | /// Nothing set to sync means nothing would upload, and the honest answer is | |
| 3096 | + | /// zero rather than the whole library. A default vault has `sync_files = 0` | |
| 3097 | + | /// on every VFS, so this is the state a new user is actually in. | |
| 3098 | + | #[test] | |
| 3099 | + | fn synced_storage_is_zero_when_no_vfs_syncs_files() { | |
| 3100 | + | let db = Database::open_in_memory().unwrap(); | |
| 3101 | + | db.conn() | |
| 3102 | + | .execute_batch( | |
| 3103 | + | "INSERT INTO samples | |
| 3104 | + | (hash, original_name, file_extension, file_size, import_date, last_modified) | |
| 3105 | + | VALUES ('a', 'a.wav', 'wav', 100, 0, 0); | |
| 3106 | + | INSERT INTO vfs (id, name, created_at, modified_at, sync_files) | |
| 3107 | + | VALUES (1, 'A', 0, 0, 0); | |
| 3108 | + | INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) | |
| 3109 | + | VALUES (1, NULL, 'a.wav', 'sample', 'a', 0);", | |
| 3110 | + | ) | |
| 3111 | + | .unwrap(); | |
| 3112 | + | ||
| 3113 | + | assert_eq!(db.synced_storage_stats().unwrap(), (0, 0)); | |
| 3114 | + | assert_eq!( | |
| 3115 | + | db.storage_stats().unwrap(), | |
| 3116 | + | (1, 100), | |
| 3117 | + | "the library is not empty" | |
| 3118 | + | ); | |
| 3119 | + | } | |
| 3120 | + | ||
| 3021 | 3121 | /// Recovery branch contract: when the non-ALTER batch fails for a | |
| 3022 | 3122 | /// reason OTHER than "already exists", `migrate()` must roll back and | |
| 3023 | 3123 | /// surface the error, NOT bump `user_version` past the failed |
| @@ -963,6 +963,12 @@ | |||
| 963 | 963 | /// before enabling blob sync for that vault. | |
| 964 | 964 | fn vfs_storage_stats(&self, vfs_id: audiofiles_core::VfsId) -> BackendResult<(u64, u64)>; | |
| 965 | 965 | ||
| 966 | + | /// `(unique_sample_count, total_bytes)` blob sync would upload: the union | |
| 967 | + | /// of every VFS with `sync_files` set, deduped by hash. This is the number | |
| 968 | + | /// the storage cap is chosen against, so the subscribe screen can propose | |
| 969 | + | /// an answer instead of asking for one. | |
| 970 | + | fn synced_storage_stats(&self) -> BackendResult<(u64, u64)>; | |
| 971 | + | ||
| 966 | 972 | /// Non-blocking poll for worker events. | |
| 967 | 973 | fn poll_events(&self) -> Vec<BackendEvent>; | |
| 968 | 974 | } |
| @@ -298,6 +298,16 @@ | |||
| 298 | 298 | /// What a cap may be, once pricing has been fetched. | |
| 299 | 299 | fn pricing(&self) -> Option<Pricing>; | |
| 300 | 300 | ||
| 301 | + | /// How many bytes blob sync would upload today: the union of every VFS with | |
| 302 | + | /// `sync_files` set, deduped by hash. | |
| 303 | + | /// | |
| 304 | + | /// The cap is chosen against this, which is why the screen can propose an | |
| 305 | + | /// answer rather than ask for one. `None` is "cannot look" - no backend on | |
| 306 | + | /// this side, or the query failed - and is what the screen falls back to | |
| 307 | + | /// the floor on. `Some(0)` is different and means something: nothing is set | |
| 308 | + | /// to sync yet. | |
| 309 | + | fn synced_library_bytes(&self) -> Option<i64>; | |
| 310 | + | ||
| 301 | 311 | /// What this cap costs at this cadence, in cents. | |
| 302 | 312 | fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64; | |
| 303 | 313 | ||
| @@ -316,11 +326,18 @@ | |||
| 316 | 326 | } | |
| 317 | 327 | ||
| 318 | 328 | /// The app's sync manager, as the narrow thing a described screen borrows. | |
| 319 | - | pub struct FromSyncManager<'a>(pub &'a audiofiles_sync::SyncManager); | |
| 329 | + | /// | |
| 330 | + | /// Carries the backend as well as the manager, for one fact: the cap screen has | |
| 331 | + | /// to know how much would upload, and that lives in the vault rather than in | |
| 332 | + | /// the sync service. Everything else here is the manager. | |
| 333 | + | pub struct FromSyncManager<'a> { | |
| 334 | + | pub manager: &'a audiofiles_sync::SyncManager, | |
| 335 | + | pub backend: &'a dyn crate::backend::Backend, | |
| 336 | + | } | |
| 320 | 337 | ||
| 321 | 338 | impl Sync for FromSyncManager<'_> { | |
| 322 | 339 | fn status(&self) -> Status { | |
| 323 | - | let status = self.0.status(); | |
| 340 | + | let status = self.manager.status(); | |
| 324 | 341 | Status { | |
| 325 | 342 | state: match status.state { | |
| 326 | 343 | audiofiles_sync::SyncState::Disconnected => State::Disconnected, | |
| @@ -340,39 +357,39 @@ | |||
| 340 | 357 | } | |
| 341 | 358 | ||
| 342 | 359 | fn connect(&self) -> Result<String, String> { | |
| 343 | - | self.0.start_auth().map_err(|error| error.to_string()) | |
| 360 | + | self.manager.start_auth().map_err(|error| error.to_string()) | |
| 344 | 361 | } | |
| 345 | 362 | ||
| 346 | 363 | fn cancel(&self) { | |
| 347 | - | self.0.cancel_auth(); | |
| 364 | + | self.manager.cancel_auth(); | |
| 348 | 365 | } | |
| 349 | 366 | ||
| 350 | 367 | fn set_password(&self, password: &str, is_new: bool) { | |
| 351 | - | self.0.setup_encryption(password.to_owned(), is_new); | |
| 368 | + | self.manager.setup_encryption(password.to_owned(), is_new); | |
| 352 | 369 | } | |
| 353 | 370 | ||
| 354 | 371 | fn sync_now(&self) { | |
| 355 | - | self.0.sync_now(); | |
| 372 | + | self.manager.sync_now(); | |
| 356 | 373 | } | |
| 357 | 374 | ||
| 358 | 375 | fn set_auto(&self, enabled: bool) { | |
| 359 | - | self.0.update_settings(Some(enabled), None); | |
| 376 | + | self.manager.update_settings(Some(enabled), None); | |
| 360 | 377 | } | |
| 361 | 378 | ||
| 362 | 379 | fn set_interval(&self, minutes: u32) { | |
| 363 | - | self.0.update_settings(None, Some(minutes)); | |
| 380 | + | self.manager.update_settings(None, Some(minutes)); | |
| 364 | 381 | } | |
| 365 | 382 | ||
| 366 | 383 | fn clear_error(&self) { | |
| 367 | - | self.0.clear_last_error(); | |
| 384 | + | self.manager.clear_last_error(); | |
| 368 | 385 | } | |
| 369 | 386 | ||
| 370 | 387 | fn disconnect(&self) { | |
| 371 | - | self.0.disconnect(); | |
| 388 | + | self.manager.disconnect(); | |
| 372 | 389 | } | |
| 373 | 390 | ||
| 374 | 391 | fn subscription(&self) -> Option<Subscription> { | |
| 375 | - | let status = self.0.status(); | |
| 392 | + | let status = self.manager.status(); | |
| 376 | 393 | status.subscription.map(|sub| Subscription { | |
| 377 | 394 | active: sub.active, | |
| 378 | 395 | limit_bytes: sub.storage_limit_bytes.unwrap_or(0), | |
| @@ -385,28 +402,33 @@ | |||
| 385 | 402 | } | |
| 386 | 403 | ||
| 387 | 404 | fn pricing(&self) -> Option<Pricing> { | |
| 388 | - | self.0.status().pricing.map(|pricing| Pricing { | |
| 405 | + | self.manager.status().pricing.map(|pricing| Pricing { | |
| 389 | 406 | min_bytes: pricing.min_cap_bytes, | |
| 390 | 407 | max_bytes: pricing.max_cap_bytes, | |
| 391 | 408 | }) | |
| 392 | 409 | } | |
| 393 | 410 | ||
| 411 | + | fn synced_library_bytes(&self) -> Option<i64> { | |
| 412 | + | let (_, bytes) = self.backend.synced_storage_stats().ok()?; | |
| 413 | + | i64::try_from(bytes).ok() | |
| 414 | + | } | |
| 415 | + | ||
| 394 | 416 | fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64 { | |
| 395 | - | self.0.status().pricing.map_or(0, |pricing| { | |
| 417 | + | self.manager.status().pricing.map_or(0, |pricing| { | |
| 396 | 418 | pricing.quote_cents(cap_bytes, interval_of(annual)).0 | |
| 397 | 419 | }) | |
| 398 | 420 | } | |
| 399 | 421 | ||
| 400 | 422 | fn refresh_subscription(&self) { | |
| 401 | - | self.0.fetch_subscription_status(); | |
| 423 | + | self.manager.fetch_subscription_status(); | |
| 402 | 424 | } | |
| 403 | 425 | ||
| 404 | 426 | fn subscribe(&self, cap_bytes: i64, annual: bool) { | |
| 405 | - | self.0.subscribe(cap_bytes, interval_of(annual)); | |
| 427 | + | self.manager.subscribe(cap_bytes, interval_of(annual)); | |
| 406 | 428 | } | |
| 407 | 429 | ||
| 408 | 430 | fn queue_cap_change(&self, cap_bytes: i64) { | |
| 409 | - | self.0.queue_cap_change(cap_bytes); | |
| 431 | + | self.manager.queue_cap_change(cap_bytes); | |
| 410 | 432 | } | |
| 411 | 433 | } | |
| 412 | 434 | ||
| @@ -457,6 +479,9 @@ | |||
| 457 | 479 | fn pricing(&self) -> Option<Pricing> { | |
| 458 | 480 | None | |
| 459 | 481 | } | |
| 482 | + | fn synced_library_bytes(&self) -> Option<i64> { | |
| 483 | + | None | |
| 484 | + | } | |
| 460 | 485 | fn quote_cents(&self, _cap_bytes: i64, _annual: bool) -> i64 { | |
| 461 | 486 | 0 | |
| 462 | 487 | } |
| @@ -1718,7 +1718,10 @@ | |||
| 1718 | 1718 | intents, | |
| 1719 | 1719 | } = host; | |
| 1720 | 1720 | let config = FromBackend(&*state.backend); | |
| 1721 | - | let manager = sync.map(FromSyncManager); | |
| 1721 | + | let manager = sync.map(|manager| FromSyncManager { | |
| 1722 | + | manager, | |
| 1723 | + | backend: &*state.backend, | |
| 1724 | + | }); | |
| 1722 | 1725 | let unconfigured = Unconfigured; | |
| 1723 | 1726 | let sync: &dyn Sync = match &manager { | |
| 1724 | 1727 | Some(manager) => manager, |
| @@ -116,7 +116,7 @@ | |||
| 116 | 116 | const BODY: &str = "sync-body"; | |
| 117 | 117 | ||
| 118 | 118 | /// One gibibyte, which is what a cap is counted in. | |
| 119 | - | const GIB: i64 = 1024 * 1024 * 1024; | |
| 119 | + | use crate::storage_cap::GIB; | |
| 120 | 120 | ||
| 121 | 121 | /// The field a cap is chosen with. | |
| 122 | 122 | const CAP: &str = "cap_gib"; | |
| @@ -492,7 +492,7 @@ | |||
| 492 | 492 | // At ninety percent, not past it: this is a cap that stops syncing | |
| 493 | 493 | // when it fills, and the point of saying so is to say it before | |
| 494 | 494 | // that happens. | |
| 495 | - | .tone(if sub.used_bytes * 10 >= sub.limit_bytes * 9 { | |
| 495 | + | .tone(if nearly_full(sub) { | |
| 496 | 496 | Tone::Warning | |
| 497 | 497 | } else { | |
| 498 | 498 | Tone::Neutral | |
| @@ -500,6 +500,23 @@ | |||
| 500 | 500 | )); | |
| 501 | 501 | } | |
| 502 | 502 | ||
| 503 | + | // Say it in words as well as in the bar, and say what happens next. A meter | |
| 504 | + | // that has gone amber reports a quantity; the user needs the consequence, | |
| 505 | + | // which is that uploads stop and metadata sync carries on. Without this the | |
| 506 | + | // first news of a full cap is a failed upload - the 402 from | |
| 507 | + | // `routes/synckit/blobs.rs`, which the user meets as a sync that broke. | |
| 508 | + | if let Some(warning) = cap_warning(sync, sub, pricing) { | |
| 509 | + | slot = slot.with(Node::Notice { | |
| 510 | + | kind: quasi_router::layout::Notice::Banner, | |
| 511 | + | tone: if sub.used_bytes >= sub.limit_bytes { | |
| 512 | + | Tone::Danger | |
| 513 | + | } else { | |
| 514 | + | Tone::Warning | |
| 515 | + | }, | |
| 516 | + | text: warning, | |
| 517 | + | }); | |
| 518 | + | } | |
| 519 | + | ||
| 503 | 520 | if let Some(pending) = sub.pending_limit_bytes { | |
| 504 | 521 | slot = slot.with(Node::text(format!( | |
| 505 | 522 | "Pending: cap changes to {} at next renewal.", | |
| @@ -507,69 +524,230 @@ | |||
| 507 | 524 | ))); | |
| 508 | 525 | } | |
| 509 | 526 | ||
| 510 | - | let annual = sub.interval == "annual"; | |
| 511 | - | Node::Region(slot.with(Node::Form { | |
| 512 | - | fields: vec![cap_field(pricing, sub.limit_bytes).hint(format!( | |
| 513 | - | "{} at this cap, {}.", | |
| 514 | - | money(sync.quote_cents(sub.limit_bytes, annual)), | |
| 515 | - | if annual { "per year" } else { "per month" } | |
| 516 | - | ))], | |
| 517 | - | submit: "Update cap".to_owned(), | |
| 518 | - | action: Action::post("/sync/cap"), | |
| 519 | - | })) | |
| 527 | + | // The same control the subscribe screen uses, defaulted to what is already | |
| 528 | + | // bought rather than to a proposal: this user has answered the question, and | |
| 529 | + | // re-proposing over their answer would be the screen arguing with them. The | |
| 530 | + | // exception is a cap that no longer covers the library, where the proposal | |
| 531 | + | // is the point. | |
| 532 | + | let default = if nearly_full(sub) { | |
| 533 | + | proposed_cap(sync.synced_library_bytes(), pricing).max(sub.limit_bytes) | |
| 534 | + | } else { | |
| 535 | + | sub.limit_bytes | |
| 536 | + | }; | |
| 537 | + | ||
| 538 | + | Node::Region( | |
| 539 | + | slot.with(Node::Form { | |
| 540 | + | fields: vec![cap_choice(sync, pricing, default)], | |
| 541 | + | submit: "Update cap".to_owned(), | |
| 542 | + | action: Action::post("/sync/cap"), | |
| 543 | + | }) | |
| 544 | + | .with(exact_cap_form(pricing, default, "/sync/cap")), | |
| 545 | + | ) | |
| 520 | 546 | } | |
| 521 | 547 | ||
| 522 | - | /// No subscription yet: pick a cap and a cadence. | |
| 548 | + | /// Whether the cap is close enough to full to say so. | |
| 523 | 549 | /// | |
| 524 | - | /// One form with the cadence in it, rather than one cap and two priced buttons. | |
| 525 | - | /// That is the redesign the module header records: a form carries one action and | |
| 526 | - | /// one submit, so two priced choices over one value is unsayable as drawn. | |
| 550 | + | /// Ninety percent, the same threshold the meter turns amber at, so the bar and | |
| 551 | + | /// the sentence never disagree about whether this is a problem. | |
| 552 | + | fn nearly_full(sub: &Subscription) -> bool { | |
| 553 | + | crate::storage_cap::nearly_full(sub.used_bytes, sub.limit_bytes) | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | /// What to say about a cap that is filling, if anything. | |
| 557 | + | /// | |
| 558 | + | /// Three cases, and they are different sentences rather than degrees of one. | |
| 559 | + | /// Full means uploads have already stopped. Nearly full means they are about to. | |
| 560 | + | /// A library that has outgrown the cap means the number to fix it is known, so | |
| 561 | + | /// the message carries it and what it costs. | |
| 562 | + | fn cap_warning(sync: &dyn Sync, sub: &Subscription, pricing: &super::Pricing) -> Option<String> { | |
| 563 | + | if !nearly_full(sub) { | |
| 564 | + | return None; | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | let annual = sub.interval == "annual"; | |
| 568 | + | let cadence = if annual { "a year" } else { "a month" }; | |
| 569 | + | let suggestion = |bytes: i64| { | |
| 570 | + | format!( | |
| 571 | + | " {} would hold it, at {} {cadence}.", | |
| 572 | + | gib_of(bytes), | |
| 573 | + | money(sync.quote_cents(bytes, annual)) | |
| 574 | + | ) | |
| 575 | + | }; | |
| 576 | + | ||
| 577 | + | // Only offer a bigger cap when there is one, and when it is actually bigger | |
| 578 | + | // than what they have. At the ceiling the honest answer is that raising the | |
| 579 | + | // cap is not the remedy. | |
| 580 | + | let bigger = sync | |
| 581 | + | .synced_library_bytes() | |
| 582 | + | .map(|need| proposed_cap(Some(need), pricing)) | |
| 583 | + | .filter(|proposed| *proposed > sub.limit_bytes) | |
| 584 | + | .map_or_else(String::new, suggestion); | |
| 585 | + | ||
| 586 | + | Some(if sub.used_bytes >= sub.limit_bytes { | |
| 587 | + | format!( | |
| 588 | + | "Your storage cap is full. New sample files are not uploading; \ | |
| 589 | + | everything else still syncs.{bigger}" | |
| 590 | + | ) | |
| 591 | + | } else { | |
| 592 | + | format!( | |
| 593 | + | "You are close to your storage cap. When it fills, new sample files \ | |
| 594 | + | stop uploading and everything else keeps syncing.{bigger}" | |
| 595 | + | ) | |
| 596 | + | }) | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | /// No subscription yet: the screen proposes a cap and says what it costs. | |
| 600 | + | /// | |
| 601 | + | /// The redesign of 2026-08-21, and what it turns on is that **the app already | |
| 602 | + | /// knows the answer it used to ask for**. `synced_library_bytes` is the exact | |
| 603 | + | /// size of what blob sync would upload, available locally and instantly, so a | |
| 604 | + | /// control that opened on an unfilled number was soliciting a guess at a | |
| 605 | + | /// question it could compute. | |
| 606 | + | /// | |
| 607 | + | /// So: state the need, propose a cap with headroom, and show every alternative | |
| 608 | + | /// with its price attached. The user confirms or nudges one number instead of | |
| 609 | + | /// exploring three orders of magnitude, which is what a slider from 250 GiB to | |
| 610 | + | /// 10 TiB asked them to do and what nobody ever did. | |
| 527 | 611 | fn on_offer(sync: &dyn Sync, pricing: &super::Pricing) -> Node { | |
| 528 | - | let start = pricing.min_bytes; | |
| 612 | + | let need = sync.synced_library_bytes(); | |
| 613 | + | let proposed = proposed_cap(need, pricing); | |
| 614 | + | ||
| 615 | + | let mut slot = Slot::new("subscription", RegionKind::Pane); | |
| 616 | + | ||
| 617 | + | // The need, first, because it is the reason the rest of the screen says what | |
| 618 | + | // it says. `None` is "cannot look"; `Some(0)` is a real and different answer. | |
| 619 | + | slot = slot.with(Node::text(match need { | |
| 620 | + | Some(0) => "No vault is set to sync sample files yet, so nothing would upload today. \ | |
| 621 | + | Turn on file sync for a vault to change that." | |
| 622 | + | .to_owned(), | |
| 623 | + | Some(bytes) => format!( | |
| 624 | + | "Your synced vaults hold {}. That is what would upload.", | |
| 625 | + | gib_of_exact(bytes) | |
| 626 | + | ), | |
| 627 | + | None => "Pick a storage cap for audio file sync.".to_owned(), | |
| 628 | + | })); | |
| 629 | + | ||
| 630 | + | if need.is_some_and(|bytes| bytes > 0) { | |
| 631 | + | slot = slot.with(Node::text(format!( | |
| 632 | + | "Proposed: {}, which leaves room to grow.", | |
| 633 | + | gib_of(proposed) | |
| 634 | + | ))); | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | slot = slot.with(Node::text( | |
| 638 | + | "Annual is two months free: fewer Stripe fees, and we pass the savings on.", | |
| 639 | + | )); | |
| 640 | + | ||
| 529 | 641 | Node::Region( | |
| 530 | - | Slot::new("subscription", RegionKind::Pane) | |
| 531 | - | .with(Node::text("Pick a storage cap for audio file sync.")) | |
| 532 | - | .with(Node::text( | |
| 533 | - | "Annual is two months free: fewer Stripe fees, and we pass the savings on.", | |
| 534 | - | )) | |
| 642 | + | slot.with(Node::Form { | |
| 643 | + | fields: vec![ | |
| 644 | + | cap_choice(sync, pricing, proposed), | |
| 645 | + | Field::radio( | |
| 646 | + | "cadence", | |
| 647 | + | "Billing", | |
| 648 | + | vec![ | |
| 649 | + | Choice::new("annual", "Annual"), | |
| 650 | + | Choice::new("monthly", "Monthly"), | |
| 651 | + | ], | |
| 652 | + | ) | |
| 653 | + | .value("annual"), | |
| 654 | + | ], | |
| 655 | + | submit: "Subscribe".to_owned(), | |
| 656 | + | action: Action::post("/sync/subscribe"), | |
| 657 | + | }) | |
| 658 | + | .with(exact_cap_form(pricing, proposed, "/sync/subscribe")), | |
| 659 | + | ) | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | /// The cap as a few named sizes, each carrying what it costs. | |
| 663 | + | /// | |
| 664 | + | /// A [`Field::radio`] rather than a select, and that is the whole point of the | |
| 665 | + | /// control: the prices have to be *visible* without interacting, because the | |
| 666 | + | /// decision being made is a spending decision and the enforced quantity is | |
| 667 | + | /// bytes. A dropdown hides five of the six prices behind a click. | |
| 668 | + | /// | |
| 669 | + | /// Both cadences are on every label, so no label goes stale when the cadence | |
| 670 | + | /// field changes underneath it. That replaces the old hint - "prices shown are | |
| 671 | + | /// for the smallest cap; the exact figure is on the checkout page" - which was | |
| 672 | + | /// a form apologising for not being able to say what it charged. | |
| 673 | + | fn cap_choice(sync: &dyn Sync, pricing: &super::Pricing, proposed: i64) -> Field { | |
| 674 | + | // The selected cap is always among the options, even when it is not one of | |
| 675 | + | // the named sizes. A user who typed an exact figure, or who is on a cap from | |
| 676 | + | // before this list existed, must see their own cap selected rather than a | |
| 677 | + | // group with nothing chosen - which is what a radio says when its value | |
| 678 | + | // matches no option, and it reads as "you have not chosen" to someone who | |
| 679 | + | // has. | |
| 680 | + | let mut sizes: Vec<i64> = offered_caps(pricing).collect(); | |
| 681 | + | if !sizes.contains(&proposed) { | |
| 682 | + | sizes.push(proposed); | |
| 683 | + | sizes.sort_unstable(); | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | let options = sizes | |
| 687 | + | .into_iter() | |
| 688 | + | .map(|bytes| { | |
| 689 | + | Choice::new( | |
| 690 | + | gib_count(bytes).to_string(), | |
| 691 | + | format!( | |
| 692 | + | "{} - {} a month, or {} a year", | |
| 693 | + | gib_of(bytes), | |
| 694 | + | money(sync.quote_cents(bytes, false)), | |
| 695 | + | money(sync.quote_cents(bytes, true)) | |
| 696 | + | ), | |
| 697 | + | ) | |
| 698 | + | }) | |
| 699 | + | .collect(); | |
| 700 | + | ||
| 701 | + | Field::radio(CAP, "Storage cap", options).value(gib_count(proposed).to_string()) | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | /// The exact-figure entry, as its own form. | |
| 705 | + | /// | |
| 706 | + | /// Two forms rather than one, because they are two acts. Picking a named size is | |
| 707 | + | /// confirming a proposal; typing a number is overriding it, and a form carries | |
| 708 | + | /// one submit and one action, so a single form offering both would have two | |
| 709 | + | /// controls competing to answer one value. | |
| 710 | + | /// | |
| 711 | + | /// `min` and `max` are set here, which is what the old `cap_field` claimed in its | |
| 712 | + | /// doc comment and did not do: it was a bare number with no bounds, and the only | |
| 713 | + | /// thing that rejected an out-of-range cap was `cap_from`, after submit, with | |
| 714 | + | /// "that cap is not on offer". | |
| 715 | + | fn exact_cap_form(pricing: &super::Pricing, proposed: i64, action: &str) -> Node { | |
| 716 | + | let field = Field { | |
| 717 | + | min: Some(gib_count(pricing.min_bytes).to_string()), | |
| 718 | + | max: Some(gib_count(pricing.max_bytes).to_string()), | |
| 719 | + | ..Field::new(FieldKind::Number, CAP, "Storage cap (GiB)") | |
| 720 | + | } | |
| 721 | + | .value(gib_count(proposed).to_string()) | |
| 722 | + | .required() | |
| 723 | + | .hint(format!( | |
| 724 | + | "Anything from {} to {}.", | |
| 725 | + | gib_of(pricing.min_bytes), | |
| 726 | + | gib_of(pricing.max_bytes) | |
| 727 | + | )); | |
| 728 | + | ||
| 729 | + | Node::Region( | |
| 730 | + | Slot::new("exact-cap", RegionKind::Pane) | |
| 731 | + | .with(Node::text("Or set an exact cap.")) | |
| 535 | 732 | .with(Node::Form { | |
| 536 | - | fields: vec![ | |
| 537 | - | cap_field(pricing, start), | |
| 538 | - | Field::select( | |
| 539 | - | "cadence", | |
| 540 | - | "Billing", | |
| 541 | - | vec![ | |
| 542 | - | Choice::new( | |
| 543 | - | "annual", | |
| 544 | - | format!("Annual, {} a year", money(sync.quote_cents(start, true))), | |
| 545 | - | ), | |
| 546 | - | Choice::new( | |
| 547 | - | "monthly", | |
| 548 | - | format!( | |
| 549 | - | "Monthly, {} a month", | |
| 550 | - | money(sync.quote_cents(start, false)) | |
| 551 | - | ), | |
| 552 | - | ), | |
| 553 | - | ], | |
| 554 | - | ) | |
| 555 | - | .hint("Prices shown are for the smallest cap; the exact figure is on the checkout page."), | |
| 556 | - | ], | |
| 557 | - | submit: "Subscribe".to_owned(), | |
| 558 | - | action: Action::post("/sync/subscribe"), | |
| 733 | + | fields: vec![field], | |
| 734 | + | submit: "Use this cap".to_owned(), | |
| 735 | + | action: Action::post(action), | |
| 559 | 736 | }), | |
| 560 | 737 | ) | |
| 561 | 738 | } | |
| 562 | 739 | ||
| 563 | - | /// The cap, as a bounded number. | |
| 740 | + | /// The named caps this pricing actually permits, smallest first. | |
| 564 | 741 | /// | |
| 565 | - | /// `min` and `max` are the bounds a renderer draws as a track and a route checks | |
| 566 | - | /// again. What is *not* said is that the shipped slider is logarithmic: that is | |
| 567 | - | /// a scale, which is how a renderer spends the space it has, and a description | |
| 568 | - | /// that named it would be naming a widget. | |
| 569 | - | fn cap_field(pricing: &super::Pricing, current: i64) -> Field { | |
| 570 | - | Field::new(FieldKind::Number, CAP, "Storage cap (GiB)") | |
| 571 | - | .value(gib_count(current.max(pricing.min_bytes)).to_string()) | |
| 572 | - | .required() | |
| 742 | + | /// Both screens read the same list from [`crate::storage_cap`]; a cap the egui | |
| 743 | + | /// panel offered and this one did not would be two products. | |
| 744 | + | fn offered_caps(pricing: &super::Pricing) -> impl Iterator<Item = i64> { | |
| 745 | + | crate::storage_cap::offered(pricing.min_bytes, pricing.max_bytes) | |
| 746 | + | } | |
| 747 | + | ||
| 748 | + | /// The cap to propose, sized to what would actually upload. | |
| 749 | + | fn proposed_cap(need: Option<i64>, pricing: &super::Pricing) -> i64 { | |
| 750 | + | crate::storage_cap::proposed(need, pricing.min_bytes, pricing.max_bytes) | |
| 573 | 751 | } | |
| 574 | 752 | ||
| 575 | 753 | /// A byte count as whole GiB, for a person. | |
| @@ -577,6 +755,27 @@ | |||
| 577 | 755 | u32::try_from(bytes / GIB).unwrap_or(u32::MAX) | |
| 578 | 756 | } | |
| 579 | 757 | ||
| 758 | + | /// A byte count as a size a person reads, keeping one decimal below a TiB. | |
| 759 | + | /// | |
| 760 | + | /// Distinct from [`gib_of`], which spells a *cap* - always a whole number of | |
| 761 | + | /// GiB, because that is what a cap is. This spells a measurement, where | |
| 762 | + | /// rounding 180.4 GiB to "180 GiB" is fine but rounding 0.4 GiB to "0 GiB" | |
| 763 | + | /// would tell a user with a small library that they have nothing. | |
| 764 | + | fn gib_of_exact(bytes: i64) -> String { | |
| 765 | + | #[expect( | |
| 766 | + | clippy::cast_precision_loss, | |
| 767 | + | reason = "a library size in GiB is far inside f64's exact integer range" | |
| 768 | + | )] | |
| 769 | + | let gib = bytes as f64 / GIB as f64; | |
| 770 | + | if gib >= 1024.0 { | |
| 771 | + | format!("{:.1} TiB", gib / 1024.0) | |
| 772 | + | } else if gib >= 10.0 { | |
| 773 | + | format!("{gib:.0} GiB") | |
| 774 | + | } else { | |
| 775 | + | format!("{gib:.1} GiB") | |
| 776 | + | } | |
| 777 | + | } | |
| 778 | + | ||
| 580 | 779 | /// A byte count as a cap, spelled the way the shipped panel spells it. | |
| 581 | 780 | fn gib_of(bytes: i64) -> String { | |
| 582 | 781 | let gib = bytes / GIB; |
| @@ -275,6 +275,44 @@ | |||
| 275 | 275 | .collect() | |
| 276 | 276 | } | |
| 277 | 277 | ||
| 278 | + | /// Every node on a screen, descending into regions. | |
| 279 | + | /// | |
| 280 | + | /// [`nodes`] stops at the top level, which is enough for a flat screen. The sync | |
| 281 | + | /// screen nests: a body region holds a subscription region holds the forms, so a | |
| 282 | + | /// test that asks what the screen says has to walk down. | |
| 283 | + | fn nodes_deep(screen: &Screen) -> Vec<Node> { | |
| 284 | + | fn walk(node: &Node, out: &mut Vec<Node>) { | |
| 285 | + | out.push(node.clone()); | |
| 286 | + | if let Node::Region(slot) = node { | |
| 287 | + | for placed in &slot.body { | |
| 288 | + | walk(&placed.node, out); | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | let mut out = Vec::new(); | |
| 293 | + | for slot in &screen.slots { | |
| 294 | + | for placed in &slot.body { | |
| 295 | + | walk(&placed.node, &mut out); | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | out | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | /// What the screen says, regions included. See [`said`]. | |
| 302 | + | fn said_deep(screen: &Screen) -> String { | |
| 303 | + | nodes_deep(screen) | |
| 304 | + | .iter() | |
| 305 | + | .filter_map(|node| match node { | |
| 306 | + | Node::Text { text, .. } | Node::Notice { text, .. } | Node::Heading { text, .. } => { | |
| 307 | + | Some(text.clone()) | |
| 308 | + | } | |
| 309 | + | Node::StandIn { message, .. } => Some(message.clone()), | |
| 310 | + | _ => None, | |
| 311 | + | }) | |
| 312 | + | .collect::<Vec<_>>() | |
| 313 | + | .join(" | ") | |
| 314 | + | } | |
| 315 | + | ||
| 278 | 316 | /// The text of every prose and notice node on a screen, joined. | |
| 279 | 317 | /// | |
| 280 | 318 | /// Assertions read against this rather than against node positions: what a | |
| @@ -484,6 +522,9 @@ | |||
| 484 | 522 | fn pricing(&self) -> Option<Pricing> { | |
| 485 | 523 | None | |
| 486 | 524 | } | |
| 525 | + | fn synced_library_bytes(&self) -> Option<i64> { | |
| 526 | + | None | |
| 527 | + | } | |
| 487 | 528 | fn quote_cents(&self, _cap_bytes: i64, _annual: bool) -> i64 { | |
| 488 | 529 | 0 | |
| 489 | 530 | } | |
| @@ -842,6 +883,8 @@ | |||
| 842 | 883 | subscription: Option<Subscription>, | |
| 843 | 884 | /// Whether pricing has arrived. | |
| 844 | 885 | priced: bool, | |
| 886 | + | /// What the vault says would upload, if the screen can look. | |
| 887 | + | library_bytes: Option<i64>, | |
| 845 | 888 | } | |
| 846 | 889 | ||
| 847 | 890 | impl FakeSync { | |
| @@ -858,6 +901,7 @@ | |||
| 858 | 901 | calls: RefCell::new(Vec::new()), | |
| 859 | 902 | subscription: None, | |
| 860 | 903 | priced: true, | |
| 904 | + | library_bytes: Some(0), | |
| 861 | 905 | } | |
| 862 | 906 | } | |
| 863 | 907 | ||
| @@ -923,6 +967,10 @@ | |||
| 923 | 967 | }) | |
| 924 | 968 | } | |
| 925 | 969 | ||
| 970 | + | fn synced_library_bytes(&self) -> Option<i64> { | |
| 971 | + | self.library_bytes | |
| 972 | + | } | |
| 973 | + | ||
| 926 | 974 | fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64 { | |
| 927 | 975 | // A stand-in for the server's model: a dollar a gibibyte a month, and | |
| 928 | 976 | // two months free on the year. The screen never computes a price, so | |
| @@ -1331,10 +1379,18 @@ | |||
| 1331 | 1379 | } | |
| 1332 | 1380 | ||
| 1333 | 1381 | #[test] | |
| 1334 | - | fn the_price_shown_is_the_committed_cap_and_not_a_live_quote() { | |
| 1335 | - | // The finding, asserted so it cannot be quietly "fixed" by making the field | |
| 1336 | - | // fire per keystroke: the hint prices what is stored, because nothing | |
| 1337 | - | // describes a display derived from a control's own uncommitted value. | |
| 1382 | + | fn every_offered_cap_carries_its_own_price() { | |
| 1383 | + | // This replaces `the_price_shown_is_the_committed_cap_and_not_a_live_quote`, | |
| 1384 | + | // which asserted the old shape: one hint, pricing the committed cap, because | |
| 1385 | + | // nothing describes a display derived from a control's own uncommitted | |
| 1386 | + | // value (quasicoherent `57c21152`). | |
| 1387 | + | // | |
| 1388 | + | // The redesign dissolves that finding for this control rather than working | |
| 1389 | + | // around it. The cap is now chosen from named sizes and each option carries | |
| 1390 | + | // its own price, so there is no uncommitted value to derive a display from - | |
| 1391 | + | // the price is on the label the user is reading when they choose. Both | |
| 1392 | + | // cadences are on every label too, so the cadence field cannot leave a price | |
| 1393 | + | // stale underneath it. | |
| 1338 | 1394 | let mut sync = FakeSync::in_state(State::Ready); | |
| 1339 | 1395 | sync.subscription = Some(active(20, 1)); | |
| 1340 | 1396 | let response = syncing(&sync, Request::get("/sync")).expect("answered"); | |
| @@ -1345,21 +1401,218 @@ | |||
| 1345 | 1401 | .any(|(_, action, _)| action == "/sync/cap"), | |
| 1346 | 1402 | "a running subscription offers a cap change" | |
| 1347 | 1403 | ); | |
| 1348 | - | let hint = screen | |
| 1349 | - | .slots | |
| 1350 | - | .iter() | |
| 1351 | - | .flat_map(|slot| &slot.body) | |
| 1352 | - | .flat_map(|placed| match &placed.node { | |
| 1353 | - | Node::Region(slot) => slot.body.iter().map(|inner| inner.node.clone()).collect(), | |
| 1354 | - | other => vec![other.clone()], | |
| 1355 | - | }) | |
| 1404 | + | ||
| 1405 | + | let cap = cap_field_of(screen).expect("the cap is chosen from named sizes"); | |
| 1406 | + | assert!( | |
| 1407 | + | !cap.options.is_empty(), | |
| 1408 | + | "the cap is a choice, not a bare number" | |
| 1409 | + | ); | |
| 1410 | + | for choice in &cap.options { | |
| 1411 | + | let gib: i64 = choice.value.parse().expect("a choice is a cap in GiB"); | |
| 1412 | + | // The fixture prices a dollar a gibibyte a month, ten months a year. | |
| 1413 | + | assert!( | |
| 1414 | + | choice.label.contains(&format!("${gib} a month")), | |
| 1415 | + | "every option prices itself monthly: {}", | |
| 1416 | + | choice.label | |
| 1417 | + | ); | |
| 1418 | + | assert!( | |
| 1419 | + | choice.label.contains(&format!("${} a year", gib * 10)), | |
| 1420 | + | "and annually, so the cadence field cannot stale it: {}", | |
| 1421 | + | choice.label | |
| 1422 | + | ); | |
| 1423 | + | } | |
| 1424 | + | ||
| 1425 | + | // The committed cap is 20 GiB, which is not one of the named sizes. It is | |
| 1426 | + | // still selected, rather than the group reading as unanswered. | |
| 1427 | + | assert_eq!(cap.value.as_deref(), Some("20")); | |
| 1428 | + | assert!( | |
| 1429 | + | cap.options.iter().any(|c| c.value == "20"), | |
| 1430 | + | "a cap off the named list is still one of the options" | |
| 1431 | + | ); | |
| 1432 | + | } | |
| 1433 | + | ||
| 1434 | + | /// The cap field, wherever in the screen's regions it landed. | |
| 1435 | + | fn cap_field_of(screen: &Screen) -> Option<quasi_router::Field> { | |
| 1436 | + | nodes_deep(screen).into_iter().find_map(|node| match node { | |
| 1437 | + | Node::Form { fields, .. } => fields | |
| 1438 | + | .iter() | |
| 1439 | + | .find(|f| f.name == "cap_gib" && f.kind == quasi_router::layout::FieldKind::Radio) | |
| 1440 | + | .cloned(), | |
| 1441 | + | _ => None, | |
| 1442 | + | }) | |
| 1443 | + | } | |
| 1444 | + | ||
| 1445 | + | /// A subscription that has lapsed, which is what puts the subscribe screen up. | |
| 1446 | + | fn lapsed() -> Subscription { | |
| 1447 | + | Subscription { | |
| 1448 | + | active: false, | |
| 1449 | + | limit_bytes: 0, | |
| 1450 | + | used_bytes: 0, | |
| 1451 | + | interval: "monthly".to_owned(), | |
| 1452 | + | pending_limit_bytes: None, | |
| 1453 | + | } | |
| 1454 | + | } | |
| 1455 | + | ||
| 1456 | + | #[test] | |
| 1457 | + | fn the_subscribe_screen_sizes_the_proposal_to_the_library() { | |
| 1458 | + | // The measurement the redesign turns on: the app already knows how much | |
| 1459 | + | // would upload, so it proposes a cap instead of soliciting one. 400 GiB of | |
| 1460 | + | // samples wants half again as headroom - 600 - and the smallest named size | |
| 1461 | + | // that covers 600 is 1024. | |
| 1462 | + | let mut sync = FakeSync::in_state(State::Ready); | |
| 1463 | + | sync.subscription = Some(lapsed()); | |
| 1464 | + | sync.library_bytes = Some(400 * GIB); | |
| 1465 | + | ||
| 1466 | + | let response = syncing(&sync, Request::get("/sync")).expect("answered"); | |
| 1467 | + | let screen = screen_of(&response); | |
| 1468 | + | ||
| 1469 | + | assert!( | |
| 1470 | + | said_deep(screen).contains("400 GiB"), | |
| 1471 | + | "the screen states the need it sized against: {}", | |
| 1472 | + | said_deep(screen) | |
| 1473 | + | ); | |
| 1474 | + | let cap = cap_field_of(screen).expect("a cap is offered"); | |
| 1475 | + | assert_eq!( | |
| 1476 | + | cap.value.as_deref(), | |
| 1477 | + | Some("1024"), | |
| 1478 | + | "the smallest named cap covering 400 GiB plus half again" | |
| 1479 | + | ); | |
| 1480 | + | } | |
| 1481 | + | ||
| 1482 | + | #[test] | |
| 1483 | + | fn nothing_set_to_sync_proposes_the_floor_and_says_why() { | |
| 1484 | + | // `Some(0)` is a real answer and a different one from "cannot look": no | |
| 1485 | + | // vault has file sync on, so nothing would upload. Proposing the floor is | |
| 1486 | + | // right, and so is saying why rather than showing a confident 250 GiB with | |
| 1487 | + | // no reason attached. | |
| 1488 | + | let mut sync = FakeSync::in_state(State::Ready); | |
| 1489 | + | sync.subscription = Some(lapsed()); | |
| 1490 | + | sync.library_bytes = Some(0); | |
| 1491 | + | ||
| 1492 | + | let response = syncing(&sync, Request::get("/sync")).expect("answered"); | |
| 1493 | + | let screen = screen_of(&response); | |
| 1494 | + | ||
| 1495 | + | assert!( | |
| 1496 | + | said_deep(screen).contains("No vault is set to sync"), | |
| 1497 | + | "{}", | |
| 1498 | + | said_deep(screen) | |
| 1499 | + | ); | |
| 1500 | + | let cap = cap_field_of(screen).expect("a cap is offered"); | |
| 1501 | + | assert_eq!( | |
| 1502 | + | cap.value.as_deref(), | |
| 1503 | + | Some("250"), | |
| 1504 | + | "the cheapest thing on offer" | |
| 1505 | + | ); | |
| 1506 | + | } | |
| 1507 | + | ||
| 1508 | + | #[test] | |
| 1509 | + | fn a_library_that_cannot_be_read_claims_no_size() { | |
| 1510 | + | // `None` is "cannot look". The screen must not invent a need, and must not | |
| 1511 | + | // print "0" as though it had measured one. | |
| 1512 | + | let mut sync = FakeSync::in_state(State::Ready); | |
| 1513 | + | sync.subscription = Some(lapsed()); | |
| 1514 | + | sync.library_bytes = None; | |
| 1515 | + | ||
| 1516 | + | let response = syncing(&sync, Request::get("/sync")).expect("answered"); | |
| 1517 | + | let screen = screen_of(&response); | |
| 1518 | + | ||
| 1519 | + | let text = said_deep(screen); | |
| 1520 | + | assert!( | |
| 1521 | + | !text.contains("would upload"), | |
| 1522 | + | "no measurement is claimed: {text}" | |
| 1523 | + | ); | |
| 1524 | + | assert!( | |
| 1525 | + | !text.contains("Proposed:"), | |
| 1526 | + | "and nothing is proposed as sized: {text}" | |
| 1527 | + | ); | |
| 1528 | + | let cap = cap_field_of(screen).expect("a cap is still offered"); | |
| 1529 | + | assert_eq!(cap.value.as_deref(), Some("250")); | |
| 1530 | + | } | |
| 1531 | + | ||
| 1532 | + | #[test] | |
| 1533 | + | fn a_filling_cap_warns_before_the_upload_fails() { | |
| 1534 | + | // Item 5 of the redesign. Today the first news of a full cap is a 402 from | |
| 1535 | + | // the blob route, which the user meets as a sync that broke. The screen | |
| 1536 | + | // knows the number, so it says the consequence first - and says that | |
| 1537 | + | // metadata sync carries on, which is the half that makes it not an outage. | |
| 1538 | + | let mut sync = FakeSync::in_state(State::Ready); | |
| 1539 | + | sync.subscription = Some(active(1024, 1000)); | |
| 1540 | + | sync.library_bytes = Some(1000 * GIB); | |
| 1541 | + | ||
| 1542 | + | let response = syncing(&sync, Request::get("/sync")).expect("answered"); | |
| 1543 | + | let text = said_deep(screen_of(&response)); | |
| 1544 | + | ||
| 1545 | + | assert!(text.contains("close to your storage cap"), "{text}"); | |
| 1546 | + | assert!( | |
| 1547 | + | text.contains("everything else keeps syncing"), | |
| 1548 | + | "the consequence is bounded, not an outage: {text}" | |
| 1549 | + | ); | |
| 1550 | + | // 1000 GiB plus half again is 1500, so 2048 is the smallest named cap that | |
| 1551 | + | // holds it, and the warning carries what that costs. | |
| 1552 | + | assert!( | |
| 1553 | + | text.contains("2048 GiB") || text.contains("2.0 TiB"), | |
| 1554 | + | "{text}" | |
| 1555 | + | ); | |
| 1556 | + | assert!( | |
| 1557 | + | text.contains("$2048 a month"), | |
| 1558 | + | "priced, at the fixture rate: {text}" | |
| 1559 | + | ); | |
| 1560 | + | } | |
| 1561 | + | ||
| 1562 | + | #[test] | |
| 1563 | + | fn a_full_cap_says_uploads_have_already_stopped() { | |
| 1564 | + | // The other side of the same sentence, and it is a different one: "will | |
| 1565 | + | // stop" and "have stopped" are not degrees of one message. | |
| 1566 | + | let mut sync = FakeSync::in_state(State::Ready); | |
| 1567 | + | sync.subscription = Some(active(1024, 1024)); | |
| 1568 | + | sync.library_bytes = Some(1024 * GIB); | |
| 1569 | + | ||
| 1570 | + | let response = syncing(&sync, Request::get("/sync")).expect("answered"); | |
| 1571 | + | let text = said_deep(screen_of(&response)); | |
| 1572 | + | ||
| 1573 | + | assert!(text.contains("cap is full"), "{text}"); | |
| 1574 | + | assert!(text.contains("not uploading"), "{text}"); | |
| 1575 | + | } | |
| 1576 | + | ||
| 1577 | + | #[test] | |
| 1578 | + | fn a_cap_with_room_says_nothing_about_filling() { | |
| 1579 | + | // The warning is a warning. A subscription at 5% must not carry it, or it | |
| 1580 | + | // stops being read. | |
| 1581 | + | let mut sync = FakeSync::in_state(State::Ready); | |
| 1582 | + | sync.subscription = Some(active(1024, 50)); | |
| 1583 | + | sync.library_bytes = Some(50 * GIB); | |
| 1584 | + | ||
| 1585 | + | let response = syncing(&sync, Request::get("/sync")).expect("answered"); | |
| 1586 | + | let text = said_deep(screen_of(&response)); | |
| 1587 | + | ||
| 1588 | + | assert!(!text.contains("storage cap"), "{text}"); | |
| 1589 | + | assert!(!text.contains("cap is full"), "{text}"); | |
| 1590 | + | } | |
| 1591 | + | ||
| 1592 | + | #[test] | |
| 1593 | + | fn the_exact_cap_form_carries_the_bounds_it_claims() { | |
| 1594 | + | // The old `cap_field` was a bare `FieldKind::Number` with no min and no max, | |
| 1595 | + | // despite a doc comment saying the bounds were what a renderer draws. The | |
| 1596 | + | // only thing rejecting an out-of-range cap was `cap_from`, after submit. | |
| 1597 | + | let mut sync = FakeSync::in_state(State::Ready); | |
| 1598 | + | sync.subscription = Some(lapsed()); | |
| 1599 | + | ||
| 1600 | + | let response = syncing(&sync, Request::get("/sync")).expect("answered"); | |
| 1601 | + | let screen = screen_of(&response); | |
| 1602 | + | ||
| 1603 | + | let exact = nodes_deep(screen) | |
| 1604 | + | .into_iter() | |
| 1356 | 1605 | .find_map(|node| match node { | |
| 1357 | - | Node::Form { fields, .. } => fields.iter().find(|f| f.name == "cap_gib")?.hint.clone(), | |
| 1606 | + | Node::Form { fields, .. } => fields | |
| 1607 | + | .iter() | |
| 1608 | + | .find(|f| f.name == "cap_gib" && f.kind == quasi_router::layout::FieldKind::Number) | |
| 1609 | + | .cloned(), | |
| 1358 | 1610 | _ => None, | |
| 1359 | 1611 | }) | |
| 1360 | - | .expect("the cap field is priced"); | |
| 1361 | - | // 20 GiB at the fixture's dollar-a-gibibyte, monthly. | |
| 1362 | - | assert!(hint.contains("$20"), "{hint}"); | |
| 1612 | + | .expect("an exact cap can be typed"); | |
| 1613 | + | ||
| 1614 | + | assert_eq!(exact.min.as_deref(), Some("10"), "the fixture's floor"); | |
| 1615 | + | assert_eq!(exact.max.as_deref(), Some("2048"), "the fixture's ceiling"); | |
| 1363 | 1616 | } | |
| 1364 | 1617 | ||
| 1365 | 1618 | #[test] |
| @@ -750,10 +750,23 @@ | |||
| 750 | 750 | /// each time `show_panel` transitions to false so reopening the panel gets | |
| 751 | 751 | /// fresh numbers. | |
| 752 | 752 | pub vfs_storage_fetched: bool, | |
| 753 | - | /// User's working cap selection on the cap-picker slider, in GiB. | |
| 754 | - | /// Persisted across frames so dragging the slider doesn't reset. Defaults | |
| 755 | - | /// to 100 GiB the first time the panel renders. | |
| 756 | - | pub cap_picker_gib: i64, | |
| 753 | + | /// The cap the user has picked, in GiB, or `None` while they have not. | |
| 754 | + | /// | |
| 755 | + | /// `None` is what makes the screen able to *propose*: with no choice of | |
| 756 | + | /// their own, the panel shows the cap sized to what would actually upload, | |
| 757 | + | /// and the moment the user picks something else that choice sticks across | |
| 758 | + | /// frames. A plain `i64` could not tell "they chose 250" from "nobody has | |
| 759 | + | /// chosen and 250 is the default", which is why this is not one. | |
| 760 | + | /// | |
| 761 | + | /// Cleared when the panel closes, alongside the storage caches. | |
| 762 | + | pub cap_picker_gib: Option<i64>, | |
| 763 | + | /// What blob sync would upload today, in bytes: the union of every VFS with | |
| 764 | + | /// file sync on, deduped by hash. `None` means not yet looked, or the look | |
| 765 | + | /// failed. | |
| 766 | + | /// | |
| 767 | + | /// Cached on the same terms as `vfs_storage_cache` and for the same reason: | |
| 768 | + | /// it is one indexed query, but not one to run at 60Hz. | |
| 769 | + | pub synced_bytes: Option<i64>, | |
| 757 | 770 | } | |
| 758 | 771 | ||
| 759 | 772 | impl Default for SyncUiState { | |
| @@ -774,7 +787,8 @@ | |||
| 774 | 787 | auth_url: None, | |
| 775 | 788 | vfs_storage_cache: std::collections::HashMap::new(), | |
| 776 | 789 | vfs_storage_fetched: false, | |
| 777 | - | cap_picker_gib: 100, | |
| 790 | + | cap_picker_gib: None, | |
| 791 | + | synced_bytes: None, | |
| 778 | 792 | } | |
| 779 | 793 | } | |
| 780 | 794 | } |
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | use audiofiles_sync::{AppPricing, BillingInterval, SyncManager, SyncState, SyncStatus}; | |
| 9 | 9 | ||
| 10 | 10 | use crate::state::{BrowserState, ConfirmAction}; | |
| 11 | + | use crate::storage_cap; | |
| 11 | 12 | use crate::ui::theme; | |
| 12 | 13 | use crate::ui::widgets; | |
| 13 | 14 | ||
| @@ -113,6 +114,8 @@ | |||
| 113 | 114 | if !state.sync.show_panel { | |
| 114 | 115 | state.sync.vfs_storage_fetched = false; | |
| 115 | 116 | state.sync.vfs_storage_cache.clear(); | |
| 117 | + | state.sync.synced_bytes = None; | |
| 118 | + | state.sync.cap_picker_gib = None; | |
| 116 | 119 | } | |
| 117 | 120 | ||
| 118 | 121 | let mut open = state.sync.show_panel; | |
| @@ -263,13 +266,70 @@ | |||
| 263 | 266 | ); | |
| 264 | 267 | } | |
| 265 | 268 | ||
| 266 | - | // Cap-change slider for subscribed users. | |
| 269 | + | // Say what a filling cap means before the upload that fails says it. | |
| 270 | + | // Without this the first news is a 402 from the blob route, which the | |
| 271 | + | // user meets as a sync that broke rather than a cap that filled. | |
| 272 | + | if storage_cap::nearly_full(used, limit) { | |
| 273 | + | ui.add_space(theme::space::hair()); | |
| 274 | + | let full = used >= limit; | |
| 275 | + | let text = if full { | |
| 276 | + | "Your storage cap is full. New sample files are not uploading; \ | |
| 277 | + | everything else still syncs." | |
| 278 | + | } else { | |
| 279 | + | "You are close to your storage cap. When it fills, new sample \ | |
| 280 | + | files stop uploading and everything else keeps syncing." | |
| 281 | + | }; | |
| 282 | + | ui.label(egui::RichText::new(text).color(if full { | |
| 283 | + | theme::danger() | |
| 284 | + | } else { | |
| 285 | + | theme::warning() | |
| 286 | + | })); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // Cap change for subscribed users. | |
| 267 | 290 | if let Some(pricing) = &sync_status.pricing { | |
| 268 | 291 | let pricing = pricing.clone(); | |
| 269 | 292 | let interval_enum = BillingInterval::from_wire(interval); | |
| 270 | 293 | ui.add_space(theme::space::peer()); | |
| 271 | - | ui.label(egui::RichText::new("Adjust cap (takes effect next cycle):").weak()); | |
| 272 | - | if let Some(cap) = draw_cap_picker(ui, state, &pricing, interval_enum, "Update cap") | |
| 294 | + | ui.label(egui::RichText::new("Adjust cap:").weak()); | |
| 295 | + | ui.label( | |
| 296 | + | egui::RichText::new( | |
| 297 | + | "An increase applies now. A decrease takes effect next cycle.", | |
| 298 | + | ) | |
| 299 | + | .small() | |
| 300 | + | .color(theme::content_muted()), | |
| 301 | + | ); | |
| 302 | + | ||
| 303 | + | // A cap that no longer covers the library is the one case where | |
| 304 | + | // re-proposing over the user's own answer is the point. | |
| 305 | + | if storage_cap::nearly_full(used, limit) { | |
| 306 | + | let need = synced_need(state); | |
| 307 | + | let proposed = | |
| 308 | + | storage_cap::proposed(need, pricing.min_cap_bytes, pricing.max_cap_bytes); | |
| 309 | + | if proposed > limit { | |
| 310 | + | ui.label( | |
| 311 | + | egui::RichText::new(format!( | |
| 312 | + | "{} would hold it, at {}/{}.", | |
| 313 | + | format_cap(proposed), | |
| 314 | + | format_cents(pricing.quote_cents(proposed, interval_enum).0), | |
| 315 | + | match interval_enum { | |
| 316 | + | BillingInterval::Monthly => "mo", | |
| 317 | + | BillingInterval::Annual => "yr", | |
| 318 | + | } | |
| 319 | + | )) | |
| 320 | + | .color(theme::content_muted()), | |
| 321 | + | ); | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | ui.add_space(theme::space::hair()); | |
| 326 | + | let cap = draw_cap_choice(ui, state, &pricing, interval_enum); | |
| 327 | + | ui.add_space(theme::space::hair()); | |
| 328 | + | let loading = state.sync.checkout_loading; | |
| 329 | + | if ui | |
| 330 | + | .add_enabled_ui(!loading, |ui| widgets::primary_button(ui, "Update cap")) | |
| 331 | + | .inner | |
| 332 | + | .clicked() | |
| 273 | 333 | { | |
| 274 | 334 | sync.queue_cap_change(cap); | |
| 275 | 335 | } | |
| @@ -278,7 +338,10 @@ | |||
| 278 | 338 | _ => { | |
| 279 | 339 | if let Some(pricing) = &sync_status.pricing { | |
| 280 | 340 | let pricing = pricing.clone(); | |
| 281 | - | ui.label("Pick a storage cap for audio file sync:"); | |
| 341 | + | // State the need first: it is why the cap below it says what it | |
| 342 | + | // says, and the app knows it exactly. | |
| 343 | + | let need = synced_need(state); | |
| 344 | + | draw_need(ui, need); | |
| 282 | 345 | ui.add_space(theme::space::hair()); | |
| 283 | 346 | ui.label( | |
| 284 | 347 | egui::RichText::new( | |
| @@ -289,10 +352,12 @@ | |||
| 289 | 352 | ); | |
| 290 | 353 | ui.add_space(theme::space::bound()); | |
| 291 | 354 | ||
| 292 | - | // One cap slider, then annual/monthly checkout buttons for that | |
| 293 | - | // single chosen cap, two priced choices, not two sliders that | |
| 294 | - | // secretly share a value. | |
| 295 | - | let cap_bytes = draw_cap_slider(ui, state, &pricing); | |
| 355 | + | // Named sizes, each carrying its price at the annual rate, since | |
| 356 | + | // annual is the recommendation; the monthly figure is on the | |
| 357 | + | // button below. Replaces a logarithmic slider that asked the user | |
| 358 | + | // to sweep three orders of magnitude to reach a number the app | |
| 359 | + | // could already compute. | |
| 360 | + | let cap_bytes = draw_cap_choice(ui, state, &pricing, BillingInterval::Annual); | |
| 296 | 361 | ui.add_space(theme::space::bound()); | |
| 297 | 362 | ||
| 298 | 363 | if state.sync.checkout_loading { | |
| @@ -716,74 +781,108 @@ | |||
| 716 | 781 | } | |
| 717 | 782 | } | |
| 718 | 783 | ||
| 719 | - | /// Draw just the storage-cap slider (GiB, logarithmic) plus a cap-size label, | |
| 720 | - | /// clamping the working value to the pricing range. Returns the chosen cap in | |
| 721 | - | /// bytes. Used by the subscribe view (one slider feeding two checkout buttons). | |
| 722 | - | fn draw_cap_slider(ui: &mut egui::Ui, state: &mut BrowserState, pricing: &AppPricing) -> i64 { | |
| 723 | - | let min_gib = (pricing.min_cap_bytes / GIB).max(1); | |
| 724 | - | let max_gib = (pricing.max_cap_bytes / GIB).max(min_gib); | |
| 725 | - | state.sync.cap_picker_gib = state.sync.cap_picker_gib.clamp(min_gib, max_gib); | |
| 726 | - | ui.add( | |
| 727 | - | egui::Slider::new(&mut state.sync.cap_picker_gib, min_gib..=max_gib) | |
| 728 | - | .logarithmic(true) | |
| 729 | - | .text("GiB"), | |
| 730 | - | ); | |
| 731 | - | let cap_bytes = state.sync.cap_picker_gib * GIB; | |
| 732 | - | ui.label(egui::RichText::new(format_cap(cap_bytes)).strong()); | |
| 733 | - | cap_bytes | |
| 784 | + | /// What blob sync would upload today, cached for as long as the panel is open. | |
| 785 | + | /// | |
| 786 | + | /// One indexed query, on the same terms as `vfs_storage_cache`: cheap, but not | |
| 787 | + | /// at 60Hz. `None` means the look failed, which the caller must not print as a | |
| 788 | + | /// measured zero - "nothing is set to sync" and "could not tell" are different | |
| 789 | + | /// things to say. | |
| 790 | + | fn synced_need(state: &mut BrowserState) -> Option<i64> { | |
| 791 | + | if state.sync.synced_bytes.is_none() | |
| 792 | + | && let Ok((_, bytes)) = state.backend.synced_storage_stats() | |
| 793 | + | { | |
| 794 | + | state.sync.synced_bytes = i64::try_from(bytes).ok(); | |
| 795 | + | } | |
| 796 | + | state.sync.synced_bytes | |
| 734 | 797 | } | |
| 735 | 798 | ||
| 736 | - | /// Cap-picker widget: slider in GiB + live price preview + action button. | |
| 737 | - | /// Used both for initial subscribe and for queuing a cap change on an active | |
| 738 | - | /// subscription. The slider's working value lives on `BrowserState::sync` so | |
| 739 | - | /// it survives frames; returns `Some(cap_bytes)` on the frame the button is | |
| 740 | - | /// clicked so the caller can fire the action (the helper avoids touching | |
| 741 | - | /// `state` further itself, sidestepping borrow conflicts with action closures). | |
| 742 | - | fn draw_cap_picker( | |
| 799 | + | /// The cap the user is working with: their own pick, or the proposal. | |
| 800 | + | fn working_cap(state: &mut BrowserState, pricing: &AppPricing) -> i64 { | |
| 801 | + | let need = synced_need(state); | |
| 802 | + | let proposed = storage_cap::proposed(need, pricing.min_cap_bytes, pricing.max_cap_bytes); | |
| 803 | + | state.sync.cap_picker_gib.map_or(proposed, |gib| { | |
| 804 | + | (gib * GIB).clamp(pricing.min_cap_bytes, pricing.max_cap_bytes) | |
| 805 | + | }) | |
| 806 | + | } | |
| 807 | + | ||
| 808 | + | /// Say what would upload, so the cap below it reads as an answer rather than a | |
| 809 | + | /// question. | |
| 810 | + | fn draw_need(ui: &mut egui::Ui, need: Option<i64>) { | |
| 811 | + | let text = match need { | |
| 812 | + | Some(0) => "No vault is set to sync sample files yet, so nothing would upload.".to_owned(), | |
| 813 | + | Some(bytes) => format!( | |
| 814 | + | "Your synced vaults hold {}. That is what would upload.", | |
| 815 | + | widgets::format_bytes(u64::try_from(bytes).unwrap_or(0)) | |
| 816 | + | ), | |
| 817 | + | None => return, | |
| 818 | + | }; | |
| 819 | + | ui.label(egui::RichText::new(text).color(theme::content_muted())); | |
| 820 | + | } | |
| 821 | + | ||
| 822 | + | /// The cap, as named sizes carrying their prices, plus an exact figure. | |
| 823 | + | /// | |
| 824 | + | /// Replaces the two logarithmic sliders this module used to draw. The slider | |
| 825 | + | /// asked the user to sweep 250 GiB to 10 TiB - three orders of magnitude - to | |
| 826 | + | /// land on a number the app could already compute, and it showed one price at a | |
| 827 | + | /// time, for whatever the handle happened to be over. Named sizes show every | |
| 828 | + | /// price at once, which is the point: this is a spending decision and the | |
| 829 | + | /// enforced quantity is bytes, so both have to be on screen together. | |
| 830 | + | /// | |
| 831 | + | /// Returns the cap in bytes that is currently selected. | |
| 832 | + | fn draw_cap_choice( | |
| 743 | 833 | ui: &mut egui::Ui, | |
| 744 | 834 | state: &mut BrowserState, | |
| 745 | 835 | pricing: &AppPricing, | |
| 746 | 836 | interval: BillingInterval, | |
| 747 | - | button_label: &str, | |
| 748 | - | ) -> Option<i64> { | |
| 749 | - | let min_gib = (pricing.min_cap_bytes / GIB).max(1); | |
| 750 | - | let max_gib = (pricing.max_cap_bytes / GIB).max(min_gib); | |
| 751 | - | if state.sync.cap_picker_gib < min_gib { | |
| 752 | - | state.sync.cap_picker_gib = min_gib; | |
| 753 | - | } | |
| 754 | - | if state.sync.cap_picker_gib > max_gib { | |
| 755 | - | state.sync.cap_picker_gib = max_gib; | |
| 837 | + | ) -> i64 { | |
| 838 | + | let mut chosen = working_cap(state, pricing); | |
| 839 | + | ||
| 840 | + | // The selected cap is always among the options, even when it is not one of | |
| 841 | + | // the named sizes: a user on a cap from before this list existed must see | |
| 842 | + | // their own cap selected, not a group with nothing chosen. | |
| 843 | + | let mut sizes: Vec<i64> = | |
| 844 | + | storage_cap::offered(pricing.min_cap_bytes, pricing.max_cap_bytes).collect(); | |
| 845 | + | if !sizes.contains(&chosen) { | |
| 846 | + | sizes.push(chosen); | |
| 847 | + | sizes.sort_unstable(); | |
| 756 | 848 | } | |
| 757 | 849 | ||
| 758 | - | ui.add( | |
| 759 | - | egui::Slider::new(&mut state.sync.cap_picker_gib, min_gib..=max_gib) | |
| 760 | - | .logarithmic(true) | |
| 761 | - | .text("GiB"), | |
| 762 | - | ); | |
| 763 | - | ||
| 764 | - | let cap_bytes = state.sync.cap_picker_gib * GIB; | |
| 765 | - | let price_cents = pricing.quote_cents(cap_bytes, interval).0; | |
| 766 | 850 | let interval_word = match interval { | |
| 767 | - | BillingInterval::Monthly => "month", | |
| 768 | - | BillingInterval::Annual => "year", | |
| 851 | + | BillingInterval::Monthly => "mo", | |
| 852 | + | BillingInterval::Annual => "yr", | |
| 769 | 853 | }; | |
| 770 | - | ui.label(format!( | |
| 771 | - | "{} to {}/{}", | |
| 772 | - | format_cap(cap_bytes), | |
| 773 | - | format_cents(price_cents), | |
| 774 | - | interval_word, | |
| 775 | - | )); | |
| 776 | - | ||
| 777 | - | let loading = state.sync.checkout_loading; | |
| 778 | - | if ui | |
| 779 | - | .add_enabled_ui(!loading, |ui| widgets::primary_button(ui, button_label)) | |
| 780 | - | .inner | |
| 781 | - | .clicked() | |
| 782 | - | { | |
| 783 | - | Some(cap_bytes) | |
| 784 | - | } else { | |
| 785 | - | None | |
| 854 | + | for bytes in sizes { | |
| 855 | + | let price = format_cents(pricing.quote_cents(bytes, interval).0); | |
| 856 | + | let label = format!("{} {price}/{interval_word}", format_cap(bytes)); | |
| 857 | + | if ui.radio(chosen == bytes, label).clicked() { | |
| 858 | + | chosen = bytes; | |
| 859 | + | state.sync.cap_picker_gib = Some(bytes / GIB); | |
| 860 | + | } | |
| 786 | 861 | } | |
| 862 | + | ||
| 863 | + | // The exact figure, for the cap that is not on the list. A drag value rather | |
| 864 | + | // than a slider: it is for typing a number you already have in mind, not for | |
| 865 | + | // exploring a range. | |
| 866 | + | ui.add_space(theme::space::hair()); | |
| 867 | + | ui.horizontal(|ui| { | |
| 868 | + | ui.label(egui::RichText::new("Exact:").color(theme::content_muted())); | |
| 869 | + | let min_gib = (pricing.min_cap_bytes / GIB).max(1); | |
| 870 | + | let max_gib = (pricing.max_cap_bytes / GIB).max(min_gib); | |
| 871 | + | let mut gib = chosen / GIB; | |
| 872 | + | if ui | |
| 873 | + | .add( | |
| 874 | + | egui::DragValue::new(&mut gib) | |
| 875 | + | .range(min_gib..=max_gib) | |
| 876 | + | .suffix(" GiB"), | |
| 877 | + | ) | |
| 878 | + | .changed() | |
| 879 | + | { | |
| 880 | + | chosen = gib.clamp(min_gib, max_gib) * GIB; | |
| 881 | + | state.sync.cap_picker_gib = Some(chosen / GIB); | |
| 882 | + | } | |
| 883 | + | }); | |
| 884 | + | ||
| 885 | + | chosen | |
| 787 | 886 | } | |
| 788 | 887 | ||
| 789 | 888 | #[cfg(test)] |
| @@ -268,6 +268,12 @@ | |||
| 268 | 268 | .map_err(|e| BackendError::Other(e.to_string())) | |
| 269 | 269 | } | |
| 270 | 270 | ||
| 271 | + | fn synced_storage_stats(&self) -> BackendResult<(u64, u64)> { | |
| 272 | + | let db = self.db.lock(); | |
| 273 | + | db.synced_storage_stats() | |
| 274 | + | .map_err(|e| BackendError::Other(e.to_string())) | |
| 275 | + | } | |
| 276 | + | ||
| 271 | 277 | fn poll_events(&self) -> Vec<BackendEvent> { | |
| 272 | 278 | let mut events = Vec::new(); | |
| 273 | 279 |