Skip to main content

blockworx/
dialogs.rs

1//! The platform's pickers, behind one seam.
2//!
3//! Every dialog the shell opens is opened through here, for two reasons. A
4//! test must never reach a real one — a suite that did would stop with a
5//! window open on someone's screen, waiting for a person — so a test build
6//! answers from a queue it filled itself and opens nothing. And a flow's
7//! *end* is then a value a test can supply, which is the only part of the
8//! flow anything below the shell ever hears about.
9
10use std::collections::VecDeque;
11
12use blockworx_doc::block_model::Asset;
13
14use crate::export::ExportPayload;
15
16/// What a dialog answers with, once. The pickers run off the UI thread, so
17/// every answer arrives through a channel the frame polls.
18type Answer<T> = std::sync::mpsc::Receiver<T>;
19
20/// The pickers, or the stand-in a test drives them through.
21pub(crate) enum Dialogs {
22    Native,
23    Scripted(Scripted),
24}
25
26impl Default for Dialogs {
27    /// A real build opens real dialogs; a test build cannot, so the choice is
28    /// made here rather than at each of the twenty-odd places an `App` is
29    /// built for a test — one of which would eventually forget.
30    fn default() -> Self {
31        if cfg!(test) {
32            Dialogs::Scripted(Scripted::default())
33        } else {
34            Dialogs::Native
35        }
36    }
37}
38
39impl Dialogs {
40    /// Pick artwork — for a block's icon, or for an image of its own.
41    pub(crate) fn image(&mut self, ctx: &egui::Context) -> Answer<Option<Asset>> {
42        match self {
43            Dialogs::Native => crate::import::spawn_image_dialog(ctx),
44            Dialogs::Scripted(scripted) => {
45                scripted.asked.push(Asked::Image);
46                answered(scripted.images.pop_front().flatten())
47            }
48        }
49    }
50
51    /// Pick a file to bring into the document (JSON / PNG / SVG).
52    pub(crate) fn import(&mut self, ctx: &egui::Context) -> Answer<Option<(String, Vec<u8>)>> {
53        match self {
54            Dialogs::Native => crate::import::spawn_import_dialog(ctx),
55            Dialogs::Scripted(scripted) => {
56                scripted.asked.push(Asked::Import);
57                answered(scripted.imports.pop_front().flatten())
58            }
59        }
60    }
61
62    /// Open `request`'s own dialog — a container to open, a place to save one.
63    #[cfg(not(target_arch = "wasm32"))]
64    pub(crate) fn file(
65        &mut self,
66        ctx: &egui::Context,
67        request: crate::file::FileRequest,
68    ) -> crate::file::PickReceiver {
69        match self {
70            Dialogs::Native => crate::file::spawn_file_dialog(ctx, request),
71            Dialogs::Scripted(scripted) => {
72                scripted.asked.push(Asked::File(request));
73                answered(scripted.files.pop_front().flatten())
74            }
75        }
76    }
77
78    /// Write `payload` out, through the save dialog that names it.
79    pub(crate) fn export(&mut self, ctx: &egui::Context, payload: ExportPayload) {
80        match self {
81            Dialogs::Native => crate::export::spawn_export(ctx, payload),
82            Dialogs::Scripted(scripted) => {
83                scripted.asked.push(Asked::Export(payload.name.clone()));
84                scripted.exports.push_back(payload);
85            }
86        }
87    }
88}
89
90/// What a flow the shell runs needs from around it: the frame's context, and
91/// the pickers it opens through. The two travel together because every one of
92/// them needs both — the picker to open, and the context to wake the canvas
93/// when it answers.
94pub(crate) struct Ask<'a> {
95    pub ctx: &'a egui::Context,
96    pub dialogs: &'a mut Dialogs,
97}
98
99impl Ask<'_> {
100    pub(crate) fn image(&mut self) -> Answer<Option<Asset>> {
101        self.dialogs.image(self.ctx)
102    }
103
104    pub(crate) fn import(&mut self) -> Answer<Option<(String, Vec<u8>)>> {
105        self.dialogs.import(self.ctx)
106    }
107
108    #[cfg(not(target_arch = "wasm32"))]
109    pub(crate) fn file(&mut self, request: crate::file::FileRequest) -> crate::file::PickReceiver {
110        self.dialogs.file(self.ctx, request)
111    }
112}
113
114/// One request a scripted run recorded. A cancel is not here: cancelling is
115/// what an unanswered request does, and what it *means* is the shell's, so
116/// there is nothing about it to record.
117#[derive(Clone, PartialEq, Eq, Debug)]
118pub(crate) enum Asked {
119    Image,
120    Import,
121    #[cfg(not(target_arch = "wasm32"))]
122    File(crate::file::FileRequest),
123    /// The name the export would have been written under.
124    Export(String),
125}
126
127/// The stand-in: it records what it was asked for and answers from what a test
128/// queued, and an empty queue answers the way a cancelled dialog does.
129#[derive(Default)]
130pub(crate) struct Scripted {
131    asked: Vec<Asked>,
132    images: VecDeque<Option<Asset>>,
133    imports: VecDeque<Option<(String, Vec<u8>)>>,
134    #[cfg(not(target_arch = "wasm32"))]
135    files: VecDeque<Option<crate::file::FilePick>>,
136    /// What an export delivered here instead of to a file: a scripted run has
137    /// no disk to write, and the bytes are what a test is about.
138    exports: VecDeque<ExportPayload>,
139}
140
141/// A channel already holding its one answer, so the frame that polls it reads
142/// the answer on the first poll rather than waiting for a thread.
143fn answered<T>(value: T) -> Answer<T> {
144    let (tx, rx) = std::sync::mpsc::channel();
145    let _ = tx.send(value);
146    rx
147}
148
149#[cfg(test)]
150impl Dialogs {
151    /// What the run has been asked to open, in order.
152    pub(crate) fn asked(&self) -> &[Asked] {
153        match self {
154            Dialogs::Native => &[],
155            Dialogs::Scripted(scripted) => &scripted.asked,
156        }
157    }
158
159    /// What the next image pick answers with.
160    pub(crate) fn answer_image(&mut self, asset: Option<Asset>) {
161        self.scripted().images.push_back(asset);
162    }
163
164    /// What the next import answers with.
165    pub(crate) fn answer_import(&mut self, file: Option<(String, Vec<u8>)>) {
166        self.scripted().imports.push_back(file);
167    }
168
169    /// The next export this run was handed, in the order they arrived.
170    pub(crate) fn exported(&mut self) -> Option<ExportPayload> {
171        self.scripted().exports.pop_front()
172    }
173
174    /// What the next file dialog answers with.
175    #[cfg(not(target_arch = "wasm32"))]
176    pub(crate) fn answer_file(&mut self, pick: Option<crate::file::FilePick>) {
177        self.scripted().files.push_back(pick);
178    }
179
180    fn scripted(&mut self) -> &mut Scripted {
181        match self {
182            Dialogs::Scripted(scripted) => scripted,
183            Dialogs::Native => panic!("a test is driving the platform's own dialogs"),
184        }
185    }
186}