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:
@@ -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
|
||||
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
|
||||
|
||||
`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".
|
||||
|
||||
@@ -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
|
||||
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`.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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
|
||||
@@ -75,6 +81,7 @@ impl ClientState {
|
||||
}
|
||||
self.settle_mode();
|
||||
self.settle_editor();
|
||||
self.settle_status();
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
@@ -87,6 +94,7 @@ impl ClientState {
|
||||
}
|
||||
self.settle_mode();
|
||||
self.settle_editor();
|
||||
self.settle_status();
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
@@ -127,9 +135,26 @@ impl ClientState {
|
||||
self.last_rejection = Some(rejection);
|
||||
self.settle_mode();
|
||||
self.settle_editor();
|
||||
self.settle_status();
|
||||
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
|
||||
/// dropped frame leaves behind is the *rest* of them, not nothing.
|
||||
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 `CategoryTreeCache`/`TileBarCache` overlap is deliberate (gotcha 32).
|
||||
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]
|
||||
fn every_fold_bumps_the_revision_so_a_renderer_can_poll_it() {
|
||||
let mut state = ClientState::default();
|
||||
|
||||
@@ -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)]
|
||||
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
|
||||
@@ -238,6 +241,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,
|
||||
|
||||
@@ -95,7 +95,7 @@ impl Client {
|
||||
// The sentence goes on *after* the rollback: it is this
|
||||
// client's own notification, and the slice it restores has none.
|
||||
state.refused(rejection);
|
||||
state.view.status_msg = message;
|
||||
state.notify(message);
|
||||
}
|
||||
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
|
||||
/// 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