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}