# Conflicts: # context/test-evidence/clients.md
This commit is contained in:
@@ -2447,6 +2447,72 @@ 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
|
||||
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 wherever the view changes (`settle_status`, beside `settle_mode` and
|
||||
`settle_editor`). That means every applied command, and also every `rebase()`,
|
||||
`refused()` and `bootstrap()`, the three paths that rebuild the view wholesale:
|
||||
|
||||
- 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.
|
||||
|
||||
That clearing has to happen on every path that moves the view, and a relay's
|
||||
rebase is one of them (improvise-0dbk). A relay heard while a frame is in
|
||||
flight rebuilds the view by `rebase()`. If `rebase()` does not settle the
|
||||
status, a rebase that moves the view off the editor leaves the owner in place.
|
||||
When the user returns to an equal editor, the old message stands again.
|
||||
`a_rebase_off_the_editor_ends_the_message_standing` is that case.
|
||||
|
||||
**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 writes it through `ClientState::notify`, which queues
|
||||
the sentence at the tail of `in_flight` through `fold_outgoing`
|
||||
(improvise-n0pb). That path applies it at once and runs `settle_status`, so the
|
||||
editor open at that moment owns the sentence. 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. A relay's rebase would also
|
||||
erase a direct write, which is why improvise-n0pb queues it.
|
||||
|
||||
**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
|
||||
|
||||
`web/style.css` stopped being "legibility only" and is now a small design
|
||||
|
||||
+7
-4
@@ -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
|
||||
test can check.
|
||||
|
||||
**The browser is not covered by this.** There, `status_msg` is a toast that
|
||||
hides after `TOAST_MS` (six seconds), and the minibuffer element draws only the
|
||||
prompt and the text. A refusal therefore disappears on a timer, whatever the
|
||||
user is doing. That is a different mechanism and has not been changed here.
|
||||
**The browser asks the same question (improvise-1x9h).** `EditorSession` now
|
||||
lives in `improvise-protocol` and is built from a `ViewState`, so the TUI's
|
||||
`press` and the browser's `ClientState::status_stands` compare the same value.
|
||||
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".
|
||||
|
||||
@@ -5588,6 +5588,92 @@ branch's switch to the send door, its reopened editor read
|
||||
`touched: true`, the shape of "the reopened editor inherited the previous
|
||||
editor's touched flag". It fails both tests.
|
||||
|
||||
### 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 `notify` this test goes through is #266's (improvise-n0pb), which queues
|
||||
the sentence through `fold_outgoing`. #279 had its own `notify` that wrote the
|
||||
status and settled it directly. Merging the two gave duplicate definitions
|
||||
(E0592), and #279's was deleted (improvise-0dbk). This test is unchanged and
|
||||
passes against the queued `notify`, because `fold_outgoing` applies the
|
||||
sentence and runs `settle_status`.
|
||||
|
||||
### a_rebase_off_the_editor_ends_the_message_standing
|
||||
|
||||
improvise-0dbk, found reviewing #266 against #279. #266's `rebase()` rebuilt
|
||||
the view without running `settle_status`. The probe:
|
||||
|
||||
1. `SetSelected(1,1)`, `ChangeMode(editing)` and `SetStatus`: the note stands.
|
||||
2. An awaited frame goes out, so the next relay rebases instead of applying.
|
||||
3. The relay `ResetCursor` rebases the cursor to (0,0), off the editor. The
|
||||
owner survived this step.
|
||||
4. `SetSelected(1,1)` goes out and returns to an equal editor. `status_stands()`
|
||||
was true: the old note stood beside an opening it was not written in.
|
||||
|
||||
Red at the merge of `9eb83538` and `57b713cf` (with #279's duplicate `notify`
|
||||
removed so that the merge compiles). The failure was "an equal editor after a
|
||||
rebase took the old message back". It is green once `rebase()` calls
|
||||
`settle_status()`.
|
||||
|
||||
- **Rival `rebase_unsettled`** (remove that call): fails only this test.
|
||||
- The assertion that `in_flight` is non-empty after step 2 is what makes step 3
|
||||
a rebase. With nothing in flight the relay is simply applied, and `apply`
|
||||
already settled the status.
|
||||
|
||||
**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.
|
||||
|
||||
### a_relay_behind_a_refused_frame_settles_before_the_next_answer
|
||||
|
||||
improvise-3kfc, found in review of #246 at `542dd9c`. `ClientState::refused`
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! The two properties this dispatch owes: `context/code/clients.md`.
|
||||
|
||||
use crate::{
|
||||
BasisCache, CategoryTreeCache, Command, DocumentCache, FormulaCache, GridCache, Projection,
|
||||
Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache, reduce_view,
|
||||
settled_mode,
|
||||
BasisCache, CategoryTreeCache, Command, DocumentCache, EditorSession, FormulaCache, GridCache,
|
||||
Projection, Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache,
|
||||
reduce_view, settled_mode,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -43,6 +43,12 @@ pub struct ClientState {
|
||||
/// replay the rest. Why a queue: `context/code/clients.md`.
|
||||
#[serde(skip)]
|
||||
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
|
||||
@@ -78,6 +84,7 @@ impl ClientState {
|
||||
}
|
||||
self.settle_mode();
|
||||
self.settle_editor();
|
||||
self.settle_status();
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
@@ -98,6 +105,7 @@ impl ClientState {
|
||||
self.replay();
|
||||
self.settle_mode();
|
||||
self.settle_editor();
|
||||
self.settle_status();
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
@@ -131,6 +139,7 @@ impl ClientState {
|
||||
}
|
||||
self.settle_mode();
|
||||
self.settle_editor();
|
||||
self.settle_status();
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
@@ -181,9 +190,18 @@ impl ClientState {
|
||||
self.last_rejection = Some(rejection);
|
||||
self.settle_mode();
|
||||
self.settle_editor();
|
||||
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
|
||||
/// dropped frame leaves behind is the *rest* of them, not nothing.
|
||||
fn replay(&mut self) {
|
||||
@@ -248,6 +266,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 `CategoryTreeCache`/`TileBarCache` overlap is deliberate (gotcha 32).
|
||||
fn apply_projection(&mut self, projection: &Projection) {
|
||||
@@ -662,6 +692,101 @@ 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"
|
||||
);
|
||||
}
|
||||
|
||||
/// A relay that rebases the view off the editor ends its message's ownership,
|
||||
/// as a fold would (improvise-0dbk). Evidence file.
|
||||
#[test]
|
||||
fn a_rebase_off_the_editor_ends_the_message_standing() {
|
||||
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(), "precondition: the note stands");
|
||||
state.fold_outgoing(&commit_like("unused")[..1]);
|
||||
assert!(
|
||||
!state.in_flight.is_empty(),
|
||||
"the relay must rebase, not apply"
|
||||
);
|
||||
|
||||
state.fold(&Command::ResetCursor);
|
||||
assert_eq!(
|
||||
state.view.cursor().selected,
|
||||
(0, 0),
|
||||
"the rebase moved the cursor"
|
||||
);
|
||||
state.fold_outgoing(&[Command::SetSelected { row: 1, col: 1 }]);
|
||||
assert!(
|
||||
!state.status_stands(),
|
||||
"an equal editor after a rebase took the old message back"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn every_fold_bumps_the_revision_so_a_renderer_can_poll_it() {
|
||||
let mut state = ClientState::default();
|
||||
|
||||
@@ -561,6 +561,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)]
|
||||
mod boundary_tests {
|
||||
/// 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.
|
||||
pub mode: 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).
|
||||
pub view_name: String,
|
||||
/// The title bar: the document's name, whether it has unsaved changes, and
|
||||
@@ -239,6 +242,7 @@ pub fn render_model(state: &ClientState) -> RenderModel {
|
||||
revision: state.revision,
|
||||
mode: improvise_protocol::mode_label(&state.view.mode).to_string(),
|
||||
status: state.view.status_msg.clone(),
|
||||
status_stands: state.status_stands(),
|
||||
view_name: state.view.active_view.clone(),
|
||||
doc_name: state.document.name.clone(),
|
||||
doc_dirty: state.document.dirty,
|
||||
|
||||
@@ -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
|
||||
/// two `Save` answers refused none. Evidence: clients.md (improvise-8uj8).
|
||||
#[test]
|
||||
|
||||
@@ -182,4 +182,38 @@ test.describe("the app bar is reserved, not fitted", () => {
|
||||
const toast = await page.locator("#toast").boundingBox();
|
||||
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
@@ -17,6 +17,7 @@ use ratatui::{
|
||||
};
|
||||
|
||||
use crate::command::key;
|
||||
use crate::protocol::EditorSession;
|
||||
use crate::ui::app::{App, AppMode, MinibufColor};
|
||||
use crate::ui::category_panel::CategoryContent;
|
||||
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),
|
||||
/// except while one editor stays open under it (su4u, sx7h). `context/code/tui.md`.
|
||||
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();
|
||||
app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(String::new()))]);
|
||||
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() {
|
||||
app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(stale))]);
|
||||
}
|
||||
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) ──────────────────────────────────────
|
||||
|
||||
// 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)]
|
||||
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::protocol::EditorSession;
|
||||
use crate::ui::app::{App, AppMode};
|
||||
use crate::workbook::Workbook;
|
||||
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 item_add = |category: &str| AppMode::item_add(category.into());
|
||||
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");
|
||||
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");
|
||||
assert_eq!(
|
||||
region,
|
||||
EditorSession::of(&app),
|
||||
EditorSession::of(&app.view_state),
|
||||
"and the same one is the same"
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -112,7 +112,7 @@ const shell = createShell({
|
||||
// it stays reachable rather than being lost to the cap.
|
||||
el.status.title = text;
|
||||
},
|
||||
onModel: (text) => toasts.offer(text),
|
||||
onModel: (text, stands) => toasts.offer(text, stands),
|
||||
timeoutMs: HANDSHAKE_TIMEOUT_MS,
|
||||
});
|
||||
// The harness asks whether the watchdog is ARMED, because a negative assertion
|
||||
@@ -268,7 +268,7 @@ function repaint() {
|
||||
el.hint.textContent = model.hint;
|
||||
paintHelp(model);
|
||||
paintMinibuffer(model);
|
||||
shell.setModel(model.status);
|
||||
shell.setModel(model.status, model.status_stands);
|
||||
el.viewName.textContent = model.view_name;
|
||||
// The title bar, off the cache `src/draw.rs::draw_title` folds — one
|
||||
// derivation, both front ends (improvise-z754, improvise-5jna).
|
||||
|
||||
+18
-5
@@ -16,7 +16,10 @@ export const TOAST_MS = 6000;
|
||||
|
||||
/** `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
|
||||
* 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({
|
||||
onShow,
|
||||
onHide,
|
||||
@@ -28,12 +31,21 @@ export function createToasts({
|
||||
// mechanism: a repaint offering it again is recognised and dropped.
|
||||
let offered = "";
|
||||
let timer = null;
|
||||
let standing = false;
|
||||
|
||||
return {
|
||||
offer(text) {
|
||||
offer(text, stands = false) {
|
||||
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;
|
||||
standing = next !== "" && stands;
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
if (next === "") {
|
||||
@@ -41,6 +53,7 @@ export function createToasts({
|
||||
return;
|
||||
}
|
||||
onShow(next);
|
||||
if (standing) return;
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
onHide();
|
||||
@@ -349,8 +362,8 @@ export function createShell({
|
||||
setShell(text) {
|
||||
onShell(text);
|
||||
},
|
||||
setModel(text) {
|
||||
onModel(text);
|
||||
setModel(text, stands = false) {
|
||||
onModel(text, stands);
|
||||
},
|
||||
/** A peer that never answers the handshake. Deliberately about the *first
|
||||
* frame*, not either peer's own failure modes — clients.md. */
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
/// 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", () => {
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user