Skip to main content

ravenrs_movie/
record.rs

1//! Records a film by running the real app from a script, with no window.
2
3use 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;
11/// How fast the pointer travels, in logical pixels a second, and its limits.
12const POINTER_SPEED: f32 = 900.0;
13const POINTER_TRAVEL: (f64, f64) = (0.25, 0.8);
14const SECONDS_PER_LETTER: f64 = 0.06;
15/// How long reading or hearing a text takes, at an easy pace.
16const READING_PER_LETTER: f64 = 0.075;
17const READING_LEAST: f64 = 2.0;
18const READING_START: f64 = 0.8;
19/// The breath after a recorded box, before the next thing happens.
20const AFTER_SPEAKING: f64 = 0.6;
21
22/// What can stop a recording.
23#[derive(Debug)]
24#[non_exhaustive]
25#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
26pub enum RecordError {
27    /// The script names something that is not on the screen.
28    NotOnScreen {
29        /// What it looked for.
30        name: String,
31        /// What there was to find.
32        available: Vec<String>,
33    },
34    /// The film could not be put together.
35    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
57/// How long it takes to read or hear a box: the voice says its title too.
58fn 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/// Runs an app from a script and films it: the steps are the ones a person would
64/// take, found by the names screen readers see. See the [crate documentation](crate).
65#[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    /// Films `root` in a window of `size`.
79    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    /// The recordings the player will say the boxes with. Each box then lasts as
98    /// long as its recording, and a little more, so that the voice and the box
99    /// end together and the film never ends while the voice is still speaking.
100    /// A box with no recording lasts as long as reading it takes.
101    #[must_use]
102    pub fn narration(mut self, narration: Narration) -> Self {
103        self.narration = Some(narration);
104        self
105    }
106
107    /// How long a box stays: as long as its recording, or as reading it takes.
108    /// The player says the title and then the text.
109    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    /// Advances the clock by one frame and notes what the app draws.
122    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    /// Lets the film run with nothing happening.
150    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    /// Starts a chapter here.
159    pub fn chapter(&mut self, title: &str, text: &str) -> &mut Self {
160        self.film.chapter(title, text, self.milliseconds());
161        self
162    }
163
164    /// Starts a chapter here and explains it in a box in the middle of the film,
165    /// for as long as it takes to read or hear.
166    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    /// A [`Self::note`] that lasts as long as `text` takes to hear, or to read
184    /// when it has no recording.
185    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    /// Slides the pointer to the middle of what is called `name`.
191    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    /// Slides the pointer to what is called `name` and clicks it.
217    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    /// Clicks the field called `name` and types `text` into it, letter by letter.
228    pub fn type_into(&mut self, name: &str, text: &str) -> Result<&mut Self, RecordError> {
229        self.click(name)?;
230        // A click on a field in a dialog can land on its label: the keyboard has to get there.
231        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    /// Presses a key.
241    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    /// Rolls the wheel over what is under the pointer, `pixels` in all; positive
249    /// goes down the page.
250    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    /// Marks what is called `name` and says `text` about it for `seconds`, in
261    /// which the film goes on. Clicking the mark in the player brings the text back.
262    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    /// The film stops here, with what is called `name` marked and `text` saying
275    /// what to do, until someone clicks the mark. Follow it with the step itself,
276    /// such as [`Self::click`], which is what the film shows next. When the
277    /// player is not stopping at steps, the box stays for as long as it takes to
278    /// read or hear; when it is, that time is skipped.
279    pub fn guide(&mut self, name: &str, title: &str, text: &str) -> Result<&mut Self, RecordError> {
280        // The pointer gets there first: the step asks for what it is already on.
281        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        // A hair larger than what it marks, so the border does not cover it.
298        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    /// The UI being filmed, for what a script cannot say by name.
313    pub fn ui_mut(&mut self) -> &mut Ui {
314        &mut self.ui
315    }
316
317    /// Ends the recording.
318    pub fn finish(self) -> Result<Movie, RecordError> {
319        let duration = self.milliseconds();
320        Ok(self.film.finish(duration)?)
321    }
322}