Skip to main content

re_viewer_context/
undo.rs

1use std::collections::BTreeMap;
2
3use re_chunk::{LatestAtQuery, TimeInt};
4use re_entity_db::EntityDb;
5use re_log_types::AbsoluteTimeRange;
6
7use crate::blueprint_timeline;
8
9/// Max number of undo points.
10///
11/// TODO(emilk): decide based on how much memory the blueprint uses instead.
12const MAX_UNDOS: usize = 100;
13
14/// We store the entire edit history of a blueprint in its store.
15///
16/// When undoing, we move back time, and redoing move it forward.
17/// When editing, we first drop all data after the current time.
18#[derive(Clone, Debug, Default)]
19pub struct BlueprintUndoState {
20    /// The current blueprint time, used for latest-at.
21    ///
22    /// Everything _after_ this time is in "redo-space",
23    /// and will be dropped before new events are appended to the timeline.
24    ///
25    /// If `None`, use the max time of the blueprint timeline.
26    current_time: Option<TimeInt>,
27
28    /// The keys form a set of interesting times to undo/redo to.
29    ///
30    /// When the user drags a slider or similar, we get new events
31    /// recorded on each frame. The user presumably wants to undo the whole
32    /// slider drag, and not each increment of it.
33    ///
34    /// So we use a heuristic to estimate when such interactions start/stop,
35    /// and add them to this set.
36    ///
37    /// The value is the frame number when the event happened,
38    /// and is used in debug builds to detect bugs
39    /// where we create undo-points every frame.
40    inflection_points: BTreeMap<TimeInt, u64>,
41}
42
43impl re_byte_size::SizeBytes for BlueprintUndoState {
44    fn heap_size_bytes(&self) -> u64 {
45        let Self {
46            current_time: _,
47            inflection_points,
48        } = self;
49        inflection_points.heap_size_bytes()
50    }
51}
52
53// We don't restore undo-state when closing the viewer.
54// If you want to support this, make sure you replace the call to `cumulative_frame_nr` with something else,
55// (because that resets to zero on restart) and also make sure you test it properly!
56static_assertions::assert_not_impl_any!(BlueprintUndoState: serde::Serialize);
57
58impl BlueprintUndoState {
59    /// Default latest-at query
60    #[inline]
61    pub fn default_query() -> LatestAtQuery {
62        LatestAtQuery::latest(blueprint_timeline())
63    }
64
65    /// How far back in time can we undo?
66    pub fn oldest_undo_point(&self) -> Option<TimeInt> {
67        self.inflection_points
68            .first_key_value()
69            .map(|(key, _)| *key)
70    }
71
72    pub fn blueprint_query(&self) -> LatestAtQuery {
73        if let Some(time) = self.current_time {
74            LatestAtQuery::new(blueprint_timeline(), time)
75        } else {
76            Self::default_query()
77        }
78    }
79
80    /// If set, everything after this time is in "redo-space" (futurum).
81    /// If `None`, there is no undo-buffer.
82    pub fn redo_time(&self) -> Option<TimeInt> {
83        self.current_time
84    }
85
86    pub fn set_redo_time(&mut self, time: TimeInt) {
87        self.current_time = Some(time);
88    }
89
90    pub fn undo(&mut self, blueprint_db: &EntityDb) {
91        let time = self
92            .current_time
93            .unwrap_or_else(|| max_blueprint_time(blueprint_db));
94
95        if let Some((previous, _)) = self.inflection_points.range(..time).next_back() {
96            re_log::trace!("Undo");
97            self.current_time = Some(*previous);
98        } else {
99            // nothing to undo to
100            re_log::debug!("Nothing to undo");
101        }
102    }
103
104    pub fn redo(&mut self) {
105        if let Some(time) = self.current_time {
106            re_log::trace!("Redo");
107            self.current_time = self
108                .inflection_points
109                .range(time.inc()..)
110                .next()
111                .map(|(key, _)| *key);
112        } else {
113            // If we have no time, we're at latest, and have nothing to redo
114            re_log::debug!("Nothing to redo");
115        }
116    }
117
118    pub fn redo_all(&mut self) {
119        self.current_time = None;
120    }
121
122    /// After calling this, there is no way to redo what was once undone.
123    pub fn clear_redo_buffer(&mut self, blueprint_db: &mut EntityDb) {
124        re_tracing::profile_function!();
125
126        if let Some(last_kept_event_time) = self.current_time.take() {
127            let first_dropped_event_time =
128                TimeInt::new_temporal(last_kept_event_time.as_i64().saturating_add(1));
129
130            // Drop everything after the current timeline time
131            let events = blueprint_db.drop_time_range(
132                &blueprint_timeline(),
133                AbsoluteTimeRange::new(first_dropped_event_time, re_chunk::TimeInt::MAX),
134            );
135
136            re_log::trace!("{} chunks affected when clearing redo buffer", events.len());
137        }
138    }
139
140    // Call each frame
141    pub fn update(&mut self, egui_ctx: &egui::Context, blueprint_db: &EntityDb) {
142        re_tracing::profile_function!();
143
144        if is_interacting(egui_ctx) {
145            return; // Don't create undo points while we're still interacting.
146        }
147
148        // NOTE: we may be called several times in each frame (if we do multiple egui passes).
149        let frame_nr = egui_ctx.cumulative_frame_nr();
150
151        if let Some((_, last_frame_nr)) = self.inflection_points.last_key_value() {
152            re_log::debug_assert!(
153                *last_frame_nr <= frame_nr,
154                "Frame counter is running backwards, from {last_frame_nr} to {frame_nr}!"
155            );
156        }
157
158        // Nothing is happening - remember this as a time to undo to.
159        let time = max_blueprint_time(blueprint_db);
160        let inserted = self.inflection_points.insert(time, frame_nr).is_none();
161        if inserted {
162            re_log::trace!("Inserted new inflection point at {time:?}");
163        }
164
165        // TODO(emilk): we should _also_ look for long streaks of changes (changes every frame)
166        // and disregard those, in case we miss something in `is_interacting`.
167        // Note that this on its own isn't enough: if you drag a slider,
168        // then you don't want an undo-point each time you pause the mouse - only on mouse-up!
169        // So we still need a call to `is_interacting`, no matter what.
170        // We must also make sure that this doesn't ignore actual bugs
171        // (writing spurious data to the blueprint store each frame -
172        // see https://github.com/rerun-io/rerun/issues/10304 and the comment below for more info).
173
174        // Don't store too many undo-points:
175        while let Some(first) = self
176            .inflection_points
177            .first_key_value()
178            .map(|(key, _)| *key)
179        {
180            if MAX_UNDOS < self.inflection_points.len() {
181                self.inflection_points.remove(&first);
182            } else {
183                break;
184            }
185        }
186
187        if cfg!(debug_assertions) {
188            // A bug we've seen before is that something ends up creating undo-points every frame.
189            // This causes undo to effectively break, but it won't be obvious unless you try to undo.
190            // So it is important that we catch this problem early.
191            // See https://github.com/rerun-io/rerun/issues/10304 for more.
192
193            // We use a simple approach here: if we're adding too many undo points
194            // in a short amount of time, that's likely because of a bug.
195
196            let n = 10;
197            let mut latest_iter = self.inflection_points.iter().rev();
198            let latest = latest_iter.next();
199            let n_back = latest_iter.nth(n - 1);
200            if let (Some((_, latest_frame_nr)), Some((_, n_back_frame_nr))) = (latest, n_back) {
201                let num_frames: u64 = latest_frame_nr - n_back_frame_nr;
202                if num_frames <= 2 * n as u64 {
203                    // We've added `n` undo points in under 2*n frames. Very suspicious!
204                    re_log::warn!(
205                        "[DEBUG]: We added {n} undo points in {num_frames} frames. This likely means Undo is broken. Please investigate!"
206                    );
207                }
208            }
209        }
210    }
211}
212
213fn max_blueprint_time(blueprint_db: &EntityDb) -> TimeInt {
214    blueprint_db
215        .time_range_for(&blueprint_timeline())
216        .map(|range| range.max.as_i64())
217        .map_or(TimeInt::ZERO, TimeInt::new_temporal)
218}
219
220fn is_interacting(egui_ctx: &egui::Context) -> bool {
221    egui_ctx.input(|i| {
222        let is_scrolling = i.smooth_scroll_delta != egui::Vec2::ZERO
223                // TODO(RR-2730): If egui properly tracked when we're scrolling we wouldn't have to do this time check.
224                || i.time_since_last_scroll() < 0.1;
225        let is_zooming = i.zoom_delta_2d() != egui::Vec2::splat(1.0);
226        i.pointer.any_down()
227            || i.any_touches()
228            || is_scrolling
229            || !i.keys_down.is_empty()
230            || is_zooming
231    })
232}