//! The cloud sync panel, described rather than built. //! //! The second audiofiles screen through `quasi`, and the one that answers a //! question the settings port could not: **what does a description do with a //! screen that is a state machine?** Nothing new. Four states, four screens, one //! route, which is routing doing its ordinary job. //! //! # It is describable here and was not in goingson, for a reason worth keeping //! //! goingson's settings port ruled Sync and Sharing out on 2026-08-09: they //! "reach a network client through commands taking an `AppHandle`", and a //! handler is `fn(&S, Request)` with no handle and no runtime. //! //! audiofiles' sync is the same *feature* and comes through cleanly, because //! every method on `SyncManager` this screen needs already takes `&self`: //! `start_auth`, `cancel_auth`, `setup_encryption`, `sync_now`, //! `update_settings`, `disconnect`, `clear_last_error`. The async lives inside //! the manager, behind a scheduler it owns, rather than in a command wrapper the //! UI has to call through. //! //! So the boundary is not "network is undescribable". It is the one the settings //! port already found and this confirms from the other side: **what a described //! screen needs is a synchronous handle to the app's own capability**, and //! whether it has one is a property of how the app is built rather than of what //! the capability does. //! //! # What the description deletes //! //! Three platform branches. `draw_disconnected` opens the auth URL with //! `open` / `xdg-open` / `cmd /c start`, chosen by `#[cfg(target_os)]`, inside a //! drawing function. Here `POST /sync/connect` answers //! [`Outcome::Goto`](quasi_router::Outcome::Goto) with an external destination, //! which is a one-way handoff the *host* performs — `Step::Open` in //! `quasi-immediate`, an anchor with `target="_blank"` in a webview. The //! description says "somewhere outside the app" and each host already knows what //! that means for it. //! //! # THE FINDING, closed: a description is built once, and this screen is alive //! //! The panel reads `sync.status()` **every frame** and redraws from it: the //! spinner while syncing, the pending-changes count as it falls, the state //! changing under the user when the OAuth callback lands in another process. //! None of that is a user acting. //! //! A description is built once per answer, so a described sync screen is a //! photograph. Nothing in the vocabulary says "this is live". The workaround //! here is that the host re-asks on a timer, which works and is invisible in the //! description — meaning two hosts will each invent their own cadence, which is //! the divergence the layer exists to end. //! //! Note what this is *not*: it is not asking for a poll interval in the //! description. `Message::undo` settled that timing is renderer policy ("No //! timeout here. How long an undo stays offered is renderer policy"). The //! missing word is nearer "this region reports something that changes without //! the user" — a fact about the content, which the renderer then answers with a //! cadence of its own choosing. //! //! That is what [`Slot::live`](quasi_router::Slot::live) is, and this screen is //! its acceptance case. `screen` sets it on the body; each renderer holds one //! `CADENCE` for every live region it draws, so the two hosts no longer invent //! their own. Nothing here re-reads per frame any more. //! //! # The subscription section, and the two findings it produced //! //! Added in a second pass. It is a purchase flow: a cap to choose, a price that //! depends on it, two cadences, and a Stripe checkout in a browser. Most of it //! describes cleanly and two things do not. //! //! **A price cannot follow a slider.** The shipped picker is a logarithmic //! slider with a live quote beside it, recomputed per drag frame from //! `AppPricing::quote_cents` — a *server* pricing model the app happens to hold //! a copy of. A described field carries a value and `Field::changes` fires a //! route when it changes, so following the drag means a request per step. The //! port therefore quotes what is **committed**, not what is under the thumb, and //! the finding is the general one: **nothing describes a display derived from a //! control's own uncommitted value.** Filed rather than papered over. //! //! Note what is *not* the answer: shipping the pricing model in the description //! so the renderer can compute. That is a formula travelling as data, and the //! next change to it silently prices three renderers differently. //! //! **A form has one submit, and this offers two priced choices over one value.** //! Annual and monthly are two actions over the same cap. //! [`Node::Form`](quasi_router::Node::Form) carries one `action` and one //! `submit`, so this is unsayable as drawn. The port makes the cadence a //! described [`Choice`] inside the form and submits once, which is arguably the //! better screen — the two buttons *are* a radio wearing button clothes — but it //! is a redesign forced by the vocabulary rather than chosen, and that is worth //! recording as such. //! //! **The checkout URL is not `Outcome::Goto`, and the auth URL is.** Same //! affordance, two shapes, and the difference is not aesthetic: //! `SyncManager::start_auth` answers a URL synchronously, so connecting is a //! described `Destination::External`. `subscribe` fetches the checkout URL //! asynchronously and opens it itself, so the route can only ask and answer with //! the screen. Whether "go here" is describable turns on whether the address is //! known when the description is built. //! //! # What it deleted //! //! About thirty-five lines of loading-flag bookkeeping: two `Instant` fields, //! two thirty-second timeouts, and the rule that a checkout error clears one //! flag but a fetch error must not. A described screen says //! [`Readiness::Pending`](quasi_router::layout::Readiness::Pending) and the //! renderer owns what waiting looks like. use quasi_router::layout::{FieldKind, Selector, Tone}; use quasi_router::{ Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, Slot, }; use super::{State, Status, Subscription, Sync}; /// The region the whole screen answers into. const BODY: &str = "sync-body"; /// One gibibyte, which is what a cap is counted in. use crate::storage_cap::GIB; /// The field a cap is chosen with. const CAP: &str = "cap_gib"; /// The cadences the panel offers, in minutes. /// /// The same four the shipped panel has. A [`Selector::Segmented`] rather than a /// number, because four named choices is a strip and not a range: the shipped /// panel draws pills, and a renderer with no pills draws a small select. const INTERVALS: &[u32] = &[5, 15, 30, 60]; /// Register this screen's routes. pub fn routes(router: Router>) -> Router> { router .get("/sync", index) .post("/sync/connect", connect) .post("/sync/cancel", cancel) .post("/sync/encryption", encryption) .post("/sync/now", sync_now) .post("/sync/auto", auto) .post("/sync/interval", interval) .post("/sync/error/clear", clear_error) .post("/sync/disconnect", disconnect) .post("/sync/subscription/refresh", refresh_subscription) .post("/sync/subscribe", subscribe) .post("/sync/cap", cap) } /// `GET /sync` fn index(state: &super::Panels<'_>, _request: Request) -> Result { Ok(screen(state.sync).into()) } /// `POST /sync/connect` /// /// Answers with somewhere to go rather than with a screen. Starting auth returns /// the address the user has to visit, and visiting it is the host's job: this is /// the case [`Destination::External`](quasi_router::Destination::External) was /// added for, and it is what replaces the three `#[cfg(target_os)]` branches the /// shipped panel carries. fn connect(state: &super::Panels<'_>, _request: Request) -> Result { let address = state .sync .connect() .map_err(|error| RouteError::internal(format!("Sync connect failed: {error}")))?; Ok(Response::goto(Action::external(address))) } /// `POST /sync/cancel` fn cancel(state: &super::Panels<'_>, _request: Request) -> Result { state.sync.cancel(); Ok(screen(state.sync).into()) } /// `POST /sync/encryption` /// /// The password arrives in the payload and is never carried back: the field is a /// [`FieldKind::Secret`], which the description refuses to hold a value for, so /// the answer re-describes an empty box rather than the one that was typed in. fn encryption(state: &super::Panels<'_>, request: Request) -> Result { let password = request.payload.get("password").unwrap_or_default(); if password.is_empty() { return Ok(Response::from(screen(state.sync)) .toast(Tone::Danger, "A password is needed to encrypt this vault.")); } let is_new = matches!( state.sync.status().state, State::NeedsEncryption { has_server_key: false } ); state.sync.set_password(password, is_new); Ok(screen(state.sync).into()) } /// `POST /sync/now` fn sync_now(state: &super::Panels<'_>, _request: Request) -> Result { state.sync.sync_now(); Ok(screen(state.sync).into()) } /// `POST /sync/auto` fn auto(state: &super::Panels<'_>, request: Request) -> Result { let on = !request.payload.get("auto").unwrap_or_default().is_empty(); state.sync.set_auto(on); Ok(screen(state.sync).into()) } /// `POST /sync/interval` fn interval(state: &super::Panels<'_>, request: Request) -> Result { let minutes: u32 = request .payload .get(Node::SELECTED) .and_then(|value| value.parse().ok()) .ok_or_else(|| RouteError::not_found("no such interval"))?; if !INTERVALS.contains(&minutes) { return Err(RouteError::not_found("no such interval")); } state.sync.set_interval(minutes); Ok(screen(state.sync).into()) } /// `POST /sync/error/clear` fn clear_error(state: &super::Panels<'_>, _request: Request) -> Result { state.sync.clear_error(); Ok(screen(state.sync).into()) } /// `POST /sync/subscription/refresh` fn refresh_subscription( state: &super::Panels<'_>, _request: Request, ) -> Result { state.sync.refresh_subscription(); Ok(screen(state.sync).into()) } /// `POST /sync/subscribe` /// /// Answers with the screen rather than with somewhere to go, which is the /// contrast the module header draws: the checkout URL is fetched asynchronously /// and the manager opens it, so there is no address to put in an /// [`Outcome::Goto`](quasi_router::Outcome::Goto). fn subscribe(state: &super::Panels<'_>, request: Request) -> Result { let cap = cap_from(state, &request)?; let annual = request.payload.get("cadence") == Some("annual"); state.sync.subscribe(cap, annual); Ok(Response::from(screen(state.sync)).toast(Tone::Info, "Opening checkout in your browser.")) } /// `POST /sync/cap` fn cap(state: &super::Panels<'_>, request: Request) -> Result { let cap = cap_from(state, &request)?; state.sync.queue_cap_change(cap); Ok(Response::from(screen(state.sync)) .toast(Tone::Success, "The cap changes at your next renewal.")) } /// The cap a request asked for, in bytes, refused if it is outside what is sold. /// /// Bounds-checked here and not only in the field, on the rule the interval route /// already follows: `Field::min` and `max` are what a renderer draws, and a /// route is reachable by typing. fn cap_from(state: &super::Panels<'_>, request: &Request) -> Result { let gib: i64 = request .payload .get(CAP) .and_then(|value| value.parse().ok()) .ok_or_else(|| RouteError::not_found("no cap named"))?; let bytes = gib.saturating_mul(GIB); let pricing = state .sync .pricing() .ok_or_else(|| RouteError::internal("Pricing is not loaded yet."))?; if bytes < pricing.min_bytes || bytes > pricing.max_bytes { return Err(RouteError::not_found("that cap is not on offer")); } Ok(bytes) } /// `POST /sync/disconnect` fn disconnect(state: &super::Panels<'_>, _request: Request) -> Result { state.sync.disconnect(); Ok(screen(state.sync).into()) } /// The screen, which is a different screen per state. /// /// One route answering four shapes rather than four routes: the state is not an /// address, and a user cannot navigate to `Authenticating` — they arrive there /// because something happened. Four addresses would be four places you could /// bookmark into a lie. fn screen(sync: &dyn Sync) -> Screen { let status = sync.status(); // Live, which is the whole of what the finding in this module's header // asked for. The state moves without anyone acting: an OAuth callback lands // in another process and `Authenticating` becomes `NeedsEncryption`, and the // pending count falls while a sync runs. The description says the contents // move and says nothing about how often to look; the cadence is // `quasi_immediate::CADENCE`, and the host no longer owns a timer. let mut body = Slot::new(BODY, RegionKind::Pane) .live() .with(Node::page("Cloud Sync")); // Nothing to offer when there is nothing to offer it against. See // [`Sync::available`]: an unavailable manager is not a disconnected one, and // the flip found the described screen offering a `Connect` that refuses and // a `Dismiss` for an error `clear_error` cannot clear. The shipped panel // answered this state with a whole second window saying the same two // sentences and drawing no controls. if !sync.available() { return Screen::sidebar_content("Cloud Sync").with( body.with(Node::text("Cloud sync is unavailable.")) .with(Node::text("Open a vault to enable sync.")), ); } body = match status.state { State::Disconnected => disconnected(body), State::Authenticating => authenticating(body), State::NeedsEncryption { has_server_key } => needs_encryption(body, has_server_key), State::Ready | State::Syncing => ready(body, &status, sync), }; // The error banner, on every state, because a failure can arrive in any of // them. `Node::Notice` carries the tone and the text; what to do about it is // two controls, and Retry is only offered where retrying means anything. if let Some(error) = &status.last_error { body = body.with(Node::Notice { kind: quasi_router::layout::Notice::Banner, tone: Tone::Danger, text: error.clone(), }); if matches!(status.state, State::Ready | State::Syncing) { body = body.with(Node::Act(Act::new("Retry", Action::post("/sync/now")))); } body = body.with(Node::Act(Act::new( "Dismiss", Action::post("/sync/error/clear"), ))); } Screen::sidebar_content("Cloud Sync").with(body) } fn disconnected(body: Slot) -> Slot { body.with(Node::text( "Connect your audiofiles vault to Makenot.work for cross-device sync.", )) .with(Node::text( "Metadata (tags, vault structure, analysis) syncs automatically. Audio file sync is per-vault opt-in.", )) .with(Node::Act(Act::new("Connect", Action::post("/sync/connect")))) } /// Waiting on a browser, with a way out. /// /// The spinner needs no vocabulary: [`Readiness::Pending`] is what a region /// waiting on something says, and every renderer already answers it. That is one /// place this screen expected a finding and did not get one. fn authenticating(body: Slot) -> Slot { body.with(Node::text("Waiting for authentication in your browser...")) .with(Node::text( "The app will update automatically once you sign in.", )) .with(Node::Act(Act::new("Cancel", Action::post("/sync/cancel")))) } /// The password that encrypts this vault. /// /// [`FieldKind::Secret`] and nothing else: the description will not carry the /// typed value, which is `39057019`, so the runtime's buffer is the only place /// it has ever lived. Whether this is a new password or an existing one changes /// only what is said, which is why `has_server_key` reaches the prose and not /// the shape. fn needs_encryption(body: Slot, has_server_key: bool) -> Slot { let says = if has_server_key { "This vault is already encrypted. Enter its password to unlock it here." } else { "Choose a password. It encrypts everything before it leaves this machine, and it cannot be recovered." }; body.with(Node::text(says)).with(Node::Form { fields: vec![ Field::new(FieldKind::Secret, "password", "Password") .required() .hint(if has_server_key { "The password this vault was encrypted with." } else { "Nobody can reset this for you." }), ], submit: if has_server_key { "Unlock" } else { "Set password" } .to_owned(), action: Action::post("/sync/encryption"), }) } /// Connected, and what it is doing. fn ready(body: Slot, status: &Status, sync: &dyn Sync) -> Slot { let syncing = matches!(status.state, State::Syncing); let mut body = body.with(Node::text(if syncing { "Syncing..." } else { "Connected" })); if let Some(last) = &status.last_sync_at { body = body.with(Node::text(format!("Last sync: {last}"))); } if status.pending_changes > 0 { // A count, not a proportion: nothing knows the total, and a `Meter` // handed a made-up denominator would draw a bar that means nothing. body = body.with(Node::Figure(quasi_router::Figure::new( status.pending_changes.to_string(), "pending changes", ))); } let mut now = Act::new("Sync now", Action::post("/sync/now")); if syncing { // Present, visible and not answering, which is what a control that is // already running should be. The shipped panel says the same thing with // `add_enabled(!syncing, ...)`. now = now.disabled(); } body.with(Node::Act(now)) .with(Node::section("Auto-sync")) .with(Node::Field(Box::new( Field::new(FieldKind::Checkbox, "auto", "Sync on a schedule") .value(if status.auto_sync_enabled { "on" } else { "" }) .changes(Action::post("/sync/auto")), ))) .with(Node::Select { kind: Selector::Segmented, options: INTERVALS .iter() .map(|minutes| { ( Choice::new(minutes.to_string(), format!("{minutes} min")), None, ) }) .collect(), chosen: Some(status.sync_interval_minutes.to_string()), action: Some(Action::post("/sync/interval")), }) .with(Node::section("Audio file sync")) .with(subscription(sync)) .with(Node::Act( Act::new("Disconnect", Action::post("/sync/disconnect")) .tone(Tone::Danger) .confirm("Disconnect this vault from cloud sync?"), )) } /// What is bought, or what may be. /// /// Three shapes: not fetched yet, subscribed, or on offer. The first is a region /// rather than a sentence, because "not fetched yet" is exactly what /// [`Readiness::Pending`](quasi_router::layout::Readiness::Pending) says and the /// renderer already knows how to draw waiting. fn subscription(sync: &dyn Sync) -> Node { let Some(pricing) = sync.pricing() else { return waiting("Loading pricing..."); }; match sync.subscription() { None => waiting("Checking subscription..."), Some(sub) if sub.active => subscribed(sync, &sub, &pricing), Some(_) => on_offer(sync, &pricing), } } /// A region that is waiting on something. /// /// The whole of what the shipped panel spends two `Instant` fields, two /// thirty-second timeouts and a spinner on. How long to wait and what to draw /// while waiting are the renderer's, which is why neither is here. fn waiting(says: &str) -> Node { Node::Region( Slot::new("subscription", RegionKind::Pane) .pending() .with(Node::text(says)) .with(Node::Act(Act::new( "Retry", Action::post("/sync/subscription/refresh"), ))), ) } /// A running subscription: what it holds, how full it is, and how to change it. fn subscribed(sync: &dyn Sync, sub: &Subscription, pricing: &super::Pricing) -> Node { let mut slot = Slot::new("subscription", RegionKind::Pane).with(Node::text(format!( "Subscribed: {} ({})", gib_of(sub.limit_bytes), sub.interval ))); if sub.limit_bytes > 0 { // A real proportion, so a real `Meter`: used against bought, both known. // Counted in GiB rather than bytes because the bar is read by a person // and `Meter` takes two `u32`s. slot = slot.with(Node::Meter( quasi_router::Meter::new(gib_count(sub.used_bytes), gib_count(sub.limit_bytes).max(1)) .label("GiB used") // At ninety percent, not past it: this is a cap that stops syncing // when it fills, and the point of saying so is to say it before // that happens. .tone(if nearly_full(sub) { Tone::Warning } else { Tone::Neutral }), )); } // Say it in words as well as in the bar, and say what happens next. A meter // that has gone amber reports a quantity; the user needs the consequence, // which is that uploads stop and metadata sync carries on. Without this the // first news of a full cap is a failed upload - the 402 from // `routes/synckit/blobs.rs`, which the user meets as a sync that broke. if let Some(warning) = cap_warning(sync, sub, pricing) { slot = slot.with(Node::Notice { kind: quasi_router::layout::Notice::Banner, tone: if sub.used_bytes >= sub.limit_bytes { Tone::Danger } else { Tone::Warning }, text: warning, }); } if let Some(pending) = sub.pending_limit_bytes { slot = slot.with(Node::text(format!( "Pending: cap changes to {} at next renewal.", gib_of(pending) ))); } // The same control the subscribe screen uses, defaulted to what is already // bought rather than to a proposal: this user has answered the question, and // re-proposing over their answer would be the screen arguing with them. The // exception is a cap that no longer covers the library, where the proposal // is the point. let default = if nearly_full(sub) { proposed_cap(sync.synced_library_bytes(), pricing).max(sub.limit_bytes) } else { sub.limit_bytes }; Node::Region( slot.with(Node::Form { fields: vec![cap_choice(sync, pricing, default)], submit: "Update cap".to_owned(), action: Action::post("/sync/cap"), }) .with(exact_cap_form(pricing, default, "/sync/cap")), ) } /// Whether the cap is close enough to full to say so. /// /// Ninety percent, the same threshold the meter turns amber at, so the bar and /// the sentence never disagree about whether this is a problem. fn nearly_full(sub: &Subscription) -> bool { crate::storage_cap::nearly_full(sub.used_bytes, sub.limit_bytes) } /// What to say about a cap that is filling, if anything. /// /// Three cases, and they are different sentences rather than degrees of one. /// Full means uploads have already stopped. Nearly full means they are about to. /// A library that has outgrown the cap means the number to fix it is known, so /// the message carries it and what it costs. fn cap_warning(sync: &dyn Sync, sub: &Subscription, pricing: &super::Pricing) -> Option { if !nearly_full(sub) { return None; } let annual = sub.interval == "annual"; let cadence = if annual { "a year" } else { "a month" }; let suggestion = |bytes: i64| { format!( " {} would hold it, at {} {cadence}.", gib_of(bytes), money(sync.quote_cents(bytes, annual)) ) }; // Only offer a bigger cap when there is one, and when it is actually bigger // than what they have. At the ceiling the honest answer is that raising the // cap is not the remedy. let bigger = sync .synced_library_bytes() .map(|need| proposed_cap(Some(need), pricing)) .filter(|proposed| *proposed > sub.limit_bytes) .map_or_else(String::new, suggestion); Some(if sub.used_bytes >= sub.limit_bytes { format!( "Your storage cap is full. New sample files are not uploading; \ everything else still syncs.{bigger}" ) } else { format!( "You are close to your storage cap. When it fills, new sample files \ stop uploading and everything else keeps syncing.{bigger}" ) }) } /// No subscription yet: the screen proposes a cap and says what it costs. /// /// The redesign of 2026-08-21, and what it turns on is that **the app already /// knows the answer it used to ask for**. `synced_library_bytes` is the exact /// size of what blob sync would upload, available locally and instantly, so a /// control that opened on an unfilled number was soliciting a guess at a /// question it could compute. /// /// So: state the need, propose a cap with headroom, and show every alternative /// with its price attached. The user confirms or nudges one number instead of /// exploring three orders of magnitude, which is what a slider from 250 GiB to /// 10 TiB asked them to do and what nobody ever did. fn on_offer(sync: &dyn Sync, pricing: &super::Pricing) -> Node { let need = sync.synced_library_bytes(); let proposed = proposed_cap(need, pricing); let mut slot = Slot::new("subscription", RegionKind::Pane); // The need, first, because it is the reason the rest of the screen says what // it says. `None` is "cannot look"; `Some(0)` is a real and different answer. slot = slot.with(Node::text(match need { Some(0) => "No vault is set to sync sample files yet, so nothing would upload today. \ Turn on file sync for a vault to change that." .to_owned(), Some(bytes) => format!( "Your synced vaults hold {}. That is what would upload.", gib_of_exact(bytes) ), None => "Pick a storage cap for audio file sync.".to_owned(), })); if need.is_some_and(|bytes| bytes > 0) { slot = slot.with(Node::text(format!( "Proposed: {}, which leaves room to grow.", gib_of(proposed) ))); } slot = slot.with(Node::text( "Annual is two months free: fewer Stripe fees, and we pass the savings on.", )); Node::Region( slot.with(Node::Form { fields: vec![ cap_choice(sync, pricing, proposed), Field::radio( "cadence", "Billing", vec![ Choice::new("annual", "Annual"), Choice::new("monthly", "Monthly"), ], ) .value("annual"), ], submit: "Subscribe".to_owned(), action: Action::post("/sync/subscribe"), }) .with(exact_cap_form(pricing, proposed, "/sync/subscribe")), ) } /// The cap as a few named sizes, each carrying what it costs. /// /// A [`Field::radio`] rather than a select, and that is the whole point of the /// control: the prices have to be *visible* without interacting, because the /// decision being made is a spending decision and the enforced quantity is /// bytes. A dropdown hides five of the six prices behind a click. /// /// Both cadences are on every label, so no label goes stale when the cadence /// field changes underneath it. That replaces the old hint - "prices shown are /// for the smallest cap; the exact figure is on the checkout page" - which was /// a form apologising for not being able to say what it charged. fn cap_choice(sync: &dyn Sync, pricing: &super::Pricing, proposed: i64) -> Field { // The selected cap is always among the options, even when it is not one of // the named sizes. A user who typed an exact figure, or who is on a cap from // before this list existed, must see their own cap selected rather than a // group with nothing chosen - which is what a radio says when its value // matches no option, and it reads as "you have not chosen" to someone who // has. let mut sizes: Vec = offered_caps(pricing).collect(); if !sizes.contains(&proposed) { sizes.push(proposed); sizes.sort_unstable(); } let options = sizes .into_iter() .map(|bytes| { Choice::new( gib_count(bytes).to_string(), format!( "{} - {} a month, or {} a year", gib_of(bytes), money(sync.quote_cents(bytes, false)), money(sync.quote_cents(bytes, true)) ), ) }) .collect(); Field::radio(CAP, "Storage cap", options).value(gib_count(proposed).to_string()) } /// The exact-figure entry, as its own form. /// /// Two forms rather than one, because they are two acts. Picking a named size is /// confirming a proposal; typing a number is overriding it, and a form carries /// one submit and one action, so a single form offering both would have two /// controls competing to answer one value. /// /// `min` and `max` are set here, which is what the old `cap_field` claimed in its /// doc comment and did not do: it was a bare number with no bounds, and the only /// thing that rejected an out-of-range cap was `cap_from`, after submit, with /// "that cap is not on offer". fn exact_cap_form(pricing: &super::Pricing, proposed: i64, action: &str) -> Node { let field = Field { min: Some(gib_count(pricing.min_bytes).to_string()), max: Some(gib_count(pricing.max_bytes).to_string()), unit: Some("GiB".to_owned()), ..Field::new(FieldKind::Number, CAP, "Storage cap") } .value(gib_count(proposed).to_string()) .required() .hint(format!( "Anything from {} to {}.", gib_of(pricing.min_bytes), gib_of(pricing.max_bytes) )); Node::Region( Slot::new("exact-cap", RegionKind::Pane) .with(Node::text("Or set an exact cap.")) .with(Node::Form { fields: vec![field], submit: "Use this cap".to_owned(), action: Action::post(action), }), ) } /// The named caps this pricing actually permits, smallest first. /// /// Both screens read the same list from [`crate::storage_cap`]; a cap the egui /// panel offered and this one did not would be two products. fn offered_caps(pricing: &super::Pricing) -> impl Iterator { crate::storage_cap::offered(pricing.min_bytes, pricing.max_bytes) } /// The cap to propose, sized to what would actually upload. fn proposed_cap(need: Option, pricing: &super::Pricing) -> i64 { crate::storage_cap::proposed(need, pricing.min_bytes, pricing.max_bytes) } /// A byte count as whole GiB, for a person. fn gib_count(bytes: i64) -> u32 { u32::try_from(bytes / GIB).unwrap_or(u32::MAX) } /// A byte count as a size a person reads, keeping one decimal below a TiB. /// /// Distinct from [`gib_of`], which spells a *cap* - always a whole number of /// GiB, because that is what a cap is. This spells a measurement, where /// rounding 180.4 GiB to "180 GiB" is fine but rounding 0.4 GiB to "0 GiB" /// would tell a user with a small library that they have nothing. fn gib_of_exact(bytes: i64) -> String { #[expect( clippy::cast_precision_loss, reason = "a library size in GiB is far inside f64's exact integer range" )] let gib = bytes as f64 / GIB as f64; if gib >= 1024.0 { format!("{:.1} TiB", gib / 1024.0) } else if gib >= 10.0 { format!("{gib:.0} GiB") } else { format!("{gib:.1} GiB") } } /// A byte count as a cap, spelled the way the shipped panel spells it. fn gib_of(bytes: i64) -> String { let gib = bytes / GIB; if gib >= 1024 { #[expect( clippy::cast_precision_loss, reason = "a cap in TiB is small enough that f64 is exact here" )] let tib = gib as f64 / 1024.0; format!("{tib:.1} TiB") } else { format!("{gib} GiB") } } /// Cents as money, the way the shipped panel writes it. fn money(cents: i64) -> String { let dollars = cents / 100; let pennies = cents % 100; if pennies == 0 { format!("${dollars}") } else { format!("${dollars}.{pennies:02}") } }