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:
+149
-14
@@ -33,6 +33,7 @@ use crate::db::{
|
|||||||
DropColumnResult, InsertResult, TableDescription, UpdateResult,
|
DropColumnResult, InsertResult, TableDescription, UpdateResult,
|
||||||
};
|
};
|
||||||
use crate::dsl::Command;
|
use crate::dsl::Command;
|
||||||
|
use crate::dsl::walker::Severity;
|
||||||
use crate::event::AppEvent;
|
use crate::event::AppEvent;
|
||||||
use crate::project::{
|
use crate::project::{
|
||||||
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
|
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).
|
/// reappears once typing stops (ADR-0027 §3).
|
||||||
const INDICATOR_DEBOUNCE: Duration = Duration::from_millis(1000);
|
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
|
/// Run the application until a `Quit` action is enacted or the
|
||||||
/// terminal closes.
|
/// terminal closes.
|
||||||
pub async fn run(args: Args) -> Result<()> {
|
pub async fn run(args: Args) -> Result<()> {
|
||||||
@@ -280,20 +333,21 @@ async fn run_loop(
|
|||||||
info!("entering main event loop");
|
info!("entering main event loop");
|
||||||
// ADR-0027 §3: the validity indicator is debounced — hidden
|
// ADR-0027 §3: the validity indicator is debounced — hidden
|
||||||
// on every keystroke, recomputed and shown once typing has
|
// on every keystroke, recomputed and shown once typing has
|
||||||
// paused for `INDICATOR_DEBOUNCE`. `indicator_pending` is
|
// paused for `INDICATOR_DEBOUNCE`. `recv` is time-boxed only
|
||||||
// true while a recompute is owed; only then is `recv`
|
// while the debounce is armed, so an idle session still does
|
||||||
// time-boxed, so an idle session still does no wake-ups.
|
// no wake-ups. See `IndicatorDebounce` for the decision
|
||||||
let mut indicator_pending = false;
|
// logic; `app.input_indicator` mirrors it for the renderer.
|
||||||
|
let mut debounce = IndicatorDebounce::default();
|
||||||
loop {
|
loop {
|
||||||
let event = if indicator_pending {
|
let event = if debounce.is_armed() {
|
||||||
match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await {
|
match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await {
|
||||||
Ok(Some(event)) => event,
|
Ok(Some(event)) => event,
|
||||||
Ok(None) => break,
|
Ok(None) => break,
|
||||||
Err(_elapsed) => {
|
Err(_elapsed) => {
|
||||||
// Typing has been quiet for the debounce
|
// Typing has been quiet for the debounce
|
||||||
// interval — settle the indicator.
|
// interval — settle the indicator.
|
||||||
app.input_indicator = app.input_validity_verdict();
|
debounce.settle(app.input_validity_verdict());
|
||||||
indicator_pending = false;
|
app.input_indicator = debounce.visible();
|
||||||
terminal
|
terminal
|
||||||
.draw(|f| ui::render(&mut app, &theme, f))
|
.draw(|f| ui::render(&mut app, &theme, f))
|
||||||
.context("redraw")?;
|
.context("redraw")?;
|
||||||
@@ -413,13 +467,11 @@ async fn run_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if is_key {
|
// A keystroke hides the indicator and re-arms the
|
||||||
// A keystroke hides the indicator and re-arms the
|
// debounce (ADR-0027 §3) — it reappears once typing
|
||||||
// debounce (ADR-0027 §3) — it reappears once typing
|
// pauses; non-key events leave it untouched.
|
||||||
// pauses.
|
debounce.note_event(is_key);
|
||||||
app.input_indicator = None;
|
app.input_indicator = debounce.visible();
|
||||||
indicator_pending = true;
|
|
||||||
}
|
|
||||||
terminal
|
terminal
|
||||||
.draw(|f| ui::render(&mut app, &theme, f))
|
.draw(|f| ui::render(&mut app, &theme, f))
|
||||||
.context("redraw")?;
|
.context("redraw")?;
|
||||||
@@ -1791,3 +1843,86 @@ fn teardown_terminal(
|
|||||||
terminal.show_cursor().context("show cursor")?;
|
terminal.show_cursor().context("show cursor")?;
|
||||||
Ok(())
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user