Skip to main content

max / quasi

Treat arriving where you already are as a refresh, not a navigation A reload went through Outcome::Screen, which resets and reseeds the view on arrival. Both are arrival behaviour, so a host reloading a form every frame cleared the box under the caret and put back every untick. Both runtimes now compare the request against where they are and skip the reset when it matches. Expose view()/view_mut() on the immediate runtime, which had no way to read or restore a draft.
Author: Max Johnson <me@maxj.phd> · 2026-08-16 01:54 UTC
Signed with PGP, not checked
Commit: 3cbe9071c4b61cc10b2c9181bcd69729df50af7f
Parent: f0ced95
3 files changed, +154 insertions, -7 deletions
@@ -105,6 +105,26 @@
105 105 !self.under.is_empty()
106 106 }
107 107
108 + /// What the user has typed and ticked on it.
109 + ///
110 + /// The terminal's runtime has had this since the beginning; here it was
111 + /// missing, which made "a refresh keeps what a navigation drops" a claim
112 + /// nothing outside this module could check.
113 + #[must_use]
114 + pub const fn view(&self) -> &View {
115 + &self.view
116 + }
117 +
118 + /// The same, to write into.
119 + ///
120 + /// What [`View::set`] documents itself for — "put a value in, as a host
121 + /// restoring one would" — was unreachable for a host holding a [`Runtime`]:
122 + /// `show` draws through the runtime's own view and nothing handed it out.
123 + /// Restoring a draft into a screen is the host's job and this is how.
124 + pub const fn view_mut(&mut self) -> &mut View {
125 + &mut self.view
126 + }
127 +
108 128 /// Draw a frame, and answer what to do about it.
109 129 ///
110 130 /// The chrome's keys are read before the drawing, so a screen cannot capture
@@ -298,13 +318,25 @@
298 318 None
299 319 }
300 320 Outcome::Screen(screen) => {
321 + // Arriving where you already are is a refresh rather than a
322 + // navigation, and the difference is the whole of what the user
323 + // has done to the screen. `reset` and `seed` are both *arrival*
324 + // behaviour: one drops what was typed and ticked, the other
325 + // applies what the description says is ticked. Running either on
326 + // a refresh would undo the user mid-sentence — a text field
327 + // cleared on every reload, and an untick put back the moment
328 + // anything redrew, which is the exact failure `View::seed`'s own
329 + // documentation says it is applied once to avoid.
330 + let refreshed = self.here.as_ref() == Some(request);
301 331 // A navigation replaces everything, including any overlay open
302 332 // over it.
303 333 self.under.clear();
304 334 self.remember(request, address.as_ref());
305 335 self.screen = screen;
306 - self.view.reset();
307 - self.view.seed(&self.screen);
336 + if !refreshed {
337 + self.view.reset();
338 + self.view.seed(&self.screen);
339 + }
308 340 self.announce();
309 341 None
310 342 }
@@ -351,6 +351,112 @@
351 351 assert_eq!(runtime.back(), Step::Idle);
352 352 }
353 353
354 + #[test]
355 + fn a_reload_keeps_what_the_user_is_in_the_middle_of_typing() {
356 + // The failure this guards, and it is the one that decides whether `reload`
357 + // is usable at all: a refresh goes through `Outcome::Screen`, which resets
358 + // the view on arrival. A host reloading a form every frame would clear the
359 + // box under the caret sixty times a second.
360 + let mut runtime = Runtime::new(screen_of([Node::text("opening")]));
361 + let form = || {
362 + screen_of([Node::Field(Box::new(Field::new(
363 + layout::FieldKind::Text,
364 + "naming-pattern",
365 + "Naming pattern",
366 + )))])
367 + };
368 + runtime.apply(
369 + &Request::get("/export"),
370 + Response {
371 + outcome: Outcome::Screen(form()),
372 + notice: None,
373 + address: None,
374 + invalidates: Vec::new(),
375 + },
376 + );
377 +
378 + runtime.view_mut().set("naming-pattern", "{name}-{bpm}");
379 +
380 + // Five refreshes, which is what a host reloading every frame does.
381 + for _ in 0..5 {
382 + let Step::Call(request) = runtime.reload() else {
383 + panic!("a screen that was navigated to can be asked for again");
384 + };
385 + runtime.apply(
386 + &request,
387 + Response {
388 + outcome: Outcome::Screen(form()),
389 + notice: None,
390 + address: None,
391 + invalidates: Vec::new(),
392 + },
393 + );
394 + }
395 + assert_eq!(
396 + runtime.view().edit("naming-pattern"),
397 + Some("{name}-{bpm}"),
398 + "a refresh is not an arrival, so it does not clear the box being typed into"
399 + );
400 +
401 + // Going somewhere else is a different matter, and still clears.
402 + runtime.apply(
403 + &Request::get("/settings"),
404 + Response {
405 + outcome: Outcome::Screen(screen_of([Node::text("elsewhere")])),
406 + notice: None,
407 + address: None,
408 + invalidates: Vec::new(),
409 + },
410 + );
411 + assert_eq!(runtime.view().edit("naming-pattern"), None);
412 + }
413 +
414 + #[test]
415 + fn a_reload_does_not_put_back_a_tick_the_user_took_off() {
416 + // `View::seed` is arrival behaviour by its own documentation -- "after this
417 + // the user's ticks are the truth" -- so a refresh must not run it. Otherwise
418 + // unticking a row that the description says is ticked lasts exactly until
419 + // the next reload.
420 + let ticked = || {
421 + let mut row = quasi_router::Row::new("One");
422 + row.selected = Some(true);
423 + row.value = Some("1".to_owned());
424 + screen_of([Node::list([row])])
425 + };
426 +
427 + let mut runtime = Runtime::new(screen_of([Node::text("opening")]));
428 + runtime.apply(
429 + &Request::get("/files"),
430 + Response {
431 + outcome: Outcome::Screen(ticked()),
432 + notice: None,
433 + address: None,
434 + invalidates: Vec::new(),
435 + },
436 + );
437 +
438 + // Arrival seeded it, which is the behaviour being distinguished from.
439 + assert!(runtime.view().is_ticked("1"));
440 + runtime.view_mut().tick("1");
441 +
442 + let Step::Call(request) = runtime.reload() else {
443 + panic!("a screen that was navigated to can be asked for again");
444 + };
445 + runtime.apply(
446 + &request,
447 + Response {
448 + outcome: Outcome::Screen(ticked()),
449 + notice: None,
450 + address: None,
451 + invalidates: Vec::new(),
452 + },
453 + );
454 + assert!(
455 + !runtime.view().is_ticked("1"),
456 + "the user took it off and a refresh is not an arrival"
457 + );
458 + }
459 +
354 460 #[test]
355 461 fn an_overlay_is_not_a_place_and_dismissing_it_reveals_what_was_under_it() {
356 462 let mut runtime = Runtime::new(screen_of([Node::text("underneath")]));
@@ -556,14 +556,23 @@
556 556 // navigating with a palette still floating over the destination is
557 557 // the state nobody asked for.
558 558 Outcome::Screen(screen) => {
559 + // Arriving where you already are is a refresh rather than a
560 + // navigation, and the difference is the whole of what the user
561 + // has done to the screen. `reset` and `seed` below are both
562 + // *arrival* behaviour, so a refresh runs neither: otherwise a
563 + // reload clears the field being typed into and puts back an
564 + // untick the moment anything redraws.
565 + let refreshed = self.here.as_ref() == Some(request);
559 566 self.under.clear();
560 567 self.remember(request, address.as_ref());
561 568 self.screen = screen;
562 - self.view.reset();
563 - // The rows a new screen says are already ticked. After this the
564 - // user's ticks are the truth, which is why it is applied once
565 - // on arrival rather than read on every draw.
566 - self.view.seed(&self.screen);
569 + if !refreshed {
570 + self.view.reset();
571 + // The rows a new screen says are already ticked. After this
572 + // the user's ticks are the truth, which is why it is applied
573 + // once on arrival rather than read on every draw.
574 + self.view.seed(&self.screen);
575 + }
567 576 self.announce();
568 577 None
569 578 }