A message beside its open editor stands in the browser too (improvise-1x9h)

The toast hid every status after six seconds, so a refusal the user was
still fixing vanished with its prompt open: su4u's bug on a timer. The
browser now asks the TUI's question. EditorSession moves into
improvise-protocol, ClientState records which editor a status was
written in, and RenderModel::status_stands tells the toast to hold it
until that editor closes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Edward Langley
2026-09-23 18:08:26 -07:00
parent 09db8eb0cd
commit 9eb83538fe
12 changed files with 389 additions and 45 deletions
+55
View File
@@ -2267,6 +2267,61 @@ reads that text. An emptied node cannot tell "nothing was said" from "something
else was said", and the assertion would pass vacuously the moment the timer else was said", and the assertion would pass vacuously the moment the timer
fired. fired.
#### A message beside its open editor stands until that editor closes (improvise-1x9h)
The timer is wrong for one kind of message: a refusal the user is still
fixing. Six seconds after it appears, the toast hid it while the minibuffer it
was about stayed open. That is the TUI's su4u bug with a timer instead of a
keypress, and the rule is the TUI's, so both front ends ask one question.
**The rule: a message stands while the editor it was written in is still the
open one.** "The editor" is `improvise_protocol::EditorSession`, the value the
TUI's `press` compares before and after a key (`context/code/tui.md`, "What
'the same editor' means"). It moved from `src/draw.rs` into the protocol crate
so that the browser asks the same question rather than a second derivation of
it (design principles §5).
**The decision is Rust's, and the page only obeys it.** `ClientState` observes
the status after every fold (`settle_status`, beside `settle_mode` and
`settle_editor`):
- a status that has changed since it was last observed is owned by the editor
open at that moment, or by nothing outside a text-entry mode;
- an unchanged status loses its owner, for good, as soon as a different editor
(or none) is open.
`RenderModel::status_stands` is `ClientState::status_stands()`. The page passes
it to `createToasts.offer(text, stands)`. A standing message arms no timer. When
it stops standing it hides at once, not six seconds later, because the TUI drops
a refusal on the key that leaves its editor.
**"For good" is the part that needs memory.** A refusal in `FormulaEdit`, then
`Esc`, then `n` again opens an editor *equal* to the first: the same mode, the
same cursor, no seed. Comparing the current editor with nothing would make the
old message stand again beside the new opening. The owner is therefore
recorded when the message is written and cleared when that editor closes, not
recomputed from what is open now.
**A status written outside a fold must be observed where it is written.**
`socket.rs` writes a rejection's sentence *after* `refused()`, so the sentence
survives the rollback. It now writes it through `ClientState::notify`, which
observes it on the spot. Assigning `status_msg` directly leaves it unobserved
until the next fold. By then the editor may have changed, and the message
would be owned by the wrong one.
**What it inherits from the identity rule above.** An identical message written
again in a *new* editor is not a new write, so it neither stands nor re-shows.
That is the "two identical messages show once" cost, and it has the same cause:
nothing beside `status_msg` says that it was written again.
**Where the rule is live.** A formula refusal still never reaches a browser.
The client folds `CommitBuffer`'s optimistic "Formula added", and the host's
error is a write to the host's own slice
(`a_formula_the_client_cannot_parse_diverges_on_the_status_line`). What
reaches a browser today is a host rejection that leaves the editor open, such
as a refused save. The rule becomes fully live when a host-addressed status
reaches its composer.
### What the styling is, and what no test here can see ### What the styling is, and what no test here can see
`web/style.css` stopped being "legibility only" and is now a small design `web/style.css` stopped being "legibility only" and is now a small design
+7 -4
View File
@@ -505,7 +505,10 @@ and stay in the minibuffer: a `:` line that switches view settles back to
Normal. So nothing could test the field, and an untestable field is a claim no Normal. So nothing could test the field, and an untestable field is a claim no
test can check. test can check.
**The browser is not covered by this.** There, `status_msg` is a toast that **The browser asks the same question (improvise-1x9h).** `EditorSession` now
hides after `TOAST_MS` (six seconds), and the minibuffer element draws only the lives in `improvise-protocol` and is built from a `ViewState`, so the TUI's
prompt and the text. A refusal therefore disappears on a timer, whatever the `press` and the browser's `ClientState::status_stands` compare the same value.
user is doing. That is a different mechanism and has not been changed here. The browser's mechanism differs: its message is a toast on a timer, and there
is no per-key clear. So it keeps the message's *owner* and suspends the timer
while that owner is open. See `context/code/clients.md`, "A message beside its
open editor stands until that editor closes".
+57
View File
@@ -5117,3 +5117,60 @@ Rival injected: `claim_as_read` ignoring the captured generation and claiming
the current one. That is the pre-fix behaviour, restated after the fix. Only the current one. That is the pre-fix behaviour, restated after the fix. Only
this test failed in the whole workspace. The layout tier staying current is this test failed in the whole workspace. The layout tier staying current is
still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber`. still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber`.
### a_message_stands_while_the_editor_it_was_written_in_is_open
improvise-1x9h, the browser half of improvise-su4u. In the browser,
`status_msg` was only ever a toast, and `createToasts` hid it after `TOAST_MS`
(six seconds) whatever the mode. A refusal written beside an open minibuffer
therefore disappeared while the user was still fixing the thing it described.
The bug was shown red in JavaScript first, where the timer lives. `shell.test.js`,
"a standing message outlives the timer and goes with its editor", failed on the
unchanged `createToasts` with "a standing message arms no timer". The decision
of *whether* a message stands is Rust's, and this test pins it. It opens
`FormulaEdit`, writes a status, and asserts that the message stands, and still
stands after a buffer edit. Then it leaves to `FormulaPanel` and asserts that
it does not. Then it re-opens an equal `FormulaEdit` and asserts that the
message *still* does not stand, although `status_msg` is unchanged.
- **Rival `any_editor`** (`status_stands` = non-empty status and any editor
open): fails this test and its sibling below, and nothing else in the
workspace (1943 passed, 2 failed).
- **Rival `owner_never_lost`** (drop the `else if` that clears the owner once
its editor closes): fails only this test, on the re-opened equal editor.
That clause is what the last assertion exists for. Without it an equal
opening is indistinguishable from the original one.
### a_message_does_not_stand_beside_an_editor_it_was_not_written_in
improvise-1x9h. There are three ways to be a different editor, and one case
each: a status written in Normal before an editor opens; a cell editor whose
cursor then moves from (1,1) to (2,1), which is a commit that advanced; and a
cell editor whose seed changes from `4` to `47` on the same cell, which is a
re-opening after a commit (improvise-sx7h's `Tab` in the last column). The
cursor sits at a nonzero row so that "the cell" is not "the first cell".
Rival `any_editor` fails it on the first case.
### a_rejection_that_arrives_in_an_open_editor_stands_beside_it
improvise-1x9h. `socket.rs` writes a rejection's sentence after `refused()`,
outside any fold, so nothing observed it. The test folds `Editing`, then a
`Rejected(NoDocumentPath)`. A refused save rolls nothing back and closes
nothing, so the editor is still open. It asserts that the sentence stands.
- **Rival `direct_write`** (the old `state.view.status_msg = message`): fails
this test. The status is never observed, so it has no owner.
**The DOM half.** `harness/tests/header.spec.js`, "a message written beside an
open minibuffer outlives the timer", is the check on the wire from
`model.status_stands` through `shell.setModel` to `toasts.offer`.
`render_surface.test.js` cannot see that wire: it is a substring search, so it
passes as long as `model.status_stands` appears anywhere in `app.js`. The spec
opens the formula editor in the real demo and delivers
`{"Commands":[{"SetStatus":"Formula error"}]}` as a worker message. It runs
the page clock past ten seconds and asserts that the toast is still visible.
Then it presses `Escape` and asserts that the toast hides. The rival
`onModel: (text, stands) => toasts.offer(text)` fails it at the
still-visible-after-ten-seconds assertion, and the node suite stays green
under that rival.
+107 -3
View File
@@ -2,9 +2,9 @@
//! The two properties this dispatch owes: `context/code/clients.md`. //! The two properties this dispatch owes: `context/code/clients.md`.
use crate::{ use crate::{
BasisCache, CategoryTreeCache, Command, DocumentCache, FormulaCache, GridCache, Projection, BasisCache, CategoryTreeCache, Command, DocumentCache, EditorSession, FormulaCache, GridCache,
Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache, reduce_view, Projection, Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache,
settled_mode, reduce_view, settled_mode,
}; };
use serde::Serialize; use serde::Serialize;
@@ -43,6 +43,12 @@ pub struct ClientState {
/// replay the rest. Why a queue: `context/code/clients.md`. /// replay the rest. Why a queue: `context/code/clients.md`.
#[serde(skip)] #[serde(skip)]
pub in_flight: std::collections::VecDeque<Sent>, pub in_flight: std::collections::VecDeque<Sent>,
/// The status text last observed, and the editor it was written in while
/// that editor is still open (improvise-1x9h, `context/code/clients.md`).
#[serde(skip)]
pub status_seen: String,
#[serde(skip)]
pub status_owner: Option<EditorSession>,
} }
/// One frame this client has sent. `awaited` is true exactly when the host /// One frame this client has sent. `awaited` is true exactly when the host
@@ -75,6 +81,7 @@ impl ClientState {
} }
self.settle_mode(); self.settle_mode();
self.settle_editor(); self.settle_editor();
self.settle_status();
self.revision += 1; self.revision += 1;
} }
@@ -87,6 +94,7 @@ impl ClientState {
} }
self.settle_mode(); self.settle_mode();
self.settle_editor(); self.settle_editor();
self.settle_status();
self.revision += 1; self.revision += 1;
} }
@@ -127,9 +135,26 @@ impl ClientState {
self.last_rejection = Some(rejection); self.last_rejection = Some(rejection);
self.settle_mode(); self.settle_mode();
self.settle_editor(); self.settle_editor();
self.settle_status();
self.revision += 1; self.revision += 1;
} }
/// Say something to this client's user: the one door for a status written
/// outside a fold, so it is observed where it was written.
pub fn notify(&mut self, message: String) {
self.view.status_msg = message;
self.settle_status();
self.revision += 1;
}
/// Whether the status belongs to the editor still open, so a renderer must
/// not expire it (improvise-1x9h). The TUI asks the same `EditorSession`.
pub fn status_stands(&self) -> bool {
!self.view.status_msg.is_empty()
&& self.status_owner.is_some()
&& self.status_owner == EditorSession::of(&self.view)
}
/// The settled slice plus every frame still in flight, in order. What a /// The settled slice plus every frame still in flight, in order. What a
/// dropped frame leaves behind is the *rest* of them, not nothing. /// dropped frame leaves behind is the *rest* of them, not nothing.
fn replay(&mut self) { fn replay(&mut self) {
@@ -177,6 +202,18 @@ impl ClientState {
} }
} }
/// A new message is owned by the editor open as it arrives; an old one loses
/// its owner for good once that editor is not the open one.
fn settle_status(&mut self) {
let now = EditorSession::of(&self.view);
if self.view.status_msg != self.status_seen {
self.status_seen = self.view.status_msg.clone();
self.status_owner = now;
} else if self.status_owner != now {
self.status_owner = None;
}
}
/// The widget dispatch: a projection names the widget it is addressed to. /// The widget dispatch: a projection names the widget it is addressed to.
/// The `CategoryTreeCache`/`TileBarCache` overlap is deliberate (gotcha 32). /// The `CategoryTreeCache`/`TileBarCache` overlap is deliberate (gotcha 32).
fn apply_projection(&mut self, projection: &Projection) { fn apply_projection(&mut self, projection: &Projection) {
@@ -435,6 +472,73 @@ mod tests {
); );
} }
/// improvise-1x9h: a message written while an editor is open stands until
/// that editor closes, and never again after. Evidence file.
#[test]
fn a_message_stands_while_the_editor_it_was_written_in_is_open() {
let mut state = ClientState::default();
state.fold(&Command::ChangeMode(crate::AppMode::formula_edit()));
state.fold(&Command::SetStatus("Formula error".into()));
assert!(
state.status_stands(),
"a refusal beside its editor must stand"
);
state.fold(&Command::SetBuffer {
name: "formula".into(),
value: "Total = ".into(),
});
assert!(state.status_stands(), "typing is the same editor");
state.fold(&Command::ChangeMode(crate::AppMode::FormulaPanel));
assert!(!state.status_stands(), "the refusal outlived its editor");
state.fold(&Command::ChangeMode(crate::AppMode::formula_edit()));
assert_eq!(state.view.status_msg, "Formula error", "nothing cleared it");
assert!(
!state.status_stands(),
"an equal editor opened later is another opening, and the message is not its"
);
}
/// The editor is the one it was WRITTEN in: not any editor, and not the next
/// cell's after a commit advances (improvise-1x9h, sx7h). Evidence file.
#[test]
fn a_message_does_not_stand_beside_an_editor_it_was_not_written_in() {
let mut state = ClientState::default();
state.fold(&Command::SetStatus("saved".into()));
state.fold(&Command::ChangeMode(crate::AppMode::editing()));
assert!(
!state.status_stands(),
"written in Normal, it is the timer's"
);
let mut state = ClientState::default();
state.fold(&Command::SetSelected { row: 1, col: 1 });
state.fold(&Command::ChangeMode(crate::AppMode::editing()));
state.fold(&Command::SetStatus("a note about this cell".into()));
assert!(state.status_stands());
state.fold(&Command::SetSelected { row: 2, col: 1 });
assert!(
!state.status_stands(),
"the note followed the editor down a row"
);
let mut state = ClientState::default();
state.fold(&Command::SetSelected { row: 1, col: 1 });
state.fold(&Command::ChangeMode(crate::AppMode::editing()));
state.fold(&Command::SetOpenedEditor(Some(crate::OpenedEditor::on(
"4".into(),
))));
state.fold(&Command::SetStatus("a note about this cell".into()));
assert!(state.status_stands());
state.fold(&Command::SetOpenedEditor(Some(crate::OpenedEditor::on(
"47".into(),
))));
assert!(
!state.status_stands(),
"a re-opening on the same cell is another editor"
);
}
#[test] #[test]
fn every_fold_bumps_the_revision_so_a_renderer_can_poll_it() { fn every_fold_bumps_the_revision_so_a_renderer_can_poll_it() {
let mut state = ClientState::default(); let mut state = ClientState::default();
+21
View File
@@ -554,6 +554,27 @@ impl ViewState {
} }
} }
/// What one opening of a minibuffer sets. Equal at two moments means the same
/// editor is still open; both front ends ask this. `context/code/tui.md`.
#[derive(Debug, Clone, PartialEq)]
pub struct EditorSession {
mode: AppMode,
cell: (usize, usize),
seed: Option<String>,
}
impl EditorSession {
/// The editor open in `view`, or `None` outside a text-entry mode.
pub fn of(view: &ViewState) -> Option<Self> {
view.mode.minibuffer()?;
Some(Self {
mode: view.mode.clone(),
cell: view.cursor().selected,
seed: view.opened_editor.as_ref().map(|o| o.seed.clone()),
})
}
}
#[cfg(test)] #[cfg(test)]
mod boundary_tests { mod boundary_tests {
/// improvise-cqi AC 2: the part of the boundary the compiler can hold. /// improvise-cqi AC 2: the part of the boundary the compiler can hold.
@@ -16,6 +16,9 @@ pub struct RenderModel {
/// The mode's own label, off `AppMode` — the client does not spell it. /// The mode's own label, off `AppMode` — the client does not spell it.
pub mode: String, pub mode: String,
pub status: String, pub status: String,
/// Whether `status` belongs to the editor still open, so the page must not
/// expire it on a timer (improvise-1x9h).
pub status_stands: bool,
/// The document's active view, as this client knows it (gotcha 29). /// The document's active view, as this client knows it (gotcha 29).
pub view_name: String, pub view_name: String,
/// The title bar: the document's name, whether it has unsaved changes, and /// The title bar: the document's name, whether it has unsaved changes, and
@@ -238,6 +241,7 @@ pub fn render_model(state: &ClientState) -> RenderModel {
revision: state.revision, revision: state.revision,
mode: improvise_protocol::mode_label(&state.view.mode).to_string(), mode: improvise_protocol::mode_label(&state.view.mode).to_string(),
status: state.view.status_msg.clone(), status: state.view.status_msg.clone(),
status_stands: state.status_stands(),
view_name: state.view.active_view.clone(), view_name: state.view.active_view.clone(),
doc_name: state.document.name.clone(), doc_name: state.document.name.clone(),
doc_dirty: state.document.dirty, doc_dirty: state.document.dirty,
+21 -1
View File
@@ -95,7 +95,7 @@ impl Client {
// The sentence goes on *after* the rollback: it is this // The sentence goes on *after* the rollback: it is this
// client's own notification, and the slice it restores has none. // client's own notification, and the slice it restores has none.
state.refused(rejection); state.refused(rejection);
state.view.status_msg = message; state.notify(message);
} }
ServerFrame::Accepted => self.state_mut().accepted(), ServerFrame::Accepted => self.state_mut().accepted(),
} }
@@ -195,6 +195,26 @@ mod tests {
); );
} }
/// A rejection's sentence is written outside a fold, so it must still be owned
/// by the editor it arrived in (improvise-1x9h). Evidence file.
#[test]
fn a_rejection_that_arrives_in_an_open_editor_stands_beside_it() {
let mut client = Client::default();
client
.state_mut()
.fold(&Command::ChangeMode(improvise_protocol::AppMode::editing()));
client.fold_frame(ServerFrame::Rejected(Rejection::NoDocumentPath));
assert!(
client.state().view.mode.is_editing(),
"a refused save closes nothing"
);
assert!(!client.state().view.status_msg.is_empty());
assert!(
client.state().status_stands(),
"the refusal was not observed where it arrived, so it would expire"
);
}
/// A rejection that refused an **edit** must say nothing was applied; the /// A rejection that refused an **edit** must say nothing was applied; the
/// two `Save` answers refused none. Evidence: clients.md (improvise-8uj8). /// two `Save` answers refused none. Evidence: clients.md (improvise-8uj8).
#[test] #[test]
+34
View File
@@ -182,4 +182,38 @@ test.describe("the app bar is reserved, not fitted", () => {
const toast = await page.locator("#toast").boundingBox(); const toast = await page.locator("#toast").boundingBox();
expect(toast.y + toast.height).toBeLessThanOrEqual(alone.y); expect(toast.y + toast.height).toBeLessThanOrEqual(alone.y);
}); });
// improvise-1x9h. Whether a message stands is Rust's answer; this is the
// wire from the render model to the toast's timer, which node cannot see.
test("a message written beside an open minibuffer outlives the timer", async ({ page }) => {
await page.addInitScript(() => {
const Real = window.Worker;
window.Worker = class extends Real {
constructor(...args) {
super(...args);
window.__worker = this;
}
};
});
await page.clock.install();
await theDemo(page);
await page.keyboard.press("F");
await page.keyboard.press("n");
await expect(page.locator("#mode")).toHaveText("FORMULA");
// A host message arriving during text entry has no keyboard route in the
// demo, so the frame is delivered as the worker would deliver it.
await page.evaluate(() => {
const frame = JSON.stringify({ Commands: [{ SetStatus: "Formula error" }] });
window.__worker.dispatchEvent(new MessageEvent("message", { data: frame }));
});
await expect(page.locator("#toast")).toContainText("Formula error");
await page.clock.runFor(10_000);
await expect(page.locator("#toast")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator("#mode")).toHaveText("FORMULAS");
await expect(page.locator("#toast")).toBeHidden();
});
}); });
+8 -30
View File
@@ -17,6 +17,7 @@ use ratatui::{
}; };
use crate::command::key; use crate::command::key;
use crate::protocol::EditorSession;
use crate::ui::app::{App, AppMode, MinibufColor}; use crate::ui::app::{App, AppMode, MinibufColor};
use crate::ui::category_panel::CategoryContent; use crate::ui::category_panel::CategoryContent;
use crate::ui::formula_panel::FormulaContent; use crate::ui::formula_panel::FormulaContent;
@@ -102,41 +103,17 @@ pub fn run_tui(
/// One keypress in the TUI. A message lasts until the next key (improvise-cq3d), /// One keypress in the TUI. A message lasts until the next key (improvise-cq3d),
/// except while one editor stays open under it (su4u, sx7h). `context/code/tui.md`. /// except while one editor stays open under it (su4u, sx7h). `context/code/tui.md`.
fn press(app: &mut App, key: key::KeyEvent) -> Result<()> { fn press(app: &mut App, key: key::KeyEvent) -> Result<()> {
let before = EditorSession::of(app); let before = EditorSession::of(&app.view_state);
let stale = app.view_state.status_msg.clone(); let stale = app.view_state.status_msg.clone();
app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(String::new()))]); app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(String::new()))]);
app.handle_key(key)?; app.handle_key(key)?;
let still_editing = before.is_some() && before == EditorSession::of(app); let still_editing = before.is_some() && before == EditorSession::of(&app.view_state);
if still_editing && app.view_state.status_msg.is_empty() && !stale.is_empty() { if still_editing && app.view_state.status_msg.is_empty() && !stale.is_empty() {
app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(stale))]); app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(stale))]);
} }
Ok(()) Ok(())
} }
/// What one opening of a minibuffer sets, which is all the TUI loop can see of
/// it: equal before and after a key means the same editor (improvise-sx7h).
#[derive(Debug, PartialEq)]
struct EditorSession {
mode: AppMode,
cell: (usize, usize),
seed: Option<String>,
}
impl EditorSession {
fn of(app: &App) -> Option<Self> {
app.view_state.mode.minibuffer()?;
Some(Self {
mode: app.view_state.mode.clone(),
cell: app.cursor().selected,
seed: app
.view_state
.opened_editor
.as_ref()
.map(|o| o.seed.clone()),
})
}
}
// ── Key event conversion (TUI boundary) ────────────────────────────────────── // ── Key event conversion (TUI boundary) ──────────────────────────────────────
// The only place crossterm's key types and `command::key` may both appear. // The only place crossterm's key types and `command::key` may both appear.
@@ -604,8 +581,9 @@ fn draw_welcome(f: &mut Frame, area: Rect) {
#[cfg(test)] #[cfg(test)]
mod bottom_bar_tests { mod bottom_bar_tests {
use super::{EditorSession, draw_bottom_bar, minibuffer_line, press}; use super::{draw_bottom_bar, minibuffer_line, press};
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers}; use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
use crate::protocol::EditorSession;
use crate::ui::app::{App, AppMode}; use crate::ui::app::{App, AppMode};
use crate::workbook::Workbook; use crate::workbook::Workbook;
use ratatui::{Terminal, backend::TestBackend, layout::Rect}; use ratatui::{Terminal, backend::TestBackend, layout::Rect};
@@ -837,14 +815,14 @@ mod bottom_bar_tests {
let mut app = App::new(Workbook::new("T"), None); let mut app = App::new(Workbook::new("T"), None);
let item_add = |category: &str| AppMode::item_add(category.into()); let item_add = |category: &str| AppMode::item_add(category.into());
app.view_state.mode = item_add("Region"); app.view_state.mode = item_add("Region");
let region = EditorSession::of(&app); let region = EditorSession::of(&app.view_state);
assert!(region.is_some(), "ItemAdd is a text-entry mode"); assert!(region.is_some(), "ItemAdd is a text-entry mode");
app.view_state.mode = item_add("Month"); app.view_state.mode = item_add("Month");
assert_ne!(region, EditorSession::of(&app)); assert_ne!(region, EditorSession::of(&app.view_state));
app.view_state.mode = item_add("Region"); app.view_state.mode = item_add("Region");
assert_eq!( assert_eq!(
region, region,
EditorSession::of(&app), EditorSession::of(&app.view_state),
"and the same one is the same" "and the same one is the same"
); );
} }
+2 -2
View File
@@ -112,7 +112,7 @@ const shell = createShell({
// it stays reachable rather than being lost to the cap. // it stays reachable rather than being lost to the cap.
el.status.title = text; el.status.title = text;
}, },
onModel: (text) => toasts.offer(text), onModel: (text, stands) => toasts.offer(text, stands),
timeoutMs: HANDSHAKE_TIMEOUT_MS, timeoutMs: HANDSHAKE_TIMEOUT_MS,
}); });
// The harness asks whether the watchdog is ARMED, because a negative assertion // The harness asks whether the watchdog is ARMED, because a negative assertion
@@ -268,7 +268,7 @@ function repaint() {
el.hint.textContent = model.hint; el.hint.textContent = model.hint;
paintHelp(model); paintHelp(model);
paintMinibuffer(model); paintMinibuffer(model);
shell.setModel(model.status); shell.setModel(model.status, model.status_stands);
el.viewName.textContent = model.view_name; el.viewName.textContent = model.view_name;
// The title bar, off the cache `src/draw.rs::draw_title` folds — one // The title bar, off the cache `src/draw.rs::draw_title` folds — one
// derivation, both front ends (improvise-z754, improvise-5jna). // derivation, both front ends (improvise-z754, improvise-5jna).
+18 -5
View File
@@ -16,7 +16,10 @@ export const TOAST_MS = 6000;
/** `ViewState::status_msg` is STATE, not an event: a repaint re-offers the /** `ViewState::status_msg` is STATE, not an event: a repaint re-offers the
* string that is already on screen, so dismissal keys off message IDENTITY * string that is already on screen, so dismissal keys off message IDENTITY
* and never off a timer alone. What that costs: context/code/clients.md. */ * and never off a timer alone. What that costs: context/code/clients.md.
*
* `stands` is Rust's answer (`RenderModel::status_stands`): while it holds the
* message is on no timer, and when it stops the message goes (improvise-1x9h). */
export function createToasts({ export function createToasts({
onShow, onShow,
onHide, onHide,
@@ -28,12 +31,21 @@ export function createToasts({
// mechanism: a repaint offering it again is recognised and dropped. // mechanism: a repaint offering it again is recognised and dropped.
let offered = ""; let offered = "";
let timer = null; let timer = null;
let standing = false;
return { return {
offer(text) { offer(text, stands = false) {
const next = text ?? ""; const next = text ?? "";
if (next === offered) return; if (next === offered) {
// Standing ends once: the editor it belonged to has closed.
if (standing && !stands) {
standing = false;
onHide();
}
return;
}
offered = next; offered = next;
standing = next !== "" && stands;
clearTimer(timer); clearTimer(timer);
timer = null; timer = null;
if (next === "") { if (next === "") {
@@ -41,6 +53,7 @@ export function createToasts({
return; return;
} }
onShow(next); onShow(next);
if (standing) return;
timer = setTimer(() => { timer = setTimer(() => {
timer = null; timer = null;
onHide(); onHide();
@@ -349,8 +362,8 @@ export function createShell({
setShell(text) { setShell(text) {
onShell(text); onShell(text);
}, },
setModel(text) { setModel(text, stands = false) {
onModel(text); onModel(text, stands);
}, },
/** A peer that never answers the handshake. Deliberately about the *first /** A peer that never answers the handshake. Deliberately about the *first
* frame*, not either peer's own failure modes — clients.md. */ * frame*, not either peer's own failure modes — clients.md. */
+55
View File
@@ -125,6 +125,61 @@ test("a host that clears the status hides the toast without waiting", () => {
assert.equal(clock.count(), 0, "a cleared message leaves no timer behind"); assert.equal(clock.count(), 0, "a cleared message leaves no timer behind");
}); });
/// improvise-1x9h: a message that stands beside an open editor is the user's
/// until the editor closes, not the timer's. Evidence: clients.md.
test("a standing message outlives the timer and goes with its editor", () => {
const clock = fakeClock();
const shown = [];
let visible = false;
const toasts = createToasts({
onShow: (t) => {
shown.push(t);
visible = true;
},
onHide: () => {
visible = false;
},
ttlMs: 6000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
toasts.offer("Formula error", true);
assert.equal(clock.count(), 0, "a standing message arms no timer");
clock.fire();
assert.equal(visible, true, "the refusal hid while its editor was open");
toasts.offer("Formula error", true);
assert.deepEqual(shown, ["Formula error"], "a repaint is not a second event");
toasts.offer("Formula error", false);
assert.equal(visible, false, "the refusal outlived its editor");
assert.equal(clock.count(), 0, "leaving hides now, not on a timer");
toasts.offer("Formula error", true);
assert.equal(visible, false, "a message that stopped standing came back");
});
/// The other direction: a message that does not stand is on the timer exactly
/// as before (improvise-1x9h).
test("a message that does not stand keeps its timer", () => {
const clock = fakeClock();
let visible = false;
const toasts = createToasts({
onShow: () => {
visible = true;
},
onHide: () => {
visible = false;
},
ttlMs: 6000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
toasts.offer("Formula added", false);
assert.equal(clock.count(), 1);
clock.fire();
assert.equal(visible, false);
});
test("an empty status on the first frame shows nothing", () => { test("an empty status on the first frame shows nothing", () => {
// The common case: every repaint before any command offers "". A feed that // The common case: every repaint before any command offers "". A feed that
// treated the first offer as new would flash an empty toast on load. // treated the first offer as new would flash an empty toast on load.