1use ravenrs_core::{AccessAction, AccessNode, Event, Key, Modifiers, Point, PointerButton, Role, Size};
4use ravenrs_style::Theme;
5use ravenrs_widgets::{Ui, View};
6
7use crate::movie::{FilmBuilder, Hotspot, HotspotKind, Movie, MovieError};
8use crate::narration::Narration;
9
10const FRAME: f64 = 1.0 / 30.0;
11const POINTER_SPEED: f32 = 900.0;
13const POINTER_TRAVEL: (f64, f64) = (0.25, 0.8);
14const SECONDS_PER_LETTER: f64 = 0.06;
15const READING_PER_LETTER: f64 = 0.075;
17const READING_LEAST: f64 = 2.0;
18const READING_START: f64 = 0.8;
19const AFTER_SPEAKING: f64 = 0.6;
21
22#[derive(Debug)]
24#[non_exhaustive]
25#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
26pub enum RecordError {
27 NotOnScreen {
29 name: String,
31 available: Vec<String>,
33 },
34 Movie(MovieError),
36}
37
38impl std::fmt::Display for RecordError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 Self::NotOnScreen { name, available } => {
42 write!(f, "'{name}' is not on the screen; there is {}", available.join(", "))
43 }
44 Self::Movie(e) => e.fmt(f),
45 }
46 }
47}
48
49impl std::error::Error for RecordError {}
50
51impl From<MovieError> for RecordError {
52 fn from(e: MovieError) -> Self {
53 Self::Movie(e)
54 }
55}
56
57fn reading_seconds(title: &str, text: &str) -> f64 {
59 let letters = title.chars().count() + text.chars().count();
60 (READING_START + letters as f64 * READING_PER_LETTER).max(READING_LEAST)
61}
62
63#[derive(Debug)]
66#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
67pub struct Recorder {
68 ui: Ui,
69 clock: f64,
70 pointer: Point,
71 logical: Size,
72 film: FilmBuilder,
73 narration: Option<Narration>,
74}
75
76#[cfg_attr(feature = "live", ravenrs_live::expose)]
77impl Recorder {
78 pub fn new(root: impl View + 'static, theme: Theme, size: Size) -> Self {
80 let mut ui = Ui::new(root, theme);
81 ui.set_viewport(size, 1.0);
82 ui.set_time(0.0);
83 ui.update();
84 let background = ui.scene().clear_color();
85 let mut recorder = Self {
86 ui,
87 clock: 0.0,
88 pointer: Point::new(size.width / 2.0, size.height / 2.0),
89 logical: size,
90 film: FilmBuilder::new(size, background),
91 narration: None,
92 };
93 recorder.tick();
94 recorder
95 }
96
97 #[must_use]
102 pub fn narration(mut self, narration: Narration) -> Self {
103 self.narration = Some(narration);
104 self
105 }
106
107 fn speaking_seconds(&self, title: &str, text: &str) -> f64 {
110 let said = format!("{title}. {text}");
111 match self.narration.as_ref().and_then(|narration| narration.seconds(&said)) {
112 Some(seconds) => (seconds + AFTER_SPEAKING).max(READING_LEAST),
113 None => reading_seconds(title, text),
114 }
115 }
116
117 fn milliseconds(&self) -> u64 {
118 (self.clock * 1000.0) as u64
119 }
120
121 fn tick(&mut self) {
123 self.clock += FRAME;
124 self.ui.set_time(self.clock);
125 self.ui.update();
126 let at = self.milliseconds();
127 self.film.frame(at, self.ui.scene().primitives());
128 }
129
130 fn find(&mut self, name: &str) -> Result<AccessNode, RecordError> {
131 let tree = self.ui.access_tree();
132 let named = |n: &&AccessNode| n.name.as_deref() == Some(name);
133 let starts = |n: &&AccessNode| n.name.as_deref().is_some_and(|text| text.starts_with(name));
134 let interactive =
135 |n: &&AccessNode| n.focusable || n.role == Role::Button || n.role == Role::TextInput;
136 let found = tree
137 .nodes
138 .iter()
139 .find(|n| named(n) && interactive(n))
140 .or_else(|| tree.nodes.iter().find(|n| named(n)))
141 .or_else(|| tree.nodes.iter().find(|n| starts(n) && interactive(n)))
142 .or_else(|| tree.nodes.iter().find(|n| starts(n)));
143 found.cloned().ok_or_else(|| RecordError::NotOnScreen {
144 name: name.to_owned(),
145 available: tree.nodes.iter().filter(|n| n.focusable).filter_map(|n| n.name.clone()).collect(),
146 })
147 }
148
149 pub fn wait(&mut self, seconds: f64) -> &mut Self {
151 let until = self.clock + seconds;
152 while self.clock < until {
153 self.tick();
154 }
155 self
156 }
157
158 pub fn chapter(&mut self, title: &str, text: &str) -> &mut Self {
160 self.film.chapter(title, text, self.milliseconds());
161 self
162 }
163
164 pub fn intro(&mut self, title: &str, text: &str) -> &mut Self {
167 self.chapter(title, text);
168 let seconds = self.speaking_seconds(title, text);
169 self.film.hotspot(Hotspot {
170 kind: HotspotKind::Note,
171 x: 0.0,
172 y: 0.0,
173 width: 0.0,
174 height: 0.0,
175 title: title.to_owned(),
176 text: text.to_owned(),
177 from_ms: self.milliseconds(),
178 until_ms: self.milliseconds() + (seconds * 1000.0) as u64,
179 });
180 self.wait(seconds)
181 }
182
183 pub fn explain(&mut self, name: &str, title: &str, text: &str) -> Result<&mut Self, RecordError> {
186 let seconds = self.speaking_seconds(title, text);
187 self.note(name, title, text, seconds)
188 }
189
190 pub fn move_to(&mut self, name: &str) -> Result<&mut Self, RecordError> {
192 let target = self.find(name)?.bounds.center();
193 let start = self.pointer;
194 let distance = (target.x - start.x).hypot(target.y - start.y);
195 let travel = f64::from(distance / POINTER_SPEED).clamp(POINTER_TRAVEL.0, POINTER_TRAVEL.1);
196 let begin = self.clock;
197 self.film.pointer(self.milliseconds(), start);
198 while self.clock - begin < travel {
199 let t = ((self.clock - begin) / travel) as f32;
200 let progress = t * t * (3.0 - 2.0 * t);
201 self.pointer = Point::new(
202 start.x + (target.x - start.x) * progress,
203 start.y + (target.y - start.y) * progress,
204 );
205 self.film.pointer(self.milliseconds(), self.pointer);
206 self.ui.handle_event(&Event::PointerMove { position: self.pointer });
207 self.tick();
208 }
209 self.pointer = target;
210 self.film.pointer(self.milliseconds(), target);
211 self.ui.handle_event(&Event::PointerMove { position: target });
212 self.tick();
213 Ok(self)
214 }
215
216 pub fn click(&mut self, name: &str) -> Result<&mut Self, RecordError> {
218 self.move_to(name)?;
219 let position = self.pointer;
220 self.film.click(self.milliseconds(), position);
221 self.ui.handle_event(&Event::PointerDown { position, button: PointerButton::Primary });
222 self.tick();
223 self.ui.handle_event(&Event::PointerUp { position, button: PointerButton::Primary });
224 Ok(self.wait(0.5))
225 }
226
227 pub fn type_into(&mut self, name: &str, text: &str) -> Result<&mut Self, RecordError> {
229 self.click(name)?;
230 let id = self.find(name)?.id;
232 self.ui.perform_action(id, AccessAction::Focus);
233 for character in text.chars() {
234 self.press(Key::Character(character.to_string()));
235 self.wait(SECONDS_PER_LETTER);
236 }
237 Ok(self.wait(0.3))
238 }
239
240 pub fn press(&mut self, key: Key) -> &mut Self {
242 self.film.key(self.milliseconds());
243 self.ui.handle_event(&Event::KeyDown { key, modifiers: Modifiers::default() });
244 self.tick();
245 self
246 }
247
248 pub fn scroll(&mut self, pixels: f32) -> &mut Self {
251 const STEPS: u16 = 15;
252 for _ in 0..STEPS {
253 let delta = Point::new(0.0, -pixels / f32::from(STEPS));
254 self.ui.handle_event(&Event::Scroll { position: self.pointer, delta });
255 self.tick();
256 }
257 self.wait(0.3)
258 }
259
260 pub fn note(
263 &mut self,
264 name: &str,
265 title: &str,
266 text: &str,
267 seconds: f64,
268 ) -> Result<&mut Self, RecordError> {
269 let hotspot = self.hotspot(name, HotspotKind::Note, title, text, seconds)?;
270 self.film.hotspot(hotspot);
271 Ok(self.wait(seconds))
272 }
273
274 pub fn guide(&mut self, name: &str, title: &str, text: &str) -> Result<&mut Self, RecordError> {
280 self.move_to(name)?;
282 let seconds = self.speaking_seconds(title, text);
283 let hotspot = self.hotspot(name, HotspotKind::Stop, title, text, seconds)?;
284 self.film.hotspot(hotspot);
285 Ok(self.wait(seconds))
286 }
287
288 fn hotspot(
289 &mut self,
290 name: &str,
291 kind: HotspotKind,
292 title: &str,
293 text: &str,
294 seconds: f64,
295 ) -> Result<Hotspot, RecordError> {
296 let bounds = self.find(name)?.bounds;
297 const MARGIN: f32 = 3.0;
299 Ok(Hotspot {
300 kind,
301 x: (bounds.x() - MARGIN).max(0.0),
302 y: (bounds.y() - MARGIN).max(0.0),
303 width: (bounds.width() + MARGIN * 2.0).min(self.logical.width),
304 height: (bounds.height() + MARGIN * 2.0).min(self.logical.height),
305 title: title.to_owned(),
306 text: text.to_owned(),
307 from_ms: self.milliseconds(),
308 until_ms: self.milliseconds() + (seconds * 1000.0) as u64,
309 })
310 }
311
312 pub fn ui_mut(&mut self) -> &mut Ui {
314 &mut self.ui
315 }
316
317 pub fn finish(self) -> Result<Movie, RecordError> {
319 let duration = self.milliseconds();
320 Ok(self.film.finish(duration)?)
321 }
322}