A TUI status message lasts until the next key, then the hint returns
improvise-cq3d: nothing in production cleared status_msg, and draw_status
prints it instead of the derived hint, so after the first message of a
session the TUI never drew the key hint again, in any mode.
run_tui now sends each key through draw.rs::press, which applies
SetStatus("") and then dispatches: a message a key produces is drawn after
that key and expires at the next one, vim-style, so an error is never
wiped before it is drawn. The expiry lives in the TUI front end rather than
App::dispatch_key, which the lockstep harness shares with the browser. The
browser already shows messages transiently (a TOAST_MS toast) beside an
always-drawn #hint. Reasoning: context/code/tui.md.
The test drives press: a stale message must give way to TileSelect's hint,
a message must survive its own key, and give way at the next. Red at base
at the first step; clearing after dispatch fails the second.
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:
@@ -379,3 +379,48 @@ again.
|
||||
hint is width-constrained and the history lives on help page 5 under "View
|
||||
history" with both halves. That is the #149 lesson — a hint that does not fit is
|
||||
never drawn, so the test pinning the widget cannot see it.
|
||||
|
||||
## A status message lasts until the next key (improvise-cq3d)
|
||||
|
||||
`draw_status` draws `status_msg` *instead of* the mode's derived hint, and
|
||||
nothing in production ever cleared `status_msg`: it was only replaced by the next
|
||||
message. So after the first "Saved", "Loaded" or error of a session, the derived
|
||||
key hint (the one key table on screen, improvise-rr8n) was never drawn again, in
|
||||
any mode.
|
||||
|
||||
**The rule: a message expires at the start of the next keypress.** `draw.rs::press`
|
||||
applies `SetStatus("")` and then dispatches the key, so:
|
||||
|
||||
- a message a key produces is drawn after that key, whatever it is, errors
|
||||
included. It stays until the user presses something, which is the moment they
|
||||
have acted on it. An error is never wiped before it has been drawn.
|
||||
- a message that arrives outside a key, such as one from startup or an import,
|
||||
also stands until the next key.
|
||||
- the next key clears it *before* its own effects run. So a key that writes a
|
||||
new message shows that message, and a key that writes none shows the hint.
|
||||
|
||||
**Rejected alternatives.**
|
||||
|
||||
- *Expire on mode change.* This fails the ticket's own case: a message written in
|
||||
Normal would stand in Normal indefinitely.
|
||||
- *Expire on the next successful command.* Effects carry no notion of success,
|
||||
and a failed command would leave the stale message in place. That is exactly
|
||||
the case where the user most needs the hint back.
|
||||
- *Clear after dispatch.* Every message would be wiped before it is drawn. The
|
||||
test's middle step exists to catch that.
|
||||
- *Compose message and hint on one line.* A long message still eats the hint, and
|
||||
the stale message still stands forever.
|
||||
|
||||
**Why here and not in `App::dispatch_key`.** `dispatch_key` is shared with the
|
||||
lockstep harness (`reduce_key`), which compares `status_msg` on the host and the
|
||||
browser after every key. Expiring there without changing the browser's resolver
|
||||
too would make the two ends disagree. The expiry is also a presentation decision
|
||||
about the TUI's one-line status row, so it belongs to the TUI front end.
|
||||
|
||||
**The browser already behaves this way.** Its `status_msg` is state that is never
|
||||
cleared either, but `web/app.js` shows it in a toast that hides after
|
||||
`TOAST_MS` (`web/shell.js::createToasts`), and `#hint` is a separate element that
|
||||
is always drawn. So a browser user sees a message briefly and never loses the
|
||||
hint. The TUI now does the same, on a keypress rather than a timer, because a
|
||||
terminal status row has one line to share and a keypress is the event the TUI
|
||||
already has.
|
||||
|
||||
@@ -870,3 +870,26 @@ exactly, and first asserts that hint is non-empty and pages.
|
||||
the protocol keymap). The test then fails: the copy still advertises `n`,
|
||||
which the keymap no longer binds. That rebinding case is exactly the one the
|
||||
ticket said the old footer would lie through.
|
||||
|
||||
### a_status_message_gives_way_to_the_hint_on_the_next_key
|
||||
|
||||
**What broke (improvise-cq3d).** Nothing in production ever cleared
|
||||
`status_msg`, and `draw_status` prints it instead of the derived hint. After the
|
||||
first message of a session, the TUI never drew the key hint again, in any mode.
|
||||
|
||||
**The case, in three steps, each guarding one direction.**
|
||||
1. A standing "Saved to x.improv", then `T`: in TileSelect, a mode other than
|
||||
the one the message was written in, the row must hold TileSelect's hint and
|
||||
not the stale message. This is the ticket's assertion.
|
||||
2. `Space` cycles an axis and writes a message: that message must be on the row
|
||||
after its own key. This guards against expiring too eagerly.
|
||||
3. `Esc` back to Normal: the axis message is gone and Normal's hint is drawn.
|
||||
|
||||
It drives keys through `draw.rs::press`, the function `run_tui` calls, rather
|
||||
than `App::handle_key`, because the expiry is the front end's rule.
|
||||
|
||||
**Red, and the rival.**
|
||||
- With `press` delegating straight to `handle_key` (the old behaviour), it fails
|
||||
at step 1: `" TILES Saved to x.improv …"`.
|
||||
- With the expiry moved *after* dispatch, it fails at step 2: "the axis cycle
|
||||
must say what it did", since every message is wiped before it can be drawn.
|
||||
|
||||
+50
-2
@@ -79,7 +79,7 @@ pub fn run_tui(
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) => {
|
||||
app.handle_key(convert_key_event(key))?;
|
||||
press(&mut app, convert_key_event(key))?;
|
||||
}
|
||||
Event::Resize(w, h) => {
|
||||
app.term_width = w;
|
||||
@@ -99,6 +99,13 @@ pub fn run_tui(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One keypress in the TUI. A message lasts until the next key, so it is drawn
|
||||
/// once and then gives way to the hint (improvise-cq3d, `context/code/tui.md`).
|
||||
fn press(app: &mut App, key: key::KeyEvent) -> Result<()> {
|
||||
app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(String::new()))]);
|
||||
app.handle_key(key)
|
||||
}
|
||||
|
||||
// ── Key event conversion (TUI boundary) ──────────────────────────────────────
|
||||
|
||||
// The only place crossterm's key types and `command::key` may both appear.
|
||||
@@ -566,7 +573,8 @@ fn draw_welcome(f: &mut Frame, area: Rect) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod bottom_bar_tests {
|
||||
use super::{draw_bottom_bar, minibuffer_line};
|
||||
use super::{draw_bottom_bar, minibuffer_line, press};
|
||||
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use crate::ui::app::{App, AppMode};
|
||||
use crate::workbook::Workbook;
|
||||
use ratatui::{Terminal, backend::TestBackend, layout::Rect};
|
||||
@@ -587,6 +595,46 @@ mod bottom_bar_tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A message lasts until the next key, then the derived hint is back
|
||||
/// (improvise-cq3d). Why each step: `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn a_status_message_gives_way_to_the_hint_on_the_next_key() {
|
||||
let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.view_state.status_msg = "Saved to x.improv".to_string();
|
||||
|
||||
press(&mut app, key(KeyCode::Char('T'))).unwrap();
|
||||
assert!(matches!(app.view_state.mode, AppMode::TileSelect));
|
||||
let row = bottom_row(&app, 240);
|
||||
assert!(
|
||||
!row.contains("Saved to"),
|
||||
"a stale message outlived a key: {row:?}"
|
||||
);
|
||||
assert!(
|
||||
row.contains(&app.hint_text()),
|
||||
"no hint in TileSelect: {row:?}"
|
||||
);
|
||||
|
||||
press(&mut app, key(KeyCode::Char(' '))).unwrap();
|
||||
let said = app.view_state.status_msg.clone();
|
||||
assert!(!said.is_empty(), "the axis cycle must say what it did");
|
||||
assert!(
|
||||
bottom_row(&app, 240).contains(&said),
|
||||
"a message was wiped before it was drawn"
|
||||
);
|
||||
|
||||
press(&mut app, key(KeyCode::Esc)).unwrap();
|
||||
let row = bottom_row(&app, 240);
|
||||
assert!(
|
||||
!row.contains(&said),
|
||||
"the axis message outlived the next key: {row:?}"
|
||||
);
|
||||
assert!(
|
||||
row.contains(&app.hint_text()),
|
||||
"no hint back in Normal: {row:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The wizard overlay draws correct per-step hints; the status bar stacked a
|
||||
/// second static table under it (improvise-rr8n) — `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user