Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(crux_time): cancellable timer #279

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 59 additions & 20 deletions crux_time/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -106,50 +117,66 @@ where
}

/// Ask to receive a notification when the specified [`Instant`] has arrived.
pub fn notify_at<F>(&self, instant: Instant, callback: F)
pub fn notify_at<F>(&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<F>(&self, duration: Duration, callback: F)
pub fn notify_after<F>(&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)]
Expand All @@ -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);
Expand All @@ -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);
Expand Down
61 changes: 54 additions & 7 deletions crux_time/tests/time_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -40,6 +40,7 @@ mod shared {
pub time: String,
debounce: Debounce,
pub debounce_complete: bool,
pub debounce_time_id: Option<TimerId>,
}

#[derive(Serialize, Deserialize, Default)]
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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::<App, _>::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());
}
}
Loading