Skip to main content

max / quasi

An action can be awaiting, and a region can say what it is waiting on Task d8d6f380. Readiness names Ready / Pending / Empty / Failed for a region and nothing named the same thing for a control: that this button is currently doing what it was clicked for, should say so, and should refuse a second click. Action::awaiting is the control half. Slot::fed_by is the region half -- a screen that is mostly local reads plus one slow part says so on the region instead of being hand-split into a second route, which is what MNW's user dashboard does with its payout summary because that one tab calls a payment provider and the rest reads the database. Screen::replace clears it when the answer lands, so a retained-screen host cannot ask twice for one region. Carried through all three renderers. Committed by a later session. The session that wrote this checkpointed without committing and did not come back; the work compiles and its tests pass, and leaving it uncommitted underneath the row-group cascade would have entangled two unrelated pieces of work in one diff.
Author: Max Johnson <me@maxj.phd> · 2026-08-18 19:21 UTC
Signed with PGP, not checked
Commit: 365145a57b1cb0fc6ea31c8205974b380aa76520
Parent: 6f6e845
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 "&#39;[data-act]&#39;)]\""
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 + }