diff --git a/crux_time/src/lib.rs b/crux_time/src/lib.rs index dde9b569..1907ea58 100644 --- a/crux_time/src/lib.rs +++ b/crux_time/src/lib.rs @@ -15,21 +15,32 @@ pub use instant::Instant; use serde::{Deserialize, Serialize}; use crux_core::capability::{CapabilityContext, Operation}; +use std::sync::atomic::{AtomicUsize, Ordering}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum TimeRequest { Now, - NotifyAt(Instant), - NotifyAfter(Duration), + NotifyAt { id: TimerId, instant: Instant }, + NotifyAfter { id: TimerId, duration: Duration }, + Clear { id: TimerId }, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TimerId(pub usize); + +fn get_timer_id() -> TimerId { + static COUNTER: AtomicUsize = AtomicUsize::new(1); + TimerId(COUNTER.fetch_add(1, Ordering::Relaxed)) } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum TimeResponse { Now(Instant), - InstantArrived, - DurationElapsed, + InstantArrived { id: TimerId }, + DurationElapsed { id: TimerId }, + Cleared { id: TimerId }, } impl Operation for TimeRequest { @@ -106,50 +117,66 @@ where } /// Ask to receive a notification when the specified [`Instant`] has arrived. - pub fn notify_at(&self, instant: Instant, callback: F) + pub fn notify_at(&self, instant: Instant, callback: F) -> TimerId where F: FnOnce(TimeResponse) -> Ev + Send + Sync + 'static, { + let tid = get_timer_id(); self.context.spawn({ let context = self.context.clone(); let this = self.clone(); async move { - context.update_app(callback(this.notify_at_async(instant).await)); + context.update_app(callback(this.notify_at_async(tid, instant).await)); } }); + + tid } /// Ask to receive a notification when the specified [`Instant`] has arrived. /// This is an async call to use with [`crux_core::compose::Compose`]. - pub async fn notify_at_async(&self, instant: Instant) -> TimeResponse { + pub async fn notify_at_async(&self, id: TimerId, instant: Instant) -> TimeResponse { self.context - .request_from_shell(TimeRequest::NotifyAt(instant)) + .request_from_shell(TimeRequest::NotifyAt { id, instant }) .await } /// Ask to receive a notification when the specified duration has elapsed. - pub fn notify_after(&self, duration: Duration, callback: F) + pub fn notify_after(&self, duration: Duration, callback: F) -> TimerId where F: FnOnce(TimeResponse) -> Ev + Send + Sync + 'static, { + let tid = get_timer_id(); self.context.spawn({ let context = self.context.clone(); let this = self.clone(); async move { - context.update_app(callback(this.notify_after_async(duration).await)); + context.update_app(callback(this.notify_after_async(tid, duration).await)); } }); + + tid } /// Ask to receive a notification when the specified duration has elapsed. /// This is an async call to use with [`crux_core::compose::Compose`]. - pub async fn notify_after_async(&self, duration: Duration) -> TimeResponse { + pub async fn notify_after_async(&self, id: TimerId, duration: Duration) -> TimeResponse { self.context - .request_from_shell(TimeRequest::NotifyAfter(duration)) + .request_from_shell(TimeRequest::NotifyAfter { id, duration }) .await } + + pub fn clear(&self, id: TimerId) { + self.context.spawn({ + let context = self.context.clone(); + + async move { + context.notify_shell(TimeRequest::Clear { id }).await; + } + }); + } } #[cfg(test)] @@ -166,18 +193,30 @@ mod test { let deserialized: TimeRequest = serde_json::from_str(&serialized).unwrap(); assert_eq!(now, deserialized); - let now = TimeRequest::NotifyAt(Instant::new(1, 2).expect("valid instant")); + let now = TimeRequest::NotifyAt { + id: TimerId(1), + instant: Instant::new(1, 2).expect("valid instant"), + }; let serialized = serde_json::to_string(&now).unwrap(); - assert_eq!(&serialized, r#"{"notifyAt":{"seconds":1,"nanos":2}}"#); + assert_eq!( + &serialized, + r#"{"notifyAt":{"id":1,"instant":{"seconds":1,"nanos":2}}}"# + ); let deserialized: TimeRequest = serde_json::from_str(&serialized).unwrap(); assert_eq!(now, deserialized); - let now = TimeRequest::NotifyAfter(Duration::from_secs(1).expect("valid duration")); + let now = TimeRequest::NotifyAfter { + id: TimerId(2), + duration: Duration::from_secs(1).expect("valid duration"), + }; let serialized = serde_json::to_string(&now).unwrap(); - assert_eq!(&serialized, r#"{"notifyAfter":{"nanos":1000000000}}"#); + assert_eq!( + &serialized, + r#"{"notifyAfter":{"id":2,"duration":{"nanos":1000000000}}}"# + ); let deserialized: TimeRequest = serde_json::from_str(&serialized).unwrap(); assert_eq!(now, deserialized); @@ -193,18 +232,18 @@ mod test { let deserialized: TimeResponse = serde_json::from_str(&serialized).unwrap(); assert_eq!(now, deserialized); - let now = TimeResponse::DurationElapsed; + let now = TimeResponse::DurationElapsed { id: TimerId(1) }; let serialized = serde_json::to_string(&now).unwrap(); - assert_eq!(&serialized, r#""durationElapsed""#); + assert_eq!(&serialized, r#"{"durationElapsed":{"id":1}}"#); let deserialized: TimeResponse = serde_json::from_str(&serialized).unwrap(); assert_eq!(now, deserialized); - let now = TimeResponse::InstantArrived; + let now = TimeResponse::InstantArrived { id: TimerId(2) }; let serialized = serde_json::to_string(&now).unwrap(); - assert_eq!(&serialized, r#""instantArrived""#); + assert_eq!(&serialized, r#"{"instantArrived":{"id":2}}"#); let deserialized: TimeResponse = serde_json::from_str(&serialized).unwrap(); assert_eq!(now, deserialized); diff --git a/crux_time/tests/time_test.rs b/crux_time/tests/time_test.rs index 81072757..ea77f14d 100644 --- a/crux_time/tests/time_test.rs +++ b/crux_time/tests/time_test.rs @@ -3,7 +3,7 @@ mod shared { use chrono::{DateTime, Utc}; use crux_core::macros::Effect; use crux_core::render::Render; - use crux_time::{Time, TimeResponse}; + use crux_time::{Time, TimeResponse, TimerId}; use serde::{Deserialize, Serialize}; #[derive(Default)] @@ -40,6 +40,7 @@ mod shared { pub time: String, debounce: Debounce, pub debounce_complete: bool, + pub debounce_time_id: Option, } #[derive(Serialize, Deserialize, Default)] @@ -73,16 +74,25 @@ mod shared { Event::StartDebounce => { let pending = model.debounce.start(); - caps.time.notify_after( + let tid = caps.time.notify_after( crux_time::Duration::from_millis(300).expect("valid duration"), event_with_user_info(pending, Event::DurationElapsed), ); + + model.debounce_time_id = Some(tid); } - Event::DurationElapsed(pending, TimeResponse::DurationElapsed) => { + Event::DurationElapsed(pending, TimeResponse::DurationElapsed { id: _ }) => { if model.debounce.resolve(pending) { model.debounce_complete = true; } } + Event::DurationElapsed(_, TimeResponse::Cleared { id }) => { + if let Some(tid) = model.debounce_time_id { + if tid == id { + model.debounce_time_id = None; + } + } + } Event::DurationElapsed(_, _) => { panic!("Unexpected debounce event") } @@ -212,17 +222,54 @@ mod tests { .expect_time(); // resolve and update - app.resolve_to_event_then_update(&mut request1, TimeResponse::DurationElapsed, &mut model) - .assert_empty(); + app.resolve_to_event_then_update( + &mut request1, + TimeResponse::DurationElapsed { + id: model.debounce_time_id.unwrap(), + }, + &mut model, + ) + .assert_empty(); // resolving the first debounce should not set the debounce_complete flag assert!(!model.debounce_complete); // resolve and update - app.resolve_to_event_then_update(&mut request2, TimeResponse::DurationElapsed, &mut model) - .assert_empty(); + app.resolve_to_event_then_update( + &mut request2, + TimeResponse::DurationElapsed { + id: model.debounce_time_id.unwrap(), + }, + &mut model, + ) + .assert_empty(); // resolving the second debounce should set the debounce_complete flag assert!(model.debounce_complete); } + + #[test] + pub fn test_cancel_timer() { + let app = AppTester::::default(); + let mut model = Model::default(); + + let mut request1 = app + .update(Event::StartDebounce, &mut model) + .expect_one_effect() + .expect_time(); + + assert!(model.debounce_time_id.is_some()); + + app.resolve_to_event_then_update( + &mut request1, + TimeResponse::Cleared { + id: model.debounce_time_id.unwrap(), + }, + &mut model, + ) + .assert_empty(); + + assert!(!model.debounce_complete); + assert!(model.debounce_time_id.is_none()); + } }