runtime: extract the indicator debounce into a tested state machine

The validity-indicator debounce was two locals in the event
loop (indicator_pending + app.input_indicator) with no unit
coverage — ADR-0027's as-built notes flag it as untested async
glue. The decision logic is now an IndicatorDebounce struct:
note_event (a keystroke hides + arms; non-key events leave it
be), settle (the quiet window elapsed → show the verdict +
disarm), is_armed (drives the recv timeout), visible (mirrored
into app.input_indicator for the renderer).

No behaviour change — the tokio timer and terminal stay in the
loop. 7 unit tests cover the debounce contract: the keystroke /
settle cycle, clean verdicts, and that a background event
mid-typing does not cancel the owed recompute. 1125 passing,
clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-19 09:44:28 +00:00
parent 400fb71460
commit c1c9f6cbc4
+149 -14
View File
@@ -33,6 +33,7 @@ use crate::db::{
DropColumnResult, InsertResult, TableDescription, UpdateResult,
};
use crate::dsl::Command;
use crate::dsl::walker::Severity;
use crate::event::AppEvent;
use crate::project::{
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
@@ -48,6 +49,58 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
/// reappears once typing stops (ADR-0027 §3).
const INDICATOR_DEBOUNCE: Duration = Duration::from_millis(1000);
/// The input-validity indicator's debounce state machine
/// (ADR-0027 §3, step E).
///
/// A keystroke hides the indicator and arms the debounce; the
/// event loop time-boxes `recv` while `is_armed`, so once
/// typing has paused for `INDICATOR_DEBOUNCE` it calls `settle`
/// with the freshly computed verdict. The `tokio` timer and the
/// terminal live in the loop — this owns only the decision
/// logic, keeping the debounce contract unit-testable without
/// an event loop. `App::input_indicator` mirrors `visible` for
/// the renderer.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct IndicatorDebounce {
/// What the indicator currently shows; `None` is hidden.
visible: Option<Severity>,
/// True while a recompute is owed — only then does the
/// event loop time-box `recv`.
armed: bool,
}
impl IndicatorDebounce {
/// The indicator value the renderer should mirror.
const fn visible(self) -> Option<Severity> {
self.visible
}
/// Whether the event loop should time-box `recv` with the
/// debounce window.
const fn is_armed(self) -> bool {
self.armed
}
/// Record a processed event. A keystroke hides the
/// indicator and (re)arms the debounce; any other event
/// leaves the indicator and the armed state untouched (a
/// background result arriving mid-typing must not cancel
/// the pending recompute).
const fn note_event(&mut self, is_key: bool) {
if is_key {
self.visible = None;
self.armed = true;
}
}
/// The debounce window elapsed — typing has paused. Show
/// `verdict` and disarm.
const fn settle(&mut self, verdict: Option<Severity>) {
self.visible = verdict;
self.armed = false;
}
}
/// Run the application until a `Quit` action is enacted or the
/// terminal closes.
pub async fn run(args: Args) -> Result<()> {
@@ -280,20 +333,21 @@ async fn run_loop(
info!("entering main event loop");
// ADR-0027 §3: the validity indicator is debounced — hidden
// on every keystroke, recomputed and shown once typing has
// paused for `INDICATOR_DEBOUNCE`. `indicator_pending` is
// true while a recompute is owed; only then is `recv`
// time-boxed, so an idle session still does no wake-ups.
let mut indicator_pending = false;
// paused for `INDICATOR_DEBOUNCE`. `recv` is time-boxed only
// while the debounce is armed, so an idle session still does
// no wake-ups. See `IndicatorDebounce` for the decision
// logic; `app.input_indicator` mirrors it for the renderer.
let mut debounce = IndicatorDebounce::default();
loop {
let event = if indicator_pending {
let event = if debounce.is_armed() {
match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await {
Ok(Some(event)) => event,
Ok(None) => break,
Err(_elapsed) => {
// Typing has been quiet for the debounce
// interval — settle the indicator.
app.input_indicator = app.input_validity_verdict();
indicator_pending = false;
debounce.settle(app.input_validity_verdict());
app.input_indicator = debounce.visible();
terminal
.draw(|f| ui::render(&mut app, &theme, f))
.context("redraw")?;
@@ -413,13 +467,11 @@ async fn run_loop(
}
}
}
if is_key {
// A keystroke hides the indicator and re-arms the
// debounce (ADR-0027 §3) — it reappears once typing
// pauses.
app.input_indicator = None;
indicator_pending = true;
}
// A keystroke hides the indicator and re-arms the
// debounce (ADR-0027 §3) — it reappears once typing
// pauses; non-key events leave it untouched.
debounce.note_event(is_key);
app.input_indicator = debounce.visible();
terminal
.draw(|f| ui::render(&mut app, &theme, f))
.context("redraw")?;
@@ -1791,3 +1843,86 @@ fn teardown_terminal(
terminal.show_cursor().context("show cursor")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::IndicatorDebounce;
use crate::dsl::walker::Severity;
#[test]
fn starts_hidden_and_disarmed() {
let d = IndicatorDebounce::default();
assert_eq!(d.visible(), None);
assert!(!d.is_armed());
}
#[test]
fn a_keystroke_hides_the_indicator_and_arms_the_debounce() {
let mut d = IndicatorDebounce::default();
d.settle(Some(Severity::Error));
assert_eq!(d.visible(), Some(Severity::Error));
d.note_event(true);
assert_eq!(d.visible(), None, "a keystroke hides the indicator");
assert!(d.is_armed(), "a keystroke arms the debounce");
}
#[test]
fn settling_shows_the_verdict_and_disarms() {
let mut d = IndicatorDebounce::default();
d.note_event(true);
assert!(d.is_armed());
d.settle(Some(Severity::Warning));
assert_eq!(d.visible(), Some(Severity::Warning));
assert!(!d.is_armed(), "a settled indicator owes no recompute");
}
#[test]
fn settling_a_clean_verdict_shows_nothing() {
let mut d = IndicatorDebounce::default();
d.note_event(true);
d.settle(None);
assert_eq!(d.visible(), None);
assert!(!d.is_armed());
}
#[test]
fn a_non_key_event_does_not_disturb_a_shown_indicator() {
let mut d = IndicatorDebounce::default();
d.settle(Some(Severity::Error));
d.note_event(false);
assert_eq!(
d.visible(),
Some(Severity::Error),
"a non-key event leaves the indicator shown",
);
assert!(!d.is_armed(), "a non-key event does not arm the debounce");
}
#[test]
fn a_non_key_event_while_armed_keeps_the_debounce_armed() {
// A background event (a DSL result, a tables refresh)
// arriving mid-typing must not cancel the pending
// recompute.
let mut d = IndicatorDebounce::default();
d.note_event(true);
assert!(d.is_armed());
d.note_event(false);
assert!(d.is_armed(), "the owed recompute survives a non-key event");
assert_eq!(d.visible(), None, "and the indicator stays hidden");
}
#[test]
fn typing_resumes_after_a_settle() {
// The full cycle: type → settle → type again → settle.
let mut d = IndicatorDebounce::default();
d.note_event(true);
d.settle(Some(Severity::Warning));
assert_eq!(d.visible(), Some(Severity::Warning));
d.note_event(true);
assert_eq!(d.visible(), None, "new typing hides the indicator again");
assert!(d.is_armed());
d.settle(None);
assert_eq!(d.visible(), None);
assert!(!d.is_armed());
}
}