Skip to main content

tilo_app/framework/
scenario.rs

1//! What the app is built for — a window someone uses, a past version, a
2//! screenshot — and what a screenshot shows that the resource does not hold.
3//!
4//! A view is built in one [`RunMode`]. Only a [`Live`](RunMode::Live) view
5//! starts work that reaches outside the process: a mail engine, a socket, a
6//! journal reader, a hardware probe. History rebuilds the same view for every
7//! past version it previews ([`Past`](RunMode::Past)), and a screenshot
8//! ([`Capture`](RunMode::Capture)) must come out the same on every machine,
9//! so neither may.
10//!
11//! A capture's scene is prepared before its view is built, by
12//! [`ResourceApp::fixture`](super::ResourceApp::fixture): typed values that
13//! stand in for live sources (a scan result, a fake backend, a fetched
14//! page), a scratch directory, blobs in the content store, versions of the
15//! resource written by other people at other times, and collaborators shown
16//! where they are in it. Views read the values with
17//! [`RecordEnv::fixture`](super::RecordEnv::fixture), surfaces with
18//! [`RecordEnv::fixture`](super::RecordEnv::fixture).
19
20use std::any::{Any, TypeId};
21use std::collections::HashMap;
22use std::rc::Rc;
23
24use tilo_resource::{Doc, Invoke, Resource};
25use ui_core::history_mode::HistoryDefinition;
26use ui_core::history_mode::checked_files::FileHistorySource;
27
28use super::surface::ResourceSelection;
29
30/// What a view is built for. See the [module docs](self).
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum RunMode {
33    /// A window someone uses: real sources, and the work that keeps them
34    /// current.
35    Live,
36    /// A past version, previewed read-only in History mode. The view is
37    /// built again for every version shown; it starts nothing.
38    Past,
39    /// A screenshot (`--shots`, `--shots-dir`, `--responsive-matrix`): the
40    /// scenario's fixtures instead of devices, networks and clocks, and
41    /// nothing that reaches outside the process.
42    Capture,
43}
44
45impl RunMode {
46    /// Whether the view may start work that reaches outside the process.
47    pub fn is_live(self) -> bool {
48        self == Self::Live
49    }
50}
51
52/// The values a capture's views and surfaces read in place of live sources.
53#[derive(Default)]
54pub(crate) struct Fixtures {
55    values: HashMap<TypeId, Rc<dyn Any>>,
56    collaborators: Vec<(String, ResourceSelection)>,
57    content: Option<tilo_content::ContentStore>,
58    /// Kept for as long as anything built from the capture is.
59    _directory: Option<tempfile::TempDir>,
60}
61
62/// The mode a root was built in, and what its views read in it.
63#[derive(Clone)]
64pub(crate) struct Run {
65    pub(crate) mode: RunMode,
66    pub(crate) fixtures: Rc<Fixtures>,
67    /// What the launch that opened the window named.
68    pub(crate) launched: Rc<Launched>,
69    /// A private window: a profile thrown away with the process.
70    pub(crate) private: bool,
71    /// The file whose history History shows beside the resource's, for an
72    /// app that declares [`ResourceApp::FILE_HISTORY`](super::ResourceApp::FILE_HISTORY),
73    /// and the History scope the root witnesses it under.
74    pub(crate) file_history: Option<(FileHistorySource, &'static HistoryDefinition)>,
75    /// The names of the participants, as the host's roster has them.
76    pub(crate) people: super::People,
77    /// Opens another record in this window, in a window someone uses.
78    pub(crate) opener: Option<super::launch::OpenHere>,
79}
80
81/// The arguments, the app's own options, the file and the playback handoff
82/// a launch handed the
83/// app.
84#[derive(Default)]
85pub(crate) struct Launched {
86    /// The record selected by the host for this launch.
87    pub(crate) record: Option<ui_core::record::RecordId>,
88    pub(crate) arguments: Vec<String>,
89    pub(crate) options: Vec<ui_core::application::LaunchOption>,
90    pub(crate) file: Option<ui_core::application::OpenFile>,
91    pub(crate) handoff: Option<ui_core::application::PlaybackHandoff>,
92    /// The services the host that mounts the app in-process lent it.
93    pub(crate) lent: Lent,
94}
95
96/// What an in-process host lent the app
97/// ([`HostContext::lend`](ui_core::application::HostContext::lend)), each
98/// taken once by the view or surface that uses it.
99#[derive(Default)]
100pub(crate) struct Lent(std::cell::RefCell<Vec<Box<dyn Any>>>);
101
102impl Lent {
103    pub(crate) fn new(services: Vec<Box<dyn Any>>) -> Self {
104        Self(std::cell::RefCell::new(services))
105    }
106
107    /// The lent service of type `T`, once.
108    pub(crate) fn take<T: 'static>(&self) -> Option<T> {
109        let mut services = self.0.borrow_mut();
110        let index = services.iter().position(|service| service.is::<T>())?;
111        services
112            .remove(index)
113            .downcast::<T>()
114            .ok()
115            .map(|service| *service)
116    }
117}
118
119impl Run {
120    pub(crate) fn live(content: Option<tilo_content::ContentStore>) -> Self {
121        Self {
122            mode: RunMode::Live,
123            fixtures: Rc::new(Fixtures {
124                content,
125                ..Fixtures::default()
126            }),
127            launched: Rc::default(),
128            private: false,
129            file_history: None,
130            people: super::People::new(None),
131            opener: None,
132        }
133    }
134
135    /// This run in a private window, when `private`.
136    pub(crate) fn in_private(mut self, private: bool) -> Self {
137        self.private = private;
138        self
139    }
140
141    /// This run, showing the history of the file `file_history` names.
142    pub(crate) fn showing_file_history(
143        mut self,
144        file_history: Option<(FileHistorySource, &'static HistoryDefinition)>,
145    ) -> Self {
146        self.file_history = file_history;
147        self
148    }
149
150    pub(crate) fn past() -> Self {
151        Self {
152            mode: RunMode::Past,
153            fixtures: Rc::default(),
154            launched: Rc::default(),
155            private: false,
156            file_history: None,
157            people: super::People::new(None),
158            opener: None,
159        }
160    }
161
162    /// The same run, able to open another record in its window.
163    pub(crate) fn opening_with(mut self, opener: Option<super::launch::OpenHere>) -> Self {
164        self.opener = opener;
165        self
166    }
167
168    /// Open what a launch naming `arguments` opens in this window.
169    pub(crate) fn open_here(
170        &self,
171        arguments: &[String],
172    ) -> Result<ui_core::application::ReplacementOutcome, String> {
173        Ok(self
174            .opener
175            .as_ref()
176            .ok_or_else(|| "this window shows the record it was opened on".to_owned())?
177            .open(arguments))
178    }
179
180    /// The same run, for the participant `me`.
181    pub(crate) fn for_participant(mut self, me: Option<&str>) -> Self {
182        self.people = super::People::new(me);
183        self
184    }
185
186    /// The same run, opened by a launch that named `launched`.
187    pub(crate) fn launched(mut self, launched: Launched) -> Self {
188        self.launched = Rc::new(launched);
189        self
190    }
191
192    pub(crate) fn launch(&self) -> super::launch::LaunchInput<'_> {
193        super::launch::LaunchInput::new(&self.launched.arguments, self.launched.file.as_ref())
194            .with_content(self.fixtures.content.as_ref())
195            .with_handoff(self.launched.handoff.as_ref())
196            .with_options(&self.launched.options)
197    }
198
199    pub(crate) fn fixture<T: 'static>(&self) -> Option<Rc<T>> {
200        let value = self.fixtures.values.get(&TypeId::of::<T>())?;
201        Rc::clone(value).downcast::<T>().ok()
202    }
203    pub(crate) fn content(&self) -> Option<tilo_content::ContentStore> {
204        self.fixtures.content.clone()
205    }
206
207    /// The collaborators a capture shows, where they are in the resource.
208    pub(crate) fn collaborators(&self) -> &[(String, ResourceSelection)] {
209        &self.fixtures.collaborators
210    }
211}
212
213/// A version of the resource another person wrote before the capture.
214struct Version<R: Resource> {
215    author: String,
216    at_ms: i64,
217    invoke: Invoke<R>,
218}
219
220/// The scene of one screenshot scenario that its actions cannot stage:
221/// handed to [`ResourceApp::fixture`](super::ResourceApp::fixture) before
222/// the view is built.
223pub struct Fixture<R: Resource> {
224    scenario: Option<String>,
225    values: HashMap<TypeId, Rc<dyn Any>>,
226    history: Vec<Version<R>>,
227    collaborators: Vec<(String, ResourceSelection)>,
228    content: Option<tilo_content::ContentStore>,
229    directory: Option<tempfile::TempDir>,
230}
231
232impl<R: Resource> Fixture<R> {
233    pub(crate) fn new(scenario: Option<&str>, content: Option<tilo_content::ContentStore>) -> Self {
234        Self {
235            scenario: scenario.map(str::to_owned),
236            values: HashMap::new(),
237            history: Vec::new(),
238            collaborators: Vec::new(),
239            content,
240            directory: None,
241        }
242    }
243
244    /// The scenario being captured; `None` for a plain `--shots`.
245    pub fn scenario(&self) -> Option<&str> {
246        self.scenario.as_deref()
247    }
248
249    /// Stand `value` in for a live source: the views and surfaces of this
250    /// capture read it with `cx.fixture::<T>()`. One value per type; a
251    /// second replaces the first.
252    pub fn provide<T: 'static>(&mut self, value: T) {
253        self.values.insert(TypeId::of::<T>(), Rc::new(value));
254    }
255
256    /// A private scratch directory for files the scene shows (a folder to
257    /// browse, a repository to diff), removed after the capture.
258    ///
259    /// # Errors
260    /// Returns why the directory could not be created.
261    pub fn directory(&mut self) -> std::io::Result<&std::path::Path> {
262        if self.directory.is_none() {
263            self.directory = Some(
264                tempfile::Builder::new()
265                    .prefix("tilo-scenario-")
266                    .tempdir()?,
267            );
268        }
269        match &self.directory {
270            Some(directory) => Ok(directory.path()),
271            None => Err(std::io::Error::other("no scenario directory")),
272        }
273    }
274
275    /// The content store the capture's views read from
276    /// ([`RecordEnv::content`](super::RecordEnv::content)), for the blobs a scene
277    /// shows: photos, attachments, thumbnails. A private one when the host
278    /// lent none.
279    pub fn content(&mut self) -> &tilo_content::ContentStore {
280        self.content
281            .get_or_insert_with(tilo_content::ContentStore::memory)
282    }
283
284    /// A version of the resource `author` wrote at `at_ms` (milliseconds
285    /// since the Unix epoch), before the capture: it arrives as their
286    /// change does over sync, attributed to them in history and on no one's
287    /// undo stack here. Versions are written in the order given, before the
288    /// scenario's own actions.
289    pub fn version(&mut self, author: &str, at_ms: i64, invoke: Invoke<R>) {
290        self.history.push(Version {
291            author: author.to_owned(),
292            at_ms,
293            invoke,
294        });
295    }
296
297    /// A collaborator named `name`, shown where they are in the resource
298    /// (a selected item, a caret) the way their presence arrives in a
299    /// shared window.
300    pub fn collaborator(&mut self, name: &str, at: ResourceSelection) {
301        self.collaborators.push((name.to_owned(), at));
302    }
303
304    /// Write the other people's versions into `doc`.
305    pub(crate) fn write_history(
306        &self,
307        doc: &mut Doc,
308        registry: &tilo_actions::ActionRegistry,
309    ) -> Result<(), String> {
310        for version in &self.history {
311            write_version::<R>(doc, registry, version)?;
312        }
313        Ok(())
314    }
315
316    pub(crate) fn has_history(&self) -> bool {
317        !self.history.is_empty()
318    }
319
320    /// What the capture's views read.
321    pub(crate) fn into_run(self) -> Run {
322        Run {
323            mode: RunMode::Capture,
324            fixtures: Rc::new(Fixtures {
325                values: self.values,
326                collaborators: self.collaborators,
327                content: self.content,
328                _directory: self.directory,
329            }),
330            launched: Rc::default(),
331            private: false,
332            file_history: None,
333            people: super::People::new(None),
334            opener: None,
335        }
336    }
337
338    /// The content store the capture lends, once the fixture is prepared.
339    pub(crate) fn lent_content(&self) -> Option<&tilo_content::ContentStore> {
340        self.content.as_ref()
341    }
342}
343
344/// Apply one version as its author's change at its time: the envelope a
345/// collaborator's device would have sent, replayed as sync replays it.
346fn write_version<R: Resource>(
347    doc: &mut Doc,
348    registry: &tilo_actions::ActionRegistry,
349    version: &Version<R>,
350) -> Result<(), String> {
351    use tilo_actions::Action as _;
352    let action = version.invoke.build(version.at_ms)?;
353    let encoded = tilo_resource::encode::<R>(&action).map_err(|error| error.to_string())?;
354    let mut context = tilo_actions::ActionContext::for_resource(R::KIND, R::KIND, R::LABEL)
355        .with_change(action.change_kind())
356        .with_host_invoker(version.author.clone(), None);
357    if let Some(described) = action.describe(doc).or_else(|| {
358        tilo_actions::Described::of_published(
359            &encoded,
360            tilo_actions::ActionKey::new("", R::KIND, version.invoke.id),
361        )
362    }) {
363        context = context.with_described(described);
364    }
365    let envelope = tilo_actions::ActionEnvelope {
366        id: tilo_actions::ActionId::new(),
367        action: encoded,
368        base_heads: doc.get_heads().iter().map(|head| head.0).collect(),
369        timestamp: version.at_ms.div_euclid(1000),
370        locus: action.locus(),
371        actor: Some(version.author.as_bytes().to_vec()),
372        context: Some(context),
373    };
374    tilo_actions::replay(doc, &envelope, registry).map_err(|error| error.to_string())
375}