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.
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    /// A capture a headless drive invokes actions on
80    /// ([`Capture::driven`](ui_core::application::Capture::driven)): the
81    /// effects it is asked for run, though it starts no work on its own.
82    pub(crate) driven: bool,
83    /// What the workspace holds, when the record the window opened was not
84    /// on this device: the view shows once it arrived.
85    pub(crate) workspace:
86        Option<ui_core::arriving::Arriving<(), ui_core::workspace_sync::WorkspaceUnreachable>>,
87}
88
89/// The arguments, the app's own options, the file and the playback handoff
90/// a launch handed the
91/// app.
92#[derive(Default)]
93pub(crate) struct Launched {
94    /// The record selected by the host for this launch.
95    pub(crate) record: Option<ui_core::record::RecordId>,
96    pub(crate) arguments: Vec<String>,
97    pub(crate) options: Vec<ui_core::application::LaunchOption>,
98    pub(crate) file: Option<ui_core::application::OpenFile>,
99    pub(crate) handoff: Option<ui_core::application::PlaybackHandoff>,
100    /// The services the host that mounts the app in-process lent it.
101    pub(crate) lent: Lent,
102    /// The item the launch asked to show ([`LaunchTarget::Record`]'s
103    /// `reveal`), until a view that opens items has shown it.
104    ///
105    /// [`LaunchTarget::Record`]: ui_core::application::LaunchTarget::Record
106    pub(crate) reveal: std::cell::RefCell<Option<String>>,
107}
108
109/// What an in-process host lent the app
110/// ([`HostContext::lend`](ui_core::application::HostContext::lend)), each
111/// taken once by the view or surface that uses it.
112#[derive(Default)]
113pub(crate) struct Lent(std::cell::RefCell<Vec<Box<dyn Any>>>);
114
115impl Lent {
116    pub(crate) fn new(services: Vec<Box<dyn Any>>) -> Self {
117        Self(std::cell::RefCell::new(services))
118    }
119
120    /// The lent service of type `T`, once.
121    pub(crate) fn take<T: 'static>(&self) -> Option<T> {
122        let mut services = self.0.borrow_mut();
123        let index = services.iter().position(|service| service.is::<T>())?;
124        services
125            .remove(index)
126            .downcast::<T>()
127            .ok()
128            .map(|service| *service)
129    }
130}
131
132impl Run {
133    pub(crate) fn live(content: Option<tilo_content::ContentStore>) -> Self {
134        Self {
135            mode: RunMode::Live,
136            fixtures: Rc::new(Fixtures {
137                content,
138                ..Fixtures::default()
139            }),
140            launched: Rc::default(),
141            private: false,
142            file_history: None,
143            people: super::People::new(None),
144            opener: None,
145            driven: false,
146            workspace: None,
147        }
148    }
149
150    /// This run in a private window, when `private`.
151    pub(crate) fn in_private(mut self, private: bool) -> Self {
152        self.private = private;
153        self
154    }
155
156    /// This run, showing the history of the file `file_history` names.
157    pub(crate) fn showing_file_history(
158        mut self,
159        file_history: Option<(FileHistorySource, &'static HistoryDefinition)>,
160    ) -> Self {
161        self.file_history = file_history;
162        self
163    }
164
165    pub(crate) fn past() -> Self {
166        Self {
167            mode: RunMode::Past,
168            fixtures: Rc::default(),
169            launched: Rc::default(),
170            private: false,
171            file_history: None,
172            people: super::People::new(None),
173            opener: None,
174            driven: false,
175            workspace: None,
176        }
177    }
178
179    /// The same run, a capture a headless drive invokes actions on when
180    /// `driven`.
181    pub(crate) fn driven_by_drive(mut self, driven: bool) -> Self {
182        self.driven = driven;
183        self
184    }
185
186    /// The same run, its view waiting for what the workspace holds
187    /// (`workspace`) when its record was not on this device.
188    pub(crate) fn awaiting_workspace(
189        mut self,
190        workspace: Option<
191            ui_core::arriving::Arriving<(), ui_core::workspace_sync::WorkspaceUnreachable>,
192        >,
193    ) -> Self {
194        self.workspace = workspace;
195        self
196    }
197
198    /// The same run, able to open another record in its window.
199    pub(crate) fn opening_with(mut self, opener: Option<super::launch::OpenHere>) -> Self {
200        self.opener = opener;
201        self
202    }
203
204    /// Open what a launch naming `arguments` opens in this window.
205    pub(crate) fn open_here(
206        &self,
207        arguments: &[String],
208    ) -> Result<ui_core::application::ReplacementOutcome, String> {
209        Ok(self
210            .opener
211            .as_ref()
212            .ok_or_else(|| "this window shows the record it was opened on".to_owned())?
213            .open(arguments))
214    }
215
216    pub(crate) fn for_participant(mut self, me: Option<&str>) -> Self {
217        self.people = super::People::new(me);
218        self
219    }
220
221    /// The same run, opened by a launch that named `launched`.
222    pub(crate) fn launched(mut self, launched: Launched) -> Self {
223        self.launched = Rc::new(launched);
224        self
225    }
226
227    pub(crate) fn launch(&self) -> super::launch::LaunchInput<'_> {
228        super::launch::LaunchInput::new(&self.launched.arguments, self.launched.file.as_ref())
229            .with_content(self.fixtures.content.as_ref())
230            .with_handoff(self.launched.handoff.as_ref())
231            .with_options(&self.launched.options)
232    }
233
234    pub(crate) fn fixture<T: 'static>(&self) -> Option<Rc<T>> {
235        let value = self.fixtures.values.get(&TypeId::of::<T>())?;
236        Rc::clone(value).downcast::<T>().ok()
237    }
238    pub(crate) fn content(&self) -> Option<tilo_content::ContentStore> {
239        self.fixtures.content.clone()
240    }
241
242    /// The collaborators a capture shows, where they are in the resource.
243    pub(crate) fn collaborators(&self) -> &[(String, ResourceSelection)] {
244        &self.fixtures.collaborators
245    }
246}
247
248/// What other providers' actions answer in a capture
249/// ([`Fixture::answer`]), by the action.
250#[derive(Clone, Default)]
251pub(crate) struct Answers(HashMap<String, serde_json::Value>);
252
253impl Answers {
254    fn key(target: &tilo_actions::remote::RemoteActionTarget) -> String {
255        format!(
256            "{}/{}/{}",
257            target.provider, target.address.resource, target.address.action
258        )
259    }
260
261    /// What the action at `target` answers in this capture.
262    pub(crate) fn get(
263        &self,
264        target: &tilo_actions::remote::RemoteActionTarget,
265    ) -> Option<&serde_json::Value> {
266        self.0.get(&Self::key(target))
267    }
268}
269
270/// A version of the resource another person wrote before the capture.
271struct Version<R: Resource> {
272    author: String,
273    at_ms: i64,
274    invoke: Invoke<R>,
275}
276
277/// The scene of one screenshot scenario that its actions cannot stage:
278/// handed to [`ResourceApp::fixture`](super::ResourceApp::fixture) before
279/// the view is built.
280pub struct Fixture<R: Resource> {
281    scenario: Option<String>,
282    /// The participant the capture's window reads as
283    /// ([`RecordEnv::me`](super::RecordEnv::me)), for a scene with
284    /// something of their own in it.
285    person: String,
286    values: HashMap<TypeId, Rc<dyn Any>>,
287    /// What the record starts with, written before the versions.
288    start: Option<Start>,
289    history: Vec<Version<R>>,
290    collaborators: Vec<(String, ResourceSelection)>,
291    content: Option<tilo_content::ContentStore>,
292    directory: Option<tempfile::TempDir>,
293}
294
295/// Writes a capture's starting state into the record.
296type Start = Box<dyn Fn(&mut Doc) -> Result<(), String>>;
297
298impl<R: Resource> Fixture<R> {
299    pub(crate) fn new(
300        scenario: Option<&str>,
301        person: &str,
302        content: Option<tilo_content::ContentStore>,
303    ) -> Self {
304        Self {
305            scenario: scenario.map(str::to_owned),
306            person: person.to_owned(),
307            values: HashMap::new(),
308            start: None,
309            history: Vec::new(),
310            collaborators: Vec::new(),
311            content,
312            directory: None,
313        }
314    }
315
316    /// The scenario being captured; `None` for a plain `--shots`.
317    pub fn scenario(&self) -> Option<&str> {
318        self.scenario.as_deref()
319    }
320
321    /// Who the capture's window is for: the participant the view reads as
322    /// its own ([`RecordEnv::me`](super::RecordEnv::me)). A scene that holds
323    /// something of the person's own — a seat at the table, an issue
324    /// assigned to them — stages it for them.
325    pub fn person(&self) -> &str {
326        &self.person
327    }
328
329    /// Stand `value` in for a live source: the views and surfaces of this
330    /// capture read it with `cx.fixture::<T>()`. One value per type; a
331    /// second replaces the first.
332    pub fn provide<T: 'static>(&mut self, value: T) {
333        self.values.insert(TypeId::of::<T>(), Rc::new(value));
334    }
335
336    /// Stand `value` in for what another provider's action at `target`
337    /// answers: a view's [`RecordEnv::query`](super::RecordEnv::query) of
338    /// it reads `value`, as the action's result, in this capture.
339    pub fn answer(
340        &mut self,
341        target: &tilo_actions::remote::RemoteActionTarget,
342        value: &impl serde::Serialize,
343    ) {
344        let mut answers = self
345            .values
346            .remove(&TypeId::of::<Answers>())
347            .and_then(|answers| answers.downcast::<Answers>().ok())
348            .map(|answers| (*answers).clone())
349            .unwrap_or_default();
350        answers.0.insert(
351            Answers::key(target),
352            serde_json::to_value(value).unwrap_or_default(),
353        );
354        self.provide(answers);
355    }
356
357    /// A private scratch directory for files the scene shows (a folder to
358    /// browse, a repository to diff), removed after the capture.
359    pub fn directory(&mut self) -> std::io::Result<&std::path::Path> {
360        if self.directory.is_none() {
361            self.directory = Some(
362                tempfile::Builder::new()
363                    .prefix("tilo-scenario-")
364                    .tempdir()?,
365            );
366        }
367        match &self.directory {
368            Some(directory) => Ok(directory.path()),
369            None => Err(std::io::Error::other("no scenario directory")),
370        }
371    }
372
373    /// The content store the capture's views read from
374    /// ([`RecordEnv::content`](super::RecordEnv::content)), for the blobs a scene
375    /// shows: photos, attachments, thumbnails. A private one when the host
376    /// lent none.
377    pub fn content(&mut self) -> &tilo_content::ContentStore {
378        self.content
379            .get_or_insert_with(tilo_content::ContentStore::memory)
380    }
381
382    /// What the record holds when the capture starts: `state`, written into
383    /// it directly as the record's first content, before the versions. For
384    /// a scene whose starting content no single action makes as it stands
385    /// (items under fixed ids the scenario names, written by several
386    /// people) in fields the record keeps to its own actions
387    /// (`#[model(readonly)]`), which refuse every generic edit, a
388    /// scenario's or a version's alike. It is nobody's change: committed as
389    /// the record's genesis and checked by no door, so only ever a
390    /// capture's.
391    pub fn start(&mut self, state: R::State)
392    where
393        R::State: tilo_resource::Fields,
394    {
395        self.start = Some(Box::new(move |doc| {
396            let root = ui_core::automerge::ROOT;
397            let before = <R::State as tilo_resource::Fields>::read_fields(doc, &root)
398                .map_err(|error| format!("read the record: {error}"))?;
399            tilo_resource::Fields::write_changed_fields(
400                &state,
401                &before,
402                doc,
403                &root,
404                &tilo_resource::Delta::default(),
405            )
406            .map_err(|error| format!("write the record's start: {error}"))?;
407            doc.commit_with(
408                ui_core::automerge::transaction::CommitOptions::default()
409                    .with_message(tilo_actions::FrameworkChange::Genesis.change_message()),
410            );
411            Ok(())
412        }));
413    }
414
415    /// A version of the resource `author` wrote at `at_ms` (milliseconds
416    /// since the Unix epoch), before the capture: it arrives as their
417    /// change does over sync, attributed to them in history and on no one's
418    /// undo stack here. Versions are written in the order given, before the
419    /// scenario's own actions.
420    pub fn version(&mut self, author: &str, at_ms: i64, invoke: Invoke<R>) {
421        self.history.push(Version {
422            author: author.to_owned(),
423            at_ms,
424            invoke,
425        });
426    }
427
428    /// A collaborator named `name`, shown where they are in the resource
429    /// (a selected item, a caret) the way their presence arrives in a
430    /// shared window.
431    pub fn collaborator(&mut self, name: &str, at: ResourceSelection) {
432        self.collaborators.push((name.to_owned(), at));
433    }
434
435    /// Write the record's start and the other people's versions into `doc`.
436    pub(crate) fn write_history(
437        &self,
438        doc: &mut Doc,
439        registry: &tilo_actions::ActionRegistry,
440    ) -> Result<(), String> {
441        if let Some(start) = &self.start {
442            start(doc)?;
443        }
444        for version in &self.history {
445            write_version::<R>(doc, registry, version)?;
446        }
447        Ok(())
448    }
449
450    pub(crate) fn has_history(&self) -> bool {
451        self.start.is_some() || !self.history.is_empty()
452    }
453
454    /// What the capture's views read.
455    pub(crate) fn into_run(self) -> Run {
456        capture_run(Fixtures {
457            values: self.values,
458            collaborators: self.collaborators,
459            content: self.content,
460            _directory: self.directory,
461        })
462    }
463
464    /// The content store the capture lends, once the fixture is prepared.
465    pub(crate) fn lent_content(&self) -> Option<&tilo_content::ContentStore> {
466        self.content.as_ref()
467    }
468}
469
470/// The run of a capture whose views read `fixtures`: made here once rather
471/// than in every app's [`Fixture::into_run`].
472#[inline(never)]
473fn capture_run(fixtures: Fixtures) -> Run {
474    Run {
475        mode: RunMode::Capture,
476        fixtures: Rc::new(fixtures),
477        launched: Rc::default(),
478        private: false,
479        file_history: None,
480        people: super::People::new(None),
481        opener: None,
482        driven: false,
483        workspace: None,
484    }
485}
486
487/// Apply one version as its author's change at its time: the envelope a
488/// collaborator's device would have sent, replayed as sync replays it.
489fn write_version<R: Resource>(
490    doc: &mut Doc,
491    registry: &tilo_actions::ActionRegistry,
492    version: &Version<R>,
493) -> Result<(), String> {
494    use tilo_actions::Action as _;
495    let action = version.invoke.build(version.at_ms)?;
496    let encoded = tilo_resource::encode::<R>(&action).map_err(|error| error.to_string())?;
497    let mut context = tilo_actions::ActionContext::for_resource(R::KIND, R::KIND, R::LABEL)
498        .with_change(action.change_kind())
499        .with_host_invoker(version.author.clone(), None);
500    if let Some(described) = action.describe(doc).or_else(|| {
501        tilo_actions::Described::of_published(
502            &encoded,
503            tilo_actions::ActionKey::new("", R::KIND, version.invoke.id),
504        )
505    }) {
506        context = context.with_described(described);
507    }
508    let envelope = tilo_actions::ActionEnvelope {
509        id: tilo_actions::ActionId::new(),
510        action: encoded,
511        base_heads: doc.get_heads().iter().map(|head| head.0).collect(),
512        timestamp: version.at_ms.div_euclid(1000),
513        locus: action.locus(),
514        actor: Some(version.author.as_bytes().to_vec()),
515        context: Some(context),
516    };
517    tilo_actions::replay(doc, &envelope, registry).map_err(|error| error.to_string())
518}