Skip to main content

egui_charts/ui/replay/
session.rs

1//! Host-owned replay checkpoints. No bars or wall-clock timer are persisted.
2
3use super::{ReplayAction, ReplayController};
4use crate::model::ReplaySession;
5use chrono::{DateTime, Utc};
6
7impl ReplayController {
8    /// Seeks to the last source bar completed by an exclusive cutoff. Returns `false` without
9    /// changes when the source has no completed bar there. Useful when changing source interval.
10    pub fn seek_cutoff(&mut self, time: DateTime<Utc>, clock: &crate::model::BarClock) -> bool {
11        let end = self.ts.partition_point(|&open| {
12            clock
13                .next_open(clock.bar_open(open))
14                .map_or(open < time, |close| close <= time)
15        });
16        if end == 0 {
17            return false;
18        }
19        self.handle_action(ReplayAction::JumpToBar(end - 1));
20        true
21    }
22
23    /// The exclusive cutoff after the current source bar, suitable for `Chart::set_replay_time`.
24    /// Pass the source interval's clock, including its calendar timezone.
25    pub fn cutoff(&self, clock: &crate::model::BarClock) -> Option<DateTime<Utc>> {
26        if !self.state.is_active() {
27            return None;
28        }
29        let time = *self.curr_ts()?;
30        clock
31            .next_open(clock.bar_open(time))
32            .or_else(|| time.checked_add_signed(chrono::TimeDelta::nanoseconds(1)))
33    }
34
35    /// Saves the active cursor for host storage, excluding bars and transient UI state.
36    pub fn session(&self) -> Option<ReplaySession> {
37        self.state.is_active().then_some(ReplaySession {
38            symbol: self.state.symbol.clone().unwrap_or_default(),
39            time: *self.curr_ts()?,
40            speed: self.state.speed.multiplier(),
41        })
42    }
43
44    /// Restores an exact source timestamp, paused, or returns `false` without changes when
45    /// history, symbol or speed is incompatible. Missing history must be fetched by the host;
46    /// a missing timestamp is never silently replaced with a different session position.
47    pub fn restore_session(&mut self, session: &ReplaySession) -> bool {
48        if self.state.symbol.as_deref().unwrap_or_default() != session.symbol
49            || !session.speed.is_finite()
50            || !(0.1..=100.0).contains(&session.speed)
51        {
52            return false;
53        }
54        let Ok(index) = self.ts.binary_search(&session.time) else {
55            return false;
56        };
57        self.handle_action(ReplayAction::EnterReplayMode);
58        self.handle_action(ReplayAction::SetSpeed(session.speed));
59        self.handle_action(ReplayAction::JumpToBar(index));
60        self.handle_action(ReplayAction::Pause);
61        true
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::model::{Bar, BarClock, Timeframe};
69
70    #[test]
71    fn checkpoints_require_matching_history_and_restore_paused() {
72        let bars: Vec<_> = (0..10)
73            .map(|minute| {
74                Bar::new(
75                    DateTime::from_timestamp(1_704_067_200 + minute * 60, 0).unwrap(),
76                    10.0,
77                    12.0,
78                    9.0,
79                    11.0,
80                    1.0,
81                )
82            })
83            .collect();
84        let mut replay = ReplayController::new(bars.clone(), vec![]).with_symbol("TEST");
85        replay.handle_action(ReplayAction::JumpToBar(4));
86        replay.handle_action(ReplayAction::SetSpeed(3.0));
87        replay.handle_action(ReplayAction::Play);
88        let saved = replay.session().unwrap();
89        let saved: ReplaySession =
90            serde_json::from_str(&serde_json::to_string(&saved).unwrap()).unwrap();
91        replay.handle_action(ReplayAction::JumpToEnd);
92        assert!(replay.restore_session(&saved));
93        assert_eq!(replay.state.curr_bar, 4);
94        assert!(!replay.state.is_playing());
95        assert_eq!(replay.state.speed.multiplier(), 3.0);
96        let clock = BarClock::new(Timeframe::Min1);
97        assert_eq!(replay.cutoff(&clock), Some(bars[5].time));
98        for invalid in [
99            ReplaySession {
100                symbol: "OTHER".into(),
101                ..saved.clone()
102            },
103            ReplaySession {
104                time: bars[0].time - chrono::TimeDelta::minutes(1),
105                ..saved.clone()
106            },
107            ReplaySession {
108                speed: f32::NAN,
109                ..saved.clone()
110            },
111            ReplaySession {
112                speed: 0.0,
113                ..saved.clone()
114            },
115        ] {
116            assert!(!replay.restore_session(&invalid));
117            assert_eq!(replay.state.curr_bar, 4);
118        }
119        assert!(!replay.seek_cutoff(bars[0].time, &clock));
120        assert!(replay.seek_cutoff(bars[8].time, &clock));
121        assert_eq!(replay.state.curr_bar, 7);
122        replay.handle_action(ReplayAction::ExitReplayMode);
123        assert!(replay.cutoff(&clock).is_none());
124        replay.load_data(vec![], vec![], "TEST".into());
125        assert!(replay.session().is_none());
126        assert!(!replay.state.is_active());
127    }
128}