max / quasi
11 files changed,
+775 insertions,
-40 deletions
| @@ -671,11 +671,17 @@ | |||
| 671 | 671 | Some(0) | None => act.label.clone(), | |
| 672 | 672 | Some(chosen) => format!("{} ({chosen})", act.label), | |
| 673 | 673 | }; | |
| 674 | + | // A control that has been pressed and not answered yet is drawn as what it | |
| 675 | + | // is: working, and not taking another press. The label is untouched, so the | |
| 676 | + | // control does not change width the moment it is pressed, and disabled is | |
| 677 | + | // the guard as well as the saying here -- a disabled widget reports no | |
| 678 | + | // click, so the second press does not exist rather than being discarded. | |
| 679 | + | let busy = pass.view.busy(&act.action); | |
| 674 | 680 | let described = layout::Act { | |
| 675 | 681 | label: &label, | |
| 676 | 682 | key: act.key.as_deref(), | |
| 677 | 683 | tone: act.tone, | |
| 678 | - | state: if chosen == Some(0) { | |
| 684 | + | state: if chosen == Some(0) || busy { | |
| 679 | 685 | Some(layout::State::Disabled) | |
| 680 | 686 | } else { | |
| 681 | 687 | act.state | |
| @@ -688,7 +694,7 @@ | |||
| 688 | 694 | &pass.immediate.palette, | |
| 689 | 695 | &pass.immediate.widget, | |
| 690 | 696 | ); | |
| 691 | - | if pressed.clicked() && chosen != Some(0) { | |
| 697 | + | if pressed.clicked() && chosen != Some(0) && !busy { | |
| 692 | 698 | let payload = over.map_or_else(Params::new, |under| pass.view.gathering(under)); | |
| 693 | 699 | pass.fire(&act.action, payload, act.confirm.as_deref()); | |
| 694 | 700 | } |
| @@ -52,6 +52,14 @@ | |||
| 52 | 52 | view: View, | |
| 53 | 53 | /// What the app offers from every screen. | |
| 54 | 54 | chrome: Chrome, | |
| 55 | + | /// The awaiting call this runtime has dispatched and not been answered | |
| 56 | + | /// about, with the request that went out. | |
| 57 | + | /// | |
| 58 | + | /// `d8d6f380`. Only an action carrying [`Action::awaiting`] lands here. The | |
| 59 | + | /// pair rather than the action alone, because the answer that clears it is | |
| 60 | + | /// the answer to that request: anything else arriving first leaves the | |
| 61 | + | /// control waiting, which is what it is doing. | |
| 62 | + | outstanding: Option<(Action, Request)>, | |
| 55 | 63 | /// The layers this one is drawn over, outermost first. | |
| 56 | 64 | /// | |
| 57 | 65 | /// Separate from `history` on purpose: an overlay is not a place. Opening | |
| @@ -80,6 +88,7 @@ | |||
| 80 | 88 | history: Vec::new(), | |
| 81 | 89 | here: None, | |
| 82 | 90 | asked: None, | |
| 91 | + | outstanding: None, | |
| 83 | 92 | saying: None, | |
| 84 | 93 | }; | |
| 85 | 94 | runtime.view.seed(&runtime.screen); | |
| @@ -132,7 +141,7 @@ | |||
| 132 | 141 | /// widgets below have not been laid out yet. | |
| 133 | 142 | pub fn show(&mut self, ui: &mut Ui, immediate: &Immediate) -> Step { | |
| 134 | 143 | if let Some(binding) = self.pressed_binding(ui.ctx()) { | |
| 135 | - | return Self::call(&binding); | |
| 144 | + | return self.call(&binding); | |
| 136 | 145 | } | |
| 137 | 146 | ||
| 138 | 147 | // What is under it first, then this one over the top. An `Area` is what | |
| @@ -181,7 +190,7 @@ | |||
| 181 | 190 | self.asked = Some((action, payload)); | |
| 182 | 191 | Step::Ask(prompt) | |
| 183 | 192 | } | |
| 184 | - | None => Self::send(&action, payload), | |
| 193 | + | None => self.send(&action, payload), | |
| 185 | 194 | }, | |
| 186 | 195 | None => Step::Idle, | |
| 187 | 196 | } | |
| @@ -206,7 +215,7 @@ | |||
| 206 | 215 | /// only ever raised by something destructive. | |
| 207 | 216 | pub fn answer(&mut self, yes: bool) -> Step { | |
| 208 | 217 | match self.asked.take() { | |
| 209 | - | Some((action, payload)) if yes => Self::send(&action, payload), | |
| 218 | + | Some((action, payload)) if yes => self.send(&action, payload), | |
| 210 | 219 | _ => Step::Idle, | |
| 211 | 220 | } | |
| 212 | 221 | } | |
| @@ -303,6 +312,16 @@ | |||
| 303 | 312 | } = response; | |
| 304 | 313 | self.saying = notice.or(self.saying.take()); | |
| 305 | 314 | ||
| 315 | + | // Whatever was outstanding has been answered. | |
| 316 | + | if self | |
| 317 | + | .outstanding | |
| 318 | + | .as_ref() | |
| 319 | + | .is_some_and(|(_, sent)| sent == request) | |
| 320 | + | { | |
| 321 | + | self.outstanding = None; | |
| 322 | + | self.view.await_on(None); | |
| 323 | + | } | |
| 324 | + | ||
| 306 | 325 | match outcome { | |
| 307 | 326 | // Over what is already there. The layer underneath is put away | |
| 308 | 327 | // whole and comes back untouched when the overlay is dismissed. | |
| @@ -416,24 +435,79 @@ | |||
| 416 | 435 | /// The action's own params ride with whatever the screen gathered, which is | |
| 417 | 436 | /// what `absorb` is for: a control that names a value and a selection that | |
| 418 | 437 | /// names members both belong in one payload. | |
| 419 | - | fn send(action: &Action, extra: Params) -> Step { | |
| 420 | - | let Some(path) = action.destination.route() else { | |
| 438 | + | fn send(&mut self, action: &Action, extra: Params) -> Step { | |
| 439 | + | if action.destination.route().is_none() { | |
| 421 | 440 | return Step::Open(action.destination.as_str().to_string()); | |
| 422 | - | }; | |
| 441 | + | } | |
| 423 | 442 | let mut payload = extra; | |
| 424 | 443 | payload.absorb(action.params.clone()); | |
| 425 | - | Step::Call(Request { | |
| 444 | + | let Some(request) = Self::request_for(action).map(|request| Request { payload, ..request }) | |
| 445 | + | else { | |
| 446 | + | return Step::Idle; | |
| 447 | + | }; | |
| 448 | + | ||
| 449 | + | // An awaiting call locks the control that made it. egui redraws from | |
| 450 | + | // this state every frame, so recording it here is the whole of the | |
| 451 | + | // guard: `act_node` draws a disabled control, and a disabled control | |
| 452 | + | // reports no click. | |
| 453 | + | if action.awaits() { | |
| 454 | + | if self | |
| 455 | + | .outstanding | |
| 456 | + | .as_ref() | |
| 457 | + | .is_some_and(|(_, sent)| *sent == request) | |
| 458 | + | { | |
| 459 | + | return Step::Idle; | |
| 460 | + | } | |
| 461 | + | self.outstanding = Some((action.clone(), request.clone())); | |
| 462 | + | self.view.await_on(Some(action.clone())); | |
| 463 | + | } | |
| 464 | + | Step::Call(request) | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | /// A read of a route, for a binding that names one. | |
| 468 | + | fn call(&mut self, action: &Action) -> Step { | |
| 469 | + | self.send(action, Params::new()) | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | /// The request an action makes, or nothing when it names somewhere outside | |
| 473 | + | /// the app. | |
| 474 | + | fn request_for(action: &Action) -> Option<Request> { | |
| 475 | + | let path = action.destination.route()?; | |
| 476 | + | Some(Request { | |
| 426 | 477 | method: action.method, | |
| 427 | 478 | path: path.to_string(), | |
| 428 | 479 | captures: Params::new(), | |
| 429 | - | payload, | |
| 480 | + | payload: action.params.clone(), | |
| 430 | 481 | carried: action.carried.clone(), | |
| 431 | 482 | }) | |
| 432 | 483 | } | |
| 433 | 484 | ||
| 434 | - | /// A read of a route, for a binding that names one. | |
| 435 | - | fn call(action: &Action) -> Step { | |
| 436 | - | Self::send(action, Params::new()) | |
| 485 | + | /// The calls this screen's regions are waiting on, for the host to perform. | |
| 486 | + | /// | |
| 487 | + | /// The counterpart of the browser's per-region trigger. A host asks for | |
| 488 | + | /// these after putting a screen up, hands each answer back to | |
| 489 | + | /// [`apply`](Self::apply), and the region fills where its spinner was. Ask | |
| 490 | + | /// again after applying one rather than keeping the list: a fragment landing | |
| 491 | + | /// clears that region's feed. | |
| 492 | + | #[must_use] | |
| 493 | + | pub fn feeds(&self) -> Vec<Request> { | |
| 494 | + | self.screen | |
| 495 | + | .feeds() | |
| 496 | + | .into_iter() | |
| 497 | + | .filter_map(Self::request_for) | |
| 498 | + | .collect() | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | /// What the control that was pressed is waiting on, if one is. | |
| 502 | + | /// | |
| 503 | + | /// Carries the amount when the description measured one. Nothing here turns | |
| 504 | + | /// it into a time: a bar shows what is done over what there is and how long | |
| 505 | + | /// it has taken, and predicts nothing. | |
| 506 | + | #[must_use] | |
| 507 | + | pub fn awaiting(&self) -> Option<quasi_router::layout::Awaiting> { | |
| 508 | + | self.outstanding | |
| 509 | + | .as_ref() | |
| 510 | + | .and_then(|(action, _)| action.awaiting) | |
| 437 | 511 | } | |
| 438 | 512 | } | |
| 439 | 513 |
| @@ -1387,3 +1387,65 @@ | |||
| 1387 | 1387 | let fired = host.click(&screen, "snare.wav").expect("the row answers"); | |
| 1388 | 1388 | assert_eq!(fired.action, Action::post("/files/2/open")); | |
| 1389 | 1389 | } | |
| 1390 | + | ||
| 1391 | + | #[test] | |
| 1392 | + | fn a_region_fed_by_a_call_is_asked_for_and_then_stops_asking() { | |
| 1393 | + | // `d8d6f380`. egui has no browser under it either, so the host performs the | |
| 1394 | + | // region's call the way it performs every other one. | |
| 1395 | + | let mut runtime = Runtime::new(Screen::sidebar_content("Payments").with( | |
| 1396 | + | Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts").awaiting()), | |
| 1397 | + | )); | |
| 1398 | + | ||
| 1399 | + | let feeds = runtime.feeds(); | |
| 1400 | + | assert_eq!(feeds.len(), 1); | |
| 1401 | + | assert_eq!(feeds[0].path, "/dashboard/payouts"); | |
| 1402 | + | assert_eq!(feeds[0].method, Method::Get); | |
| 1403 | + | ||
| 1404 | + | runtime.apply( | |
| 1405 | + | &feeds[0].clone(), | |
| 1406 | + | Response { | |
| 1407 | + | outcome: Outcome::Fragment { | |
| 1408 | + | region: "payouts".to_owned(), | |
| 1409 | + | node: Node::text("$12.00"), | |
| 1410 | + | }, | |
| 1411 | + | notice: None, | |
| 1412 | + | address: None, | |
| 1413 | + | invalidates: Vec::new(), | |
| 1414 | + | }, | |
| 1415 | + | ); | |
| 1416 | + | assert!( | |
| 1417 | + | runtime.feeds().is_empty(), | |
| 1418 | + | "a region that has been filled asks again" | |
| 1419 | + | ); | |
| 1420 | + | } | |
| 1421 | + | ||
| 1422 | + | #[test] | |
| 1423 | + | fn the_control_being_waited_on_is_the_one_that_is_busy() { | |
| 1424 | + | // What `act_node` reads to disable the control that was pressed, and the | |
| 1425 | + | // whole guard with it: a disabled widget reports no click. | |
| 1426 | + | let mut view = View::new(); | |
| 1427 | + | let buying = Action::post("/checkout").awaiting(); | |
| 1428 | + | assert!(!view.busy(&buying)); | |
| 1429 | + | ||
| 1430 | + | view.await_on(Some(buying.clone())); | |
| 1431 | + | assert!(view.busy(&buying)); | |
| 1432 | + | // One control, not the app: everything else on the screen still answers. | |
| 1433 | + | assert!(!view.busy(&Action::post("/cancel"))); | |
| 1434 | + | ||
| 1435 | + | // A screen arriving is the answer, or is somewhere else entirely. | |
| 1436 | + | view.reset(); | |
| 1437 | + | assert!(!view.busy(&buying)); | |
| 1438 | + | } | |
| 1439 | + | ||
| 1440 | + | #[test] | |
| 1441 | + | fn a_screen_waiting_on_a_control_still_draws() { | |
| 1442 | + | // One frame with an outstanding control, which is the state every frame | |
| 1443 | + | // between the press and the answer is in. | |
| 1444 | + | let screen = screen_of([Node::Act(Act::new( | |
| 1445 | + | "Buy", | |
| 1446 | + | Action::post("/checkout").awaiting(), | |
| 1447 | + | ))]); | |
| 1448 | + | let mut view = View::new(); | |
| 1449 | + | view.await_on(Some(Action::post("/checkout").awaiting())); | |
| 1450 | + | draw(&screen, &mut view); | |
| 1451 | + | } |
| @@ -24,7 +24,7 @@ | |||
| 24 | 24 | ||
| 25 | 25 | use std::time::Instant; | |
| 26 | 26 | ||
| 27 | - | use quasi_router::{Node, Params, Screen}; | |
| 27 | + | use quasi_router::{Action, Node, Params, Screen}; | |
| 28 | 28 | ||
| 29 | 29 | /// What the user has done to a screen since it arrived. | |
| 30 | 30 | /// | |
| @@ -51,6 +51,15 @@ | |||
| 51 | 51 | /// Here rather than in `Pass` because a `Pass` is one frame and the wait | |
| 52 | 52 | /// spans many. It is the same reason `edits` lives here. | |
| 53 | 53 | awaiting: BTreeMap<String, Instant>, | |
| 54 | + | /// The action this screen is waiting on, when one is outstanding. | |
| 55 | + | /// | |
| 56 | + | /// `d8d6f380`. Only an action carrying [`Action::awaiting`] lands here: the | |
| 57 | + | /// description says which calls are worth locking a control for, and this | |
| 58 | + | /// renderer does not decide that a route is slow. Beside `awaiting` above | |
| 59 | + | /// and not folded into it, which is the distinction the ruling turns on: a | |
| 60 | + | /// consult is a wait this renderer chose to impose, and this is a wait the | |
| 61 | + | /// description declared. | |
| 62 | + | outstanding: Option<Action>, | |
| 54 | 63 | } | |
| 55 | 64 | ||
| 56 | 65 | impl View { | |
| @@ -111,6 +120,29 @@ | |||
| 111 | 120 | self.awaiting.remove(name); | |
| 112 | 121 | } | |
| 113 | 122 | ||
| 123 | + | /// The action this view is waiting on. | |
| 124 | + | #[must_use] | |
| 125 | + | pub const fn outstanding(&self) -> Option<&Action> { | |
| 126 | + | self.outstanding.as_ref() | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// Say that this action is outstanding, or that nothing is. | |
| 130 | + | /// | |
| 131 | + | /// The runtime's to set, from what the description marked as awaiting. | |
| 132 | + | pub(crate) fn await_on(&mut self, action: Option<Action>) { | |
| 133 | + | self.outstanding = action; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// Whether this is the control that was pressed and has not been answered. | |
| 137 | + | /// | |
| 138 | + | /// What the drawing consults to disable it, which in an immediate-mode | |
| 139 | + | /// renderer is also the whole of the guard: a disabled control reports no | |
| 140 | + | /// click, so the second press does not exist rather than being discarded. | |
| 141 | + | #[must_use] | |
| 142 | + | pub fn busy(&self, action: &Action) -> bool { | |
| 143 | + | self.outstanding.as_ref() == Some(action) | |
| 144 | + | } | |
| 145 | + | ||
| 114 | 146 | /// Whether a row's value is in the screen's selection. | |
| 115 | 147 | #[must_use] | |
| 116 | 148 | pub fn is_ticked(&self, value: &str) -> bool { | |
| @@ -157,6 +189,9 @@ | |||
| 157 | 189 | self.edits.clear(); | |
| 158 | 190 | self.ticked.clear(); | |
| 159 | 191 | self.awaiting.clear(); | |
| 192 | + | // A screen that has arrived is the answer to whatever was outstanding, | |
| 193 | + | // or is somewhere else entirely. | |
| 194 | + | self.outstanding = None; | |
| 160 | 195 | } | |
| 161 | 196 | ||
| 162 | 197 | /// The values a form submits, by the names it declared. |
| @@ -192,6 +192,24 @@ | |||
| 192 | 192 | /// be performed and then handed to the reader. Both are the same sentence | |
| 193 | 193 | /// here and differ only in the emitting. | |
| 194 | 194 | pub saves: Option<String>, | |
| 195 | + | /// That this call waits on something which resolves once, in expected | |
| 196 | + | /// finite time. | |
| 197 | + | /// | |
| 198 | + | /// `d8d6f380`, ruled 2026-08-18. The mark itself is | |
| 199 | + | /// [`layout::Awaiting`] and its docs carry the whole of what is described | |
| 200 | + | /// and what is not. It rides on the action rather than on the control | |
| 201 | + | /// because the wait is a fact about the call, and because the same call is | |
| 202 | + | /// what a region is fed by: one mark, read twice. | |
| 203 | + | /// | |
| 204 | + | /// A control carrying one goes busy when it is pressed and refuses a second | |
| 205 | + | /// press until the answer lands, which is the double-submit guard the MNW | |
| 206 | + | /// server writes by hand twice against 57 spinners. A region carrying one | |
| 207 | + | /// through [`Slot::fed_by`] stands in and fills. | |
| 208 | + | /// | |
| 209 | + | /// Not remoteness, which [`Destination`] would already answer and which | |
| 210 | + | /// misses a heavy local query. Not slowness, which is a judgement. What it | |
| 211 | + | /// says is that something is outstanding and will finish. | |
| 212 | + | pub awaiting: Option<layout::Awaiting>, | |
| 195 | 213 | } | |
| 196 | 214 | ||
| 197 | 215 | impl Action { | |
| @@ -204,6 +222,7 @@ | |||
| 204 | 222 | carried: Params::new(), | |
| 205 | 223 | saves: None, | |
| 206 | 224 | replaces: None, | |
| 225 | + | awaiting: None, | |
| 207 | 226 | } | |
| 208 | 227 | } | |
| 209 | 228 | ||
| @@ -216,6 +235,7 @@ | |||
| 216 | 235 | carried: Params::new(), | |
| 217 | 236 | saves: None, | |
| 218 | 237 | replaces: None, | |
| 238 | + | awaiting: None, | |
| 219 | 239 | } | |
| 220 | 240 | } | |
| 221 | 241 | ||
| @@ -233,6 +253,7 @@ | |||
| 233 | 253 | carried: Params::new(), | |
| 234 | 254 | saves: None, | |
| 235 | 255 | replaces: None, | |
| 256 | + | awaiting: None, | |
| 236 | 257 | } | |
| 237 | 258 | } | |
| 238 | 259 | ||
| @@ -245,6 +266,7 @@ | |||
| 245 | 266 | carried: Params::new(), | |
| 246 | 267 | saves: None, | |
| 247 | 268 | replaces: None, | |
| 269 | + | awaiting: None, | |
| 248 | 270 | } | |
| 249 | 271 | } | |
| 250 | 272 | ||
| @@ -260,6 +282,7 @@ | |||
| 260 | 282 | carried: Params::new(), | |
| 261 | 283 | saves: None, | |
| 262 | 284 | replaces: None, | |
| 285 | + | awaiting: None, | |
| 263 | 286 | } | |
| 264 | 287 | } | |
| 265 | 288 | ||
| @@ -280,6 +303,36 @@ | |||
| 280 | 303 | self | |
| 281 | 304 | } | |
| 282 | 305 | ||
| 306 | + | /// Say that this call waits on something which resolves once, with nothing | |
| 307 | + | /// countable about the wait. | |
| 308 | + | /// | |
| 309 | + | /// The common case: a round trip to a payment provider, a report the server | |
| 310 | + | /// assembles, a heavy local query. The renderer draws it indeterminate, | |
| 311 | + | /// because manufacturing a figure for it is the prediction | |
| 312 | + | /// [`layout::Awaiting`] refuses. | |
| 313 | + | #[must_use] | |
| 314 | + | pub const fn awaiting(mut self) -> Self { | |
| 315 | + | self.awaiting = Some(layout::Awaiting::unmeasured()); | |
| 316 | + | self | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | /// Say that it waits, and how much there is to get through. | |
| 320 | + | /// | |
| 321 | + | /// Only with a measured figure. An upload knows its file length; nothing | |
| 322 | + | /// else here may guess one, since a renderer cannot tell a measurement from | |
| 323 | + | /// an estimate once it is written down. | |
| 324 | + | #[must_use] | |
| 325 | + | pub const fn awaiting_amount(mut self, amount: u64) -> Self { | |
| 326 | + | self.awaiting = Some(layout::Awaiting::of(amount)); | |
| 327 | + | self | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | /// Whether this call waits on something. | |
| 331 | + | #[must_use] | |
| 332 | + | pub const fn awaits(&self) -> bool { | |
| 333 | + | self.awaiting.is_some() | |
| 334 | + | } | |
| 335 | + | ||
| 283 | 336 | /// The route this calls, if it calls one. | |
| 284 | 337 | #[must_use] | |
| 285 | 338 | pub fn route(&self) -> Option<&str> { | |
| @@ -1720,6 +1773,28 @@ | |||
| 1720 | 1773 | /// have nowhere to put one, which is correct rather than a gap — a frame has | |
| 1721 | 1774 | /// a caption, not a tab name. | |
| 1722 | 1775 | pub label: Option<String>, | |
| 1776 | + | /// The call that fills this region, when the region's content is not here | |
| 1777 | + | /// yet. | |
| 1778 | + | /// | |
| 1779 | + | /// `d8d6f380`. The region half of [`layout::Awaiting`]: a screen that is | |
| 1780 | + | /// mostly local reads plus one slow part says so here instead of being | |
| 1781 | + | /// hand-split into a second route, which is what MNW's user dashboard does | |
| 1782 | + | /// with its payout summary because that one tab calls a payment provider | |
| 1783 | + | /// and the rest of it reads the database. | |
| 1784 | + | /// | |
| 1785 | + | /// [`readiness`](Self::readiness) is [`Pending`](layout::Readiness::Pending) | |
| 1786 | + | /// while this is set, and [`Screen::replace`] clears it when the answer | |
| 1787 | + | /// lands, so a retained-screen host cannot ask twice for one region. | |
| 1788 | + | /// | |
| 1789 | + | /// The wait's size, if it has one, is on the action rather than here. A | |
| 1790 | + | /// region is fed by a call and the call is what knows. | |
| 1791 | + | /// | |
| 1792 | + | /// Boxed, which is the one place in this file that is: [`Node::Region`] | |
| 1793 | + | /// holds a [`Slot`] by value, so every node in every tree would carry an | |
| 1794 | + | /// [`Action`]'s width for a field almost no region sets. Reach for | |
| 1795 | + | /// [`fed_by`](Self::fed_by) and [`awaiting`](Self::awaiting) rather than the | |
| 1796 | + | /// field, and the box is not something a caller has to think about. | |
| 1797 | + | pub fed_by: Option<Box<Action>>, | |
| 1723 | 1798 | } | |
| 1724 | 1799 | ||
| 1725 | 1800 | impl Slot { | |
| @@ -1733,6 +1808,7 @@ | |||
| 1733 | 1808 | showing: layout::Showing::All, | |
| 1734 | 1809 | shown: None, | |
| 1735 | 1810 | label: None, | |
| 1811 | + | fed_by: None, | |
| 1736 | 1812 | } | |
| 1737 | 1813 | } | |
| 1738 | 1814 | ||
| @@ -1803,6 +1879,33 @@ | |||
| 1803 | 1879 | self | |
| 1804 | 1880 | } | |
| 1805 | 1881 | ||
| 1882 | + | /// The content arrives from this call rather than with the screen. | |
| 1883 | + | /// | |
| 1884 | + | /// Sets [`readiness`](Self::readiness) to | |
| 1885 | + | /// [`Pending`](layout::Readiness::Pending) in the same breath, because a | |
| 1886 | + | /// region that says where its content is coming from is by construction a | |
| 1887 | + | /// region that does not have it yet, and the two disagreeing is a state no | |
| 1888 | + | /// renderer could draw honestly. | |
| 1889 | + | /// | |
| 1890 | + | /// Mark the action [`awaiting`](Action::awaiting) unless there is a reason | |
| 1891 | + | /// not to. Without the mark this is still a deferred load and every renderer | |
| 1892 | + | /// still fetches; what is lost is the size of the wait, so the stand-in has | |
| 1893 | + | /// no proportion to draw. | |
| 1894 | + | #[must_use] | |
| 1895 | + | pub fn fed_by(mut self, action: Action) -> Self { | |
| 1896 | + | self.readiness = layout::Readiness::Pending; | |
| 1897 | + | self.fed_by = Some(Box::new(action)); | |
| 1898 | + | self | |
| 1899 | + | } | |
| 1900 | + | ||
| 1901 | + | /// What this region is waiting on, when it is waiting on something. | |
| 1902 | + | /// | |
| 1903 | + | /// Asked once here rather than reached through the action in each renderer. | |
| 1904 | + | #[must_use] | |
| 1905 | + | pub fn awaiting(&self) -> Option<layout::Awaiting> { | |
| 1906 | + | self.fed_by.as_ref().and_then(|action| action.awaiting) | |
| 1907 | + | } | |
| 1908 | + | ||
| 1806 | 1909 | /// Show one child at a time, starting at this one. | |
| 1807 | 1910 | /// | |
| 1808 | 1911 | /// The carousel and the tab group, which are one thing said twice: whether | |
| @@ -1913,6 +2016,25 @@ | |||
| 1913 | 2016 | /// generic over mutability: a `&mut` borrow of `self` cannot be handed to | |
| 1914 | 2017 | /// the recursive call and kept, which is what `find_map` does on the shared | |
| 1915 | 2018 | /// side. | |
| 2019 | + | /// Every call this region and the regions inside it are waiting on, in draw | |
| 2020 | + | /// order. | |
| 2021 | + | /// | |
| 2022 | + | /// Here rather than in each renderer because it is a walk over this crate's | |
| 2023 | + | /// own tree, and two hosts writing it separately is two answers to "which | |
| 2024 | + | /// regions have not arrived". A webview needs none of it: the markup carries | |
| 2025 | + | /// a trigger per region and the browser does the walk. Every host that | |
| 2026 | + | /// retains the description rather than the markup does need it. | |
| 2027 | + | fn feeds_into<'a>(&'a self, out: &mut Vec<&'a Action>) { | |
| 2028 | + | if let Some(action) = &self.fed_by { | |
| 2029 | + | out.push(action); | |
| 2030 | + | } | |
| 2031 | + | for placed in &self.body { | |
| 2032 | + | if let Node::Region(slot) = &placed.node { | |
| 2033 | + | slot.feeds_into(out); | |
| 2034 | + | } | |
| 2035 | + | } | |
| 2036 | + | } | |
| 2037 | + | ||
| 1916 | 2038 | fn find_mut(&mut self, id: &str) -> Option<&mut Self> { | |
| 1917 | 2039 | if self.id == id { | |
| 1918 | 2040 | return Some(self); | |
| @@ -3734,9 +3856,28 @@ | |||
| 3734 | 3856 | /// **The region becomes [`Ready`](layout::Readiness::Ready).** A fragment | |
| 3735 | 3857 | /// arriving is the content arriving, so a slot marked | |
| 3736 | 3858 | /// [`Pending`](layout::Readiness::Pending) while it was in flight stops | |
| 3737 | - | /// being pending here. Emptiness is a different axis and rides on the node: | |
| 3859 | + | /// being pending here, and a [`Slot::fed_by`] naming the call that just | |
| 3860 | + | /// answered is cleared with it. Emptiness is a different axis and rides on the node: | |
| 3738 | 3861 | /// a [`Node::StandIn`] carries its own state, and replacing with one is a | |
| 3739 | 3862 | /// region that is ready and has nothing to show. | |
| 3863 | + | /// Every call this screen's regions are waiting on, in draw order. | |
| 3864 | + | /// | |
| 3865 | + | /// What a host performs after putting a screen up: each answer comes back as | |
| 3866 | + | /// a [`Response::Fragment`] naming the region, and [`replace`](Self::replace) | |
| 3867 | + | /// clears the feed as it lands, so asking again after applying one is | |
| 3868 | + | /// answered with what is still outstanding rather than with the same list. | |
| 3869 | + | /// | |
| 3870 | + | /// Empty for every screen that has all of its content, which is nearly all | |
| 3871 | + | /// of them. | |
| 3872 | + | #[must_use] | |
| 3873 | + | pub fn feeds(&self) -> Vec<&Action> { | |
| 3874 | + | let mut out = Vec::new(); | |
| 3875 | + | for slot in &self.slots { | |
| 3876 | + | slot.feeds_into(&mut out); | |
| 3877 | + | } | |
| 3878 | + | out | |
| 3879 | + | } | |
| 3880 | + | ||
| 3740 | 3881 | pub fn replace(&mut self, region: &str, node: Node) -> bool { | |
| 3741 | 3882 | let Some(slot) = self.slots.iter_mut().find_map(|slot| slot.find_mut(region)) else { | |
| 3742 | 3883 | return false; | |
| @@ -3744,6 +3885,10 @@ | |||
| 3744 | 3885 | slot.body.clear(); | |
| 3745 | 3886 | slot.body.push(Ranked::new(node)); | |
| 3746 | 3887 | slot.readiness = layout::Readiness::Ready; | |
| 3888 | + | // The call that fed it has answered, so the region stops naming one. A | |
| 3889 | + | // retained-screen host redraws from this tree, and a region still | |
| 3890 | + | // pointing at its feed would ask again on the next paint. | |
| 3891 | + | slot.fed_by = None; | |
| 3747 | 3892 | true | |
| 3748 | 3893 | } | |
| 3749 | 3894 | } | |
| @@ -3929,4 +4074,56 @@ | |||
| 3929 | 4074 | ); | |
| 3930 | 4075 | assert!(CUTOFFS.is_sorted()); | |
| 3931 | 4076 | } | |
| 4077 | + | ||
| 4078 | + | #[test] | |
| 4079 | + | fn a_region_fed_by_a_call_is_pending_until_the_answer_lands() { | |
| 4080 | + | // `d8d6f380`. The two facts move together: a region that says where its | |
| 4081 | + | // content is coming from does not have it, and a region that has been | |
| 4082 | + | // filled is no longer asking. | |
| 4083 | + | let mut screen = Screen::list_detail("Payments", false).with( | |
| 4084 | + | Slot::new("payouts", RegionKind::Pane) | |
| 4085 | + | .fed_by(Action::get("/dashboard/payouts").awaiting()), | |
| 4086 | + | ); | |
| 4087 | + | assert_eq!(screen.feeds().len(), 1); | |
| 4088 | + | assert_eq!( | |
| 4089 | + | screen.slots[0].readiness, | |
| 4090 | + | layout::Readiness::Pending, | |
| 4091 | + | "a fed region has not arrived" | |
| 4092 | + | ); | |
| 4093 | + | ||
| 4094 | + | assert!(screen.replace("payouts", Node::text("paid out"))); | |
| 4095 | + | assert_eq!(screen.slots[0].readiness, layout::Readiness::Ready); | |
| 4096 | + | assert!( | |
| 4097 | + | screen.feeds().is_empty(), | |
| 4098 | + | "a filled region asks for itself again" | |
| 4099 | + | ); | |
| 4100 | + | } | |
| 4101 | + | ||
| 4102 | + | #[test] | |
| 4103 | + | fn the_wait_is_measured_only_where_something_measured_it() { | |
| 4104 | + | // The ruling on `5fa96a82`: an amount is a fact about the payload, and | |
| 4105 | + | // a call with nothing countable about it says nothing rather than | |
| 4106 | + | // guessing. | |
| 4107 | + | let stripe = Action::post("/checkout").awaiting(); | |
| 4108 | + | let upload = Action::post("/media").awaiting_amount(41_943_040); | |
| 4109 | + | assert!(stripe.awaits() && upload.awaits()); | |
| 4110 | + | assert_eq!(stripe.awaiting.and_then(|mark| mark.amount), None); | |
| 4111 | + | assert_eq!( | |
| 4112 | + | upload.awaiting.and_then(|mark| mark.amount), | |
| 4113 | + | Some(41_943_040) | |
| 4114 | + | ); | |
| 4115 | + | assert!(!Action::post("/save").awaits()); | |
| 4116 | + | } | |
| 4117 | + | ||
| 4118 | + | #[test] | |
| 4119 | + | fn a_nested_region_is_fed_too() { | |
| 4120 | + | // The walk descends the way `find_mut` does, or a region inside a pane | |
| 4121 | + | // never asks for itself and shows its stand-in forever. | |
| 4122 | + | let inner = Slot::new("inner", RegionKind::Pane).fed_by(Action::get("/slow").awaiting()); | |
| 4123 | + | let screen = Screen::list_detail("Screen", false) | |
| 4124 | + | .with(Slot::new("outer", RegionKind::Pane).with(Node::Region(inner))); | |
| 4125 | + | let feeds = screen.feeds(); | |
| 4126 | + | assert_eq!(feeds.len(), 1); | |
| 4127 | + | assert_eq!(feeds[0].route(), Some("/slow")); | |
| 4128 | + | } | |
| 3932 | 4129 | } |
| @@ -156,6 +156,13 @@ | |||
| 156 | 156 | ||
| 157 | 157 | Node::Act(act) => { | |
| 158 | 158 | let focused = claim_act(pass, act); | |
| 159 | + | // The control this screen is waiting on is drawn as the thing it is: | |
| 160 | + | // pressed, working, and not answering another press. The runtime | |
| 161 | + | // refuses that press whether or not this is drawn, so what is here | |
| 162 | + | // is the saying rather than the guard. | |
| 163 | + | if pass.view.busy(&act.action) { | |
| 164 | + | return text::draw_line(&busy_line(tui, act, focused), area, buf); | |
| 165 | + | } | |
| 159 | 166 | let chosen = commit_count(pass, act.over.as_deref()); | |
| 160 | 167 | text::draw_line(&commit_line(tui, act, chosen, focused), area, buf) | |
| 161 | 168 | } | |
| @@ -209,7 +216,12 @@ | |||
| 209 | 216 | match act { | |
| 210 | 217 | Some(act) => { | |
| 211 | 218 | let focused = claim_act(pass, act); | |
| 212 | - | used + text::draw_line(&act_line(tui, act, focused), below(area, used), buf) | |
| 219 | + | let line = if pass.view.busy(&act.action) { | |
| 220 | + | busy_line(tui, act, focused) | |
| 221 | + | } else { | |
| 222 | + | act_line(tui, act, focused) | |
| 223 | + | }; | |
| 224 | + | used + text::draw_line(&line, below(area, used), buf) | |
| 213 | 225 | } | |
| 214 | 226 | None => used, | |
| 215 | 227 | } | |
| @@ -873,6 +885,20 @@ | |||
| 873 | 885 | piece::act(tui.style(), &act.as_layout(), focused) | |
| 874 | 886 | } | |
| 875 | 887 | ||
| 888 | + | /// A control that has been pressed and has not been answered yet. | |
| 889 | + | /// | |
| 890 | + | /// [`layout::State::Disabled`] and nothing else. The label is untouched on | |
| 891 | + | /// purpose: appending a word to it would widen the control the moment it was | |
| 892 | + | /// pressed, which is the reflow "first paint is final paint" forbids, and the | |
| 893 | + | /// muted tone already says the thing will not answer right now. A terminal has | |
| 894 | + | /// no spinner that is not a clock, which is the same reason a pending region | |
| 895 | + | /// says "Loading" in words. | |
| 896 | + | fn busy_line(tui: &Tui, act: &Act, focused: bool) -> Line<'static> { | |
| 897 | + | let mut described = act.as_layout(); | |
| 898 | + | described.state = Some(layout::State::Disabled); | |
| 899 | + | piece::act(tui.style(), &described, focused) | |
| 900 | + | } | |
| 901 | + | ||
| 876 | 902 | /// How many are ticked, for a control that acts on the selection. | |
| 877 | 903 | /// | |
| 878 | 904 | /// `None` for a control that does not, which is nearly all of them. |
| @@ -162,6 +162,18 @@ | |||
| 162 | 162 | here: Option<Request>, | |
| 163 | 163 | /// A control waiting on its own question being answered. | |
| 164 | 164 | asked: Option<(Action, Params)>, | |
| 165 | + | /// The awaiting call this runtime has dispatched and not yet been answered | |
| 166 | + | /// about, with the request that went out. | |
| 167 | + | /// | |
| 168 | + | /// `d8d6f380`. Only an action carrying [`Action::awaiting`] lands here: the | |
| 169 | + | /// description is what says which calls are worth locking a control for, so | |
| 170 | + | /// this renderer does not decide that a route is slow. | |
| 171 | + | /// | |
| 172 | + | /// The pair rather than the action alone, because the refusal is against the | |
| 173 | + | /// request that is outstanding: pressing the same control again is the | |
| 174 | + | /// double submit, and pressing a different one is a different call this has | |
| 175 | + | /// no business refusing. | |
| 176 | + | outstanding: Option<(Action, Request)>, | |
| 165 | 177 | /// Something to say once the screen it belongs to has arrived. | |
| 166 | 178 | saying: Option<Message>, | |
| 167 | 179 | } | |
| @@ -178,6 +190,7 @@ | |||
| 178 | 190 | history: Vec::new(), | |
| 179 | 191 | here: None, | |
| 180 | 192 | asked: None, | |
| 193 | + | outstanding: None, | |
| 181 | 194 | saying: None, | |
| 182 | 195 | }; | |
| 183 | 196 | runtime.view.seed(&runtime.screen); | |
| @@ -251,6 +264,39 @@ | |||
| 251 | 264 | } | |
| 252 | 265 | } | |
| 253 | 266 | ||
| 267 | + | /// The calls this screen's regions are waiting on, for the host to perform. | |
| 268 | + | /// | |
| 269 | + | /// A webview gets this for free: every region fed by a call carries a | |
| 270 | + | /// trigger and the browser asks as soon as the element exists. A terminal | |
| 271 | + | /// has nobody to do that, so the host asks for these after putting a screen | |
| 272 | + | /// up, hands each answer back to [`apply`](Self::apply), and the region | |
| 273 | + | /// fills where its stand-in was. | |
| 274 | + | /// | |
| 275 | + | /// Ask again after applying an answer rather than keeping the list: a | |
| 276 | + | /// fragment landing clears the region's feed, so what comes back is what is | |
| 277 | + | /// still outstanding. | |
| 278 | + | #[must_use] | |
| 279 | + | pub fn feeds(&self) -> Vec<Request> { | |
| 280 | + | self.screen | |
| 281 | + | .feeds() | |
| 282 | + | .into_iter() | |
| 283 | + | .filter_map(Self::request_for) | |
| 284 | + | .collect() | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | /// What the control that was pressed is waiting on, if one is. | |
| 288 | + | /// | |
| 289 | + | /// The amount rides on it when the description measured one, which is what | |
| 290 | + | /// a host drawing its own progress reads. Nothing here turns it into a time: | |
| 291 | + | /// a bar shows what is done over what there is and how long it has taken, | |
| 292 | + | /// and predicts nothing. | |
| 293 | + | #[must_use] | |
| 294 | + | pub fn awaiting(&self) -> Option<layout::Awaiting> { | |
| 295 | + | self.outstanding | |
| 296 | + | .as_ref() | |
| 297 | + | .and_then(|(action, _)| action.awaiting) | |
| 298 | + | } | |
| 299 | + | ||
| 254 | 300 | /// Everything reachable on it, in focus order. | |
| 255 | 301 | #[must_use] | |
| 256 | 302 | pub fn reaches(&self) -> Vec<Reach> { | |
| @@ -354,7 +400,7 @@ | |||
| 354 | 400 | // now. Nothing can tick while a prompt owns the keyboard, so | |
| 355 | 401 | // the two are the same set -- and reading it here would mean | |
| 356 | 402 | // the answer depended on state the user could not see. | |
| 357 | - | Key::Char('y' | 'Y') | Key::Enter => Self::send(&action, payload), | |
| 403 | + | Key::Char('y' | 'Y') | Key::Enter => self.send(&action, payload), | |
| 358 | 404 | _ => Step::Idle, | |
| 359 | 405 | }; | |
| 360 | 406 | } | |
| @@ -369,7 +415,7 @@ | |||
| 369 | 415 | if !self.editing() || !matches!(key, Key::Char(_)) { | |
| 370 | 416 | let pressed = Self::key_name(key); | |
| 371 | 417 | if let Some(binding) = pressed.as_deref().and_then(|name| self.chrome.bound(name)) { | |
| 372 | - | return Self::call(&binding.action.clone()); | |
| 418 | + | return self.call(&binding.action.clone()); | |
| 373 | 419 | } | |
| 374 | 420 | } | |
| 375 | 421 | ||
| @@ -461,7 +507,7 @@ | |||
| 461 | 507 | self.asked = Some((action, payload)); | |
| 462 | 508 | Step::Ask(prompt) | |
| 463 | 509 | } | |
| 464 | - | None => Self::send(&action, payload), | |
| 510 | + | None => self.send(&action, payload), | |
| 465 | 511 | } | |
| 466 | 512 | } | |
| 467 | 513 | Some(Spot::Submit { action, names }) => { | |
| @@ -472,7 +518,7 @@ | |||
| 472 | 518 | .map(|reach| reach.spot.clone()) | |
| 473 | 519 | .collect::<Vec<_>>(), | |
| 474 | 520 | ); | |
| 475 | - | Self::send(&action, payload) | |
| 521 | + | self.send(&action, payload) | |
| 476 | 522 | } | |
| 477 | 523 | // A field takes Enter and does nothing with it. A browser | |
| 478 | 524 | // submits the form around it, and doing that here would fire a | |
| @@ -480,7 +526,7 @@ | |||
| 480 | 526 | // submit is one Tab away and says what it does. | |
| 481 | 527 | Some(Spot::Field(_)) | None => Step::Idle, | |
| 482 | 528 | Some(other) => match other.enters() { | |
| 483 | - | Some(action) => Self::call(&action.clone()), | |
| 529 | + | Some(action) => self.call(&action.clone()), | |
| 484 | 530 | None => Step::Idle, | |
| 485 | 531 | }, | |
| 486 | 532 | }, | |
| @@ -493,7 +539,7 @@ | |||
| 493 | 539 | Some(Spot::Row { | |
| 494 | 540 | toggle: Some(action), | |
| 495 | 541 | .. | |
| 496 | - | }) => Self::call(&action), | |
| 542 | + | }) => self.call(&action), | |
| 497 | 543 | // Otherwise it joins or leaves the set the screen names. The | |
| 498 | 544 | // hole `5f2b8753` was filed for was here: this used to be | |
| 499 | 545 | // `Step::Idle`, so the box was drawn, the key was bound, and | |
| @@ -544,7 +590,7 @@ | |||
| 544 | 590 | match claimed { | |
| 545 | 591 | Some((action, over)) => { | |
| 546 | 592 | let payload = self.gathering(over.as_deref()); | |
| 547 | - | Self::send(&action, payload) | |
| 593 | + | self.send(&action, payload) | |
| 548 | 594 | } | |
| 549 | 595 | None => Step::Idle, | |
| 550 | 596 | } | |
| @@ -568,6 +614,19 @@ | |||
| 568 | 614 | } = response; | |
| 569 | 615 | self.saying = notice.or(self.saying.take()); | |
| 570 | 616 | ||
| 617 | + | // Whatever was outstanding has been answered. Only the request that went | |
| 618 | + | // out clears it: an answer to something else arriving first leaves the | |
| 619 | + | // control locked, which is what it means for that control to still be | |
| 620 | + | // waiting. | |
| 621 | + | if self | |
| 622 | + | .outstanding | |
| 623 | + | .as_ref() | |
| 624 | + | .is_some_and(|(_, sent)| sent == request) | |
| 625 | + | { | |
| 626 | + | self.outstanding = None; | |
| 627 | + | self.view.awaiting(None); | |
| 628 | + | } | |
| 629 | + | ||
| 571 | 630 | match outcome { | |
| 572 | 631 | // Invalidations are not applied to a whole screen, matching what an | |
| 573 | 632 | // HTTP host does with them and for the same reason: every region is | |
| @@ -655,7 +714,7 @@ | |||
| 655 | 714 | self.announce(); | |
| 656 | 715 | None | |
| 657 | 716 | } | |
| 658 | - | Outcome::Goto(action) => match Self::call(&action) { | |
| 717 | + | Outcome::Goto(action) => match self.call(&action) { | |
| 659 | 718 | Step::Call(request) => Some(request), | |
| 660 | 719 | // An external destination is the host's to open, and there is | |
| 661 | 720 | // nothing to come back for. | |
| @@ -850,7 +909,7 @@ | |||
| 850 | 909 | // act, and a handler reads both the same way. | |
| 851 | 910 | let mut payload = self.gathering(over.as_deref()); | |
| 852 | 911 | payload.insert(name, value); | |
| 853 | - | return Self::send(&action, payload); | |
| 912 | + | return self.send(&action, payload); | |
| 854 | 913 | } | |
| 855 | 914 | ||
| 856 | 915 | // Asking about the value carries the value and nothing else. No ticks: | |
| @@ -867,7 +926,7 @@ | |||
| 867 | 926 | } | |
| 868 | 927 | let mut payload = Params::new(); | |
| 869 | 928 | payload.insert(name, value); | |
| 870 | - | return match Self::send(&consult.action, payload) { | |
| 929 | + | return match self.send(&consult.action, payload) { | |
| 871 | 930 | Step::Call(request) => Step::CallAfter { | |
| 872 | 931 | request, | |
| 873 | 932 | after: consult.after, | |
| @@ -907,23 +966,57 @@ | |||
| 907 | 966 | } | |
| 908 | 967 | ||
| 909 | 968 | /// An action as something the host can ask. | |
| 910 | - | fn call(action: &Action) -> Step { | |
| 911 | - | Self::send(action, Params::new()) | |
| 969 | + | fn call(&mut self, action: &Action) -> Step { | |
| 970 | + | self.send(action, Params::new()) | |
| 912 | 971 | } | |
| 913 | 972 | ||
| 914 | - | /// An action, plus values the control is sending that are not on it. | |
| 915 | - | fn send(action: &Action, extra: Params) -> Step { | |
| 916 | - | let Some(path) = action.destination.route() else { | |
| 917 | - | return Step::Open(action.destination.as_str().to_string()); | |
| 918 | - | }; | |
| 919 | - | let mut payload = extra; | |
| 920 | - | payload.absorb(action.params.clone()); | |
| 921 | - | Step::Call(Request { | |
| 973 | + | /// The request an action makes, or nothing when it names somewhere outside | |
| 974 | + | /// the app. | |
| 975 | + | /// | |
| 976 | + | /// Carries the action's own payload and the view it was offered under. What | |
| 977 | + | /// a control gathers on top of that is `send`'s, because only a press | |
| 978 | + | /// happens next to a selection. | |
| 979 | + | fn request_for(action: &Action) -> Option<Request> { | |
| 980 | + | let path = action.destination.route()?; | |
| 981 | + | Some(Request { | |
| 922 | 982 | method: action.method, | |
| 923 | 983 | path: path.to_string(), | |
| 924 | 984 | captures: Params::new(), | |
| 925 | - | payload, | |
| 985 | + | payload: action.params.clone(), | |
| 926 | 986 | carried: action.carried.clone(), | |
| 927 | 987 | }) | |
| 928 | 988 | } | |
| 989 | + | ||
| 990 | + | /// An action, plus values the control is sending that are not on it. | |
| 991 | + | fn send(&mut self, action: &Action, extra: Params) -> Step { | |
| 992 | + | if action.destination.route().is_none() { | |
| 993 | + | return Step::Open(action.destination.as_str().to_string()); | |
| 994 | + | } | |
| 995 | + | let mut payload = extra; | |
| 996 | + | payload.absorb(action.params.clone()); | |
| 997 | + | let Some(request) = Self::request_for(action).map(|request| Request { payload, ..request }) | |
| 998 | + | else { | |
| 999 | + | return Step::Idle; | |
| 1000 | + | }; | |
| 1001 | + | ||
| 1002 | + | // An awaiting call locks the control that made it until the answer | |
| 1003 | + | // arrives. A terminal has no browser to do this for it, and the second | |
| 1004 | + | // press is the one that buys the same thing twice. | |
| 1005 | + | // | |
| 1006 | + | // The refusal is against the outstanding request rather than against | |
| 1007 | + | // being busy at all: the rest of the screen keeps working, which is what | |
| 1008 | + | // "this control is doing something" means as opposed to "the app is". | |
| 1009 | + | if action.awaits() { | |
| 1010 | + | if self | |
| 1011 | + | .outstanding | |
| 1012 | + | .as_ref() | |
| 1013 | + | .is_some_and(|(_, sent)| *sent == request) | |
| 1014 | + | { | |
| 1015 | + | return Step::Idle; | |
| 1016 | + | } | |
| 1017 | + | self.outstanding = Some((action.clone(), request.clone())); | |
| 1018 | + | self.view.awaiting(Some(action.clone())); | |
| 1019 | + | } | |
| 1020 | + | Step::Call(request) | |
| 1021 | + | } | |
| 929 | 1022 | } |
| @@ -2043,3 +2043,93 @@ | |||
| 2043 | 2043 | assert!(!with.contains("Sort"), "{with}"); | |
| 2044 | 2044 | assert!(with.contains("Inserted"), "{with}"); | |
| 2045 | 2045 | } | |
| 2046 | + | ||
| 2047 | + | #[test] | |
| 2048 | + | fn an_awaiting_control_is_pressed_once_and_refuses_the_second_press() { | |
| 2049 | + | // `d8d6f380`. A terminal has no browser to lock a button for it, and the | |
| 2050 | + | // second press is the one that buys the same thing twice. | |
| 2051 | + | let mut runtime = Runtime::new(screen_of([ | |
| 2052 | + | Node::Act(Act::new("Buy", Action::post("/checkout").awaiting())), | |
| 2053 | + | Node::Act(Act::new("Cancel", Action::post("/cancel"))), | |
| 2054 | + | ])); | |
| 2055 | + | ||
| 2056 | + | assert_eq!(calling(&runtime.key(Key::Enter)), Some("/checkout")); | |
| 2057 | + | assert_eq!( | |
| 2058 | + | runtime.key(Key::Enter), | |
| 2059 | + | Step::Idle, | |
| 2060 | + | "the same control, still waiting" | |
| 2061 | + | ); | |
| 2062 | + | ||
| 2063 | + | // The rest of the screen keeps working: one control is busy, the app is | |
| 2064 | + | // not. | |
| 2065 | + | runtime.key(Key::Tab); | |
| 2066 | + | assert_eq!(calling(&runtime.key(Key::Enter)), Some("/cancel")); | |
| 2067 | + | ||
| 2068 | + | // The answer arrives and the control is offered again. | |
| 2069 | + | runtime.apply( | |
| 2070 | + | &Request::post("/checkout"), | |
| 2071 | + | Response::fragment("main", Node::text("Bought")), | |
| 2072 | + | ); | |
| 2073 | + | assert!(runtime.awaiting().is_none()); | |
| 2074 | + | } | |
| 2075 | + | ||
| 2076 | + | #[test] | |
| 2077 | + | fn a_control_with_no_mark_is_never_locked() { | |
| 2078 | + | // Nothing here decides that a route is slow. Only a described wait locks | |
| 2079 | + | // anything, so every screen written before this existed behaves as it did. | |
| 2080 | + | let mut runtime = Runtime::new(screen_of([Node::Act(Act::new( | |
| 2081 | + | "Save", | |
| 2082 | + | Action::post("/save"), | |
| 2083 | + | ))])); | |
| 2084 | + | assert_eq!(calling(&runtime.key(Key::Enter)), Some("/save")); | |
| 2085 | + | assert_eq!(calling(&runtime.key(Key::Enter)), Some("/save")); | |
| 2086 | + | } | |
| 2087 | + | ||
| 2088 | + | #[test] | |
| 2089 | + | fn a_region_fed_by_a_call_is_asked_for_and_then_stops_asking() { | |
| 2090 | + | // What the browser does with a trigger per region, done by the host here | |
| 2091 | + | // because a terminal has nobody to do it. | |
| 2092 | + | let mut runtime = Runtime::new(Screen::sidebar_content("Payments").with( | |
| 2093 | + | Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts").awaiting()), | |
| 2094 | + | )); | |
| 2095 | + | ||
| 2096 | + | let feeds = runtime.feeds(); | |
| 2097 | + | assert_eq!(feeds.len(), 1); | |
| 2098 | + | assert_eq!(feeds[0].path, "/dashboard/payouts"); | |
| 2099 | + | ||
| 2100 | + | // The stand-in is on the screen while it is out. | |
| 2101 | + | let waiting = shown(runtime.screen(), 40, 8).join(" "); | |
| 2102 | + | assert!(waiting.contains("Loading"), "{waiting}"); | |
| 2103 | + | ||
| 2104 | + | runtime.apply( | |
| 2105 | + | &feeds[0].clone(), | |
| 2106 | + | Response::fragment("payouts", Node::text("$12.00")), | |
| 2107 | + | ); | |
| 2108 | + | let filled = shown(runtime.screen(), 40, 8).join(" "); | |
| 2109 | + | assert!(filled.contains("$12.00"), "{filled}"); | |
| 2110 | + | assert!(runtime.feeds().is_empty(), "a filled region asks again"); | |
| 2111 | + | } | |
| 2112 | + | ||
| 2113 | + | #[test] | |
| 2114 | + | fn the_control_that_is_waiting_is_drawn_muted_and_keeps_its_width() { | |
| 2115 | + | // Disabled and nothing else. A word appended to the label would widen the | |
| 2116 | + | // control the moment it was pressed, which is the reflow the first-paint | |
| 2117 | + | // rule forbids. | |
| 2118 | + | let mut runtime = Runtime::new(screen_of([Node::Act(Act::new( | |
| 2119 | + | "Buy", | |
| 2120 | + | Action::post("/checkout").awaiting(), | |
| 2121 | + | ))])); | |
| 2122 | + | ||
| 2123 | + | let before = shown(runtime.screen(), 40, 6); | |
| 2124 | + | runtime.key(Key::Enter); | |
| 2125 | + | ||
| 2126 | + | let mut buf = Buffer::empty(Rect::new(0, 0, 40, 6)); | |
| 2127 | + | runtime.draw(&tui(), Rect::new(0, 0, 40, 6), &mut buf); | |
| 2128 | + | let after = rows(&buf); | |
| 2129 | + | assert_eq!( | |
| 2130 | + | before.iter().map(|row| row.trim_end().len()).sum::<usize>(), | |
| 2131 | + | after.iter().map(|row| row.trim_end().len()).sum::<usize>(), | |
| 2132 | + | "{before:?} against {after:?}" | |
| 2133 | + | ); | |
| 2134 | + | assert!(after.iter().any(|row| row.contains("Buy")), "{after:?}"); | |
| 2135 | + | } |
| @@ -47,7 +47,7 @@ | |||
| 47 | 47 | use std::collections::{BTreeMap, BTreeSet}; | |
| 48 | 48 | ||
| 49 | 49 | use makeover_layout as layout; | |
| 50 | - | use quasi_router::{Params, Screen}; | |
| 50 | + | use quasi_router::{Action, Params, Screen}; | |
| 51 | 51 | ||
| 52 | 52 | use crate::focus::{FieldSpot, Spot}; | |
| 53 | 53 | ||
| @@ -103,6 +103,18 @@ | |||
| 103 | 103 | /// Absent means the description's own answer still stands, which is what | |
| 104 | 104 | /// makes an untouched screen draw what the handler said. | |
| 105 | 105 | shown: BTreeMap<String, usize>, | |
| 106 | + | /// The action this screen is waiting on, when one is outstanding. | |
| 107 | + | /// | |
| 108 | + | /// `d8d6f380`, and the seventh of the same discovery: a control that has | |
| 109 | + | /// been pressed and has not been answered yet is a fact about this moment, | |
| 110 | + | /// which is why it lives here and not in the description. A browser owns it | |
| 111 | + | /// too, and htmx expresses it as a class on the element that made the | |
| 112 | + | /// request. | |
| 113 | + | /// | |
| 114 | + | /// Only an [`Action`] carrying [`Action::awaiting`] ever lands here. The | |
| 115 | + | /// rest resolve fast enough that a terminal drawing them busy would be a | |
| 116 | + | /// flicker, and the description says which those are. | |
| 117 | + | outstanding: Option<Action>, | |
| 106 | 118 | } | |
| 107 | 119 | ||
| 108 | 120 | impl View { | |
| @@ -123,6 +135,29 @@ | |||
| 123 | 135 | self.showing(&field.name, field.value.as_deref()) | |
| 124 | 136 | } | |
| 125 | 137 | ||
| 138 | + | /// The action this view is waiting on. | |
| 139 | + | /// | |
| 140 | + | /// What the drawing consults to mute a control that has been pressed, and | |
| 141 | + | /// what the runtime consults to refuse a second press of it. | |
| 142 | + | #[must_use] | |
| 143 | + | pub const fn outstanding(&self) -> Option<&Action> { | |
| 144 | + | self.outstanding.as_ref() | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// Say that this action is outstanding, or that nothing is. | |
| 148 | + | /// | |
| 149 | + | /// The runtime's to set. A host driving this crate without one is drawing a | |
| 150 | + | /// screen it never dispatched from, so there is nothing for it to say here. | |
| 151 | + | pub(crate) fn awaiting(&mut self, action: Option<Action>) { | |
| 152 | + | self.outstanding = action; | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// Whether this is the control that was pressed and has not been answered. | |
| 156 | + | #[must_use] | |
| 157 | + | pub fn busy(&self, action: &Action) -> bool { | |
| 158 | + | self.outstanding.as_ref() == Some(action) | |
| 159 | + | } | |
| 160 | + | ||
| 126 | 161 | /// [`typed`](Self::typed) for a caller holding the described field itself | |
| 127 | 162 | /// rather than a walk's record of it, which is what the drawing has. | |
| 128 | 163 | #[must_use] | |
| @@ -307,6 +342,10 @@ | |||
| 307 | 342 | self.ticked.clear(); | |
| 308 | 343 | self.shown.clear(); | |
| 309 | 344 | self.focus = 0; | |
| 345 | + | // A screen that has arrived is the answer to whatever was outstanding, | |
| 346 | + | // or is a different place entirely. Either way nothing on it has been | |
| 347 | + | // pressed yet. | |
| 348 | + | self.outstanding = None; | |
| 310 | 349 | } | |
| 311 | 350 | ||
| 312 | 351 | /// The values a form submits, gathered for `names` in the order given. |
| @@ -345,6 +345,12 @@ | |||
| 345 | 345 | pub(crate) enum Fires<'a> { | |
| 346 | 346 | /// The user activating the control. htmx's default for a button or a link. | |
| 347 | 347 | Click, | |
| 348 | + | /// The element existing. What a region fed by a call uses, so the screen | |
| 349 | + | /// paints its stand-in and the slow part arrives behind it. | |
| 350 | + | /// | |
| 351 | + | /// The only member that is not a gesture, which is why it is the only one | |
| 352 | + | /// that suppresses the `href` below: a region is not somewhere to go. | |
| 353 | + | Load, | |
| 348 | 354 | /// The control's own value changing. What a checkbox that is itself the | |
| 349 | 355 | /// write does. | |
| 350 | 356 | Change, | |
| @@ -405,7 +411,7 @@ | |||
| 405 | 411 | Self::ChangeInside | Self::Typing { .. } => { | |
| 406 | 412 | Some("find input, find select, find textarea") | |
| 407 | 413 | } | |
| 408 | - | Self::Click | Self::Change | Self::Key(_) | Self::ClickBeside => None, | |
| 414 | + | Self::Click | Self::Change | Self::Key(_) | Self::ClickBeside | Self::Load => None, | |
| 409 | 415 | } | |
| 410 | 416 | } | |
| 411 | 417 | } | |
| @@ -462,7 +468,10 @@ | |||
| 462 | 468 | // crawler, and the page with JS off. The parameters are folded into it | |
| 463 | 469 | // because a link to a filtered list that drops the filter is a different | |
| 464 | 470 | // place, and `hx-vals` below carries the same ones down htmx's path. | |
| 465 | - | if matches!(action.destination, Destination::Route(_)) && !action.method.mutates() { | |
| 471 | + | if matches!(action.destination, Destination::Route(_)) | |
| 472 | + | && !action.method.mutates() | |
| 473 | + | && !matches!(fires, Fires::Load) | |
| 474 | + | { | |
| 466 | 475 | out.push_str(" href=\""); | |
| 467 | 476 | escape_into(&url, out); | |
| 468 | 477 | out.push('"'); | |
| @@ -547,6 +556,10 @@ | |||
| 547 | 556 | "'[data-act]')]\"" | |
| 548 | 557 | )), | |
| 549 | 558 | Fires::Change => out.push_str(" hx-trigger=\"change\""), | |
| 559 | + | // Once, when the element appears. `Slot::fed_by` is the whole of what | |
| 560 | + | // reaches this, and the region it sits on is `Readiness::Pending` | |
| 561 | + | // already, so the stand-in is on screen before the request leaves. | |
| 562 | + | Fires::Load => out.push_str(" hx-trigger=\"load\""), | |
| 550 | 563 | Fires::Key(filter) => { | |
| 551 | 564 | // `from:body`, because the element is hidden and never focused: a | |
| 552 | 565 | // trigger on itself would wait for a keystroke it can never | |
| @@ -595,6 +608,36 @@ | |||
| 595 | 608 | // through a swap, so a whole-Screen answer stops being destructive. | |
| 596 | 609 | out.push_str(" hx-swap=\"morph\""); | |
| 597 | 610 | } | |
| 611 | + | ||
| 612 | + | // The action says it waits on something that resolves once, so the control | |
| 613 | + | // stops answering until it does. `hx-disabled-elt` is the half of this | |
| 614 | + | // treatment the MNW server writes twice against 57 spinners, and it is the | |
| 615 | + | // half that guards a double-submitted purchase. | |
| 616 | + | // | |
| 617 | + | // No `hx-indicator`: htmx already puts `htmx-request` on the element that | |
| 618 | + | // made the request, so naming it as its own indicator emits an attribute | |
| 619 | + | // that changes nothing. A screen wanting a spinner somewhere else is | |
| 620 | + | // pointing at an element no description names. | |
| 621 | + | // | |
| 622 | + | // No static `aria-busy` either. The control is not busy when the page is | |
| 623 | + | // painted, and an attribute that is true only between two events is the | |
| 624 | + | // binder's to set. What a reader gets in the meantime is the `disabled` htmx | |
| 625 | + | // applies, which is not silent. | |
| 626 | + | if let Some(awaiting) = &action.awaiting { | |
| 627 | + | out.push_str(" hx-disabled-elt=\"this\" data-awaiting=\""); | |
| 628 | + | out.push_str(if awaiting.is_determinate() { | |
| 629 | + | "determinate" | |
| 630 | + | } else { | |
| 631 | + | "indeterminate" | |
| 632 | + | }); | |
| 633 | + | out.push('"'); | |
| 634 | + | // The amount, when it was measured. Never a duration and never anything | |
| 635 | + | // derived into one: a bar drawn from this shows what is done over what | |
| 636 | + | // there is, and the time it has taken, and predicts nothing. | |
| 637 | + | if let Some(amount) = awaiting.amount { | |
| 638 | + | let _ = write!(out, " data-awaiting-amount=\"{amount}\""); | |
| 639 | + | } | |
| 640 | + | } | |
| 598 | 641 | } | |
| 599 | 642 | ||
| 600 | 643 | /// Whether an action is somewhere to go rather than something to do. | |
| @@ -2258,6 +2301,20 @@ | |||
| 2258 | 2301 | out.push_str(" aria-busy=\"true\""); | |
| 2259 | 2302 | } | |
| 2260 | 2303 | ||
| 2304 | + | // The region says where its content is coming from, so it asks for it | |
| 2305 | + | // itself as soon as it exists. This is what a hand-split route was doing | |
| 2306 | + | // before there was a word for it: MNW's payout summary is its own tab | |
| 2307 | + | // because it calls a payment provider while the rest of the screen reads the | |
| 2308 | + | // database, and under this it is a region of the screen that arrives late. | |
| 2309 | + | // | |
| 2310 | + | // Every `hx-` attribute comes out of `action_attrs`, including these. The | |
| 2311 | + | // answer is aimed by `HX-Retarget` naming this slot's id, which is why | |
| 2312 | + | // nothing here writes a target: the router is the party that knows what it | |
| 2313 | + | // changed, and that is unchanged by the request having been started here. | |
| 2314 | + | if let Some(action) = &slot.fed_by { | |
| 2315 | + | action_attrs(action, Fires::Load, None, morphs, None, out); | |
| 2316 | + | } | |
| 2317 | + | ||
| 2261 | 2318 | if matches!(slot.kind, quasi_router::RegionKind::Modal) { | |
| 2262 | 2319 | // A modal takes input until dismissed, which is what modal means, and | |
| 2263 | 2320 | // saying so is the renderer's job rather than the app's. |
| @@ -3581,3 +3581,59 @@ | |||
| 3581 | 3581 | assert_eq!(html.matches("<div class=\"cell-drops").count(), 2); | |
| 3582 | 3582 | assert!(html.contains("<p class=\"text\">Library</p>"), "{html}"); | |
| 3583 | 3583 | } | |
| 3584 | + | ||
| 3585 | + | #[test] | |
| 3586 | + | fn an_awaiting_control_locks_itself_while_it_waits() { | |
| 3587 | + | // `d8d6f380`. The guard, which is the half the shipped server writes twice | |
| 3588 | + | // against 57 spinners, and the half a double-submitted purchase needs. | |
| 3589 | + | let html = fragment(&Node::Act(Act::new( | |
| 3590 | + | "Buy", | |
| 3591 | + | Action::post("/checkout").awaiting(), | |
| 3592 | + | ))); | |
| 3593 | + | assert!(html.contains(r#"hx-disabled-elt="this""#), "{html}"); | |
| 3594 | + | assert!(html.contains(r#"data-awaiting="indeterminate""#), "{html}"); | |
| 3595 | + | assert!(!html.contains("data-awaiting-amount"), "{html}"); | |
| 3596 | + | ||
| 3597 | + | // No mark, nothing said. A control that waits on nothing worth saying so | |
| 3598 | + | // about draws exactly what it drew before this existed. | |
| 3599 | + | let plain = fragment(&Node::Act(Act::new("Save", Action::post("/save")))); | |
| 3600 | + | assert!(!plain.contains("hx-disabled-elt"), "{plain}"); | |
| 3601 | + | assert!(!plain.contains("data-awaiting"), "{plain}"); | |
| 3602 | + | } | |
| 3603 | + | ||
| 3604 | + | #[test] | |
| 3605 | + | fn a_measured_wait_carries_its_amount_and_never_a_duration() { | |
| 3606 | + | let html = fragment(&Node::Act(Act::new( | |
| 3607 | + | "Upload", | |
| 3608 | + | Action::post("/media").awaiting_amount(41_943_040), | |
| 3609 | + | ))); | |
| 3610 | + | assert!(html.contains(r#"data-awaiting="determinate""#), "{html}"); | |
| 3611 | + | assert!( | |
| 3612 | + | html.contains(r#"data-awaiting-amount="41943040""#), | |
| 3613 | + | "{html}" | |
| 3614 | + | ); | |
| 3615 | + | // The amount is what there is, not how long it will take. Nothing in the | |
| 3616 | + | // markup may read as a prediction, because the description carries none. | |
| 3617 | + | assert!(!html.contains("duration"), "{html}"); | |
| 3618 | + | assert!(!html.contains("eta"), "{html}"); | |
| 3619 | + | } | |
| 3620 | + | ||
| 3621 | + | #[test] | |
| 3622 | + | fn a_region_fed_by_a_call_asks_for_itself() { | |
| 3623 | + | // MNW's payout summary: one slow part of a screen that is otherwise local | |
| 3624 | + | // reads. A hand-split route before there was a word for it. | |
| 3625 | + | let screen = Screen::list_detail("Payments", false).with( | |
| 3626 | + | Slot::new("payouts", RegionKind::Pane).fed_by(Action::get("/dashboard/payouts").awaiting()), | |
| 3627 | + | ); | |
| 3628 | + | let html = render(&screen); | |
| 3629 | + | assert!(html.contains(r#"hx-trigger="load""#), "{html}"); | |
| 3630 | + | assert!(html.contains(r#"hx-get="/dashboard/payouts""#), "{html}"); | |
| 3631 | + | // Pending while it is on its way, which is the state that was already | |
| 3632 | + | // sayable and had no way to fill itself. | |
| 3633 | + | assert!(html.contains(r#"aria-busy="true""#), "{html}"); | |
| 3634 | + | // A region is not somewhere to go, so the read does not become an anchor. | |
| 3635 | + | assert!(!html.contains(r#"href="/dashboard/payouts""#), "{html}"); | |
| 3636 | + | // Nothing aims the answer. The router names what it changed, which is | |
| 3637 | + | // decision 7 and is not weakened by the request starting here. | |
| 3638 | + | assert!(!html.contains("hx-target"), "{html}"); | |
| 3639 | + | } |