1#[cfg(not(target_arch = "wasm32"))]
7use std::path::PathBuf;
8
9use henad_compute::entry::ModelSet;
10use henad_compute::simulation::{RunSetup, SetupError};
11use henad_core::explore::fingerprint::schema_hash;
12use henad_core::explore::replay::Replay;
13use henad_core::provenance::BuildInfo;
14
15use crate::state::OpenAt;
16use crate::ui::sweep::draft::error_chain;
17
18const HENAD_ICON_PNG: &[u8] = include_bytes!("../assets/icon-256.png");
20
21#[derive(Debug)]
23pub struct AppOptions {
24 pub(crate) models: ModelSet,
25 pub(crate) product: Product,
26 pub(crate) opening: Option<AppOpening>,
27 pub(crate) thread_pool_note: Option<String>,
29}
30
31impl AppOptions {
32 pub fn new(models: ModelSet, product: impl Into<String>, host: BuildInfo) -> Self {
40 Self {
41 models,
42 product: Product {
43 name: product.into(),
44 host,
45 icon_png: HENAD_ICON_PNG,
46 source_url: None,
47 documentation_url: None,
48 license: None,
49 cli_command: None,
50 official: false,
51 },
52 opening: None,
53 thread_pool_note: None,
54 }
55 }
56
57 pub fn icon_png(mut self, png: &'static [u8]) -> Self {
59 self.product.icon_png = png;
60 self
61 }
62
63 pub fn source_url(mut self, url: impl Into<String>) -> Self {
65 self.product.source_url = Some(url.into());
66 self
67 }
68
69 pub fn documentation_url(mut self, url: impl Into<String>) -> Self {
71 self.product.documentation_url = Some(url.into());
72 self
73 }
74
75 pub fn license(mut self, license: impl Into<String>) -> Self {
77 self.product.license = Some(license.into());
78 self
79 }
80
81 pub fn cli_command(mut self, command: impl Into<String>) -> Self {
87 self.product.cli_command = Some(command.into());
88 self
89 }
90
91 pub fn opening(mut self, opening: AppOpening) -> Self {
93 self.opening = Some(opening);
94 self
95 }
96
97 #[doc(hidden)]
101 pub fn __official(mut self) -> Self {
102 self.product.official = true;
103 self
104 }
105
106 pub(crate) fn check_opening(&self) -> Result<(), OpeningError> {
111 let Some(opening) = &self.opening else {
112 return Ok(());
113 };
114 match opening {
115 #[cfg(not(target_arch = "wasm32"))]
116 AppOpening::Results(_) => Ok(()),
117 AppOpening::Run { replay, .. } => {
118 let entry = self
119 .models
120 .get(&replay.model)
121 .ok_or_else(|| OpeningError::NotInSet(replay.model.clone()))?;
122 match RunSetup::from_replay(entry, replay) {
123 Ok(_) => Ok(()),
124 Err(SetupError::ParamCount { expected, found }) => Err(OpeningError::ParamCount {
125 model: replay.model.clone(),
126 given: found,
127 declared: expected,
128 }),
129 Err(error) => Err(OpeningError::RunRefused {
130 model: replay.model.clone(),
131 reason: refusal_reason(&error),
132 }),
133 }
134 }
135 AppOpening::Setup { setup, .. } => {
136 let id = setup.entry().id();
137 let entry = self
138 .models
139 .get(id)
140 .ok_or_else(|| OpeningError::NotInSet(id.to_owned()))?;
141 if schema_hash(&entry.schema()) == schema_hash(&setup.entry().schema()) {
142 Ok(())
143 } else {
144 Err(OpeningError::OtherSchema(id.to_owned()))
145 }
146 }
147 }
148 }
149}
150
151#[derive(Clone)]
153pub(crate) struct Product {
154 pub name: String,
155 pub host: BuildInfo,
157 pub icon_png: &'static [u8],
158 pub source_url: Option<String>,
159 pub documentation_url: Option<String>,
160 pub license: Option<String>,
161 pub cli_command: Option<String>,
163 pub official: bool,
165}
166
167impl std::fmt::Debug for Product {
168 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 formatter
170 .debug_struct("Product")
171 .field("name", &self.name)
172 .field("host", &self.host)
173 .field("icon_png_bytes", &self.icon_png.len())
174 .field("source_url", &self.source_url)
175 .field("documentation_url", &self.documentation_url)
176 .field("license", &self.license)
177 .field("cli_command", &self.cli_command)
178 .field("official", &self.official)
179 .finish()
180 }
181}
182
183pub(crate) fn cli_phrase(command: Option<&str>) -> String {
186 command.map_or_else(|| "on the command line".to_owned(), |command| format!("with {command}"))
187}
188
189#[derive(Debug, Clone)]
191#[non_exhaustive]
192pub enum AppOpening {
193 #[cfg(not(target_arch = "wasm32"))]
195 Results(PathBuf),
196 Run {
198 replay: Replay,
200 open_at: OpenAt,
202 },
203 Setup {
208 setup: RunSetup,
210 open_at: OpenAt,
212 },
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
217pub(crate) enum OpeningError {
218 NotInSet(String),
220 ParamCount {
222 model: String,
223 given: usize,
224 declared: usize,
225 },
226 OtherSchema(String),
228 RunRefused { model: String, reason: String },
230}
231
232fn refusal_reason(error: &SetupError) -> String {
234 match error {
235 SetupError::Param(reason) => error_chain(reason),
236 other => error_chain(other),
237 }
238}
239
240impl std::fmt::Display for OpeningError {
241 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 match self {
243 Self::NotInSet(model) => write!(formatter, "this build does not include model '{model}'"),
244 Self::ParamCount { model, given, declared } => write!(
245 formatter,
246 "the run to open sets {given} {}, but model '{model}' has {declared}",
247 crate::ui::plural(*given as u64, "parameter")
248 ),
249 Self::OtherSchema(model) => write!(
250 formatter,
251 "the setup to open declares other parameters, stats or actions than model '{model}' of this build"
252 ),
253 Self::RunRefused { model, reason } => {
254 write!(formatter, "model '{model}' refuses the run to open: {reason}")
255 }
256 }
257 }
258}
259
260#[cfg(not(target_arch = "wasm32"))]
264pub struct AppError {
265 kind: AppErrorKind,
266}
267
268#[cfg(not(target_arch = "wasm32"))]
269#[derive(Debug)]
270enum AppErrorKind {
271 Opening(OpeningError),
272 Eframe(eframe::Error),
273}
274
275#[cfg(not(target_arch = "wasm32"))]
276impl AppError {
277 pub(crate) fn opening(error: OpeningError) -> Self {
278 Self {
279 kind: AppErrorKind::Opening(error),
280 }
281 }
282
283 pub(crate) fn eframe(error: eframe::Error) -> Self {
284 Self {
285 kind: AppErrorKind::Eframe(error),
286 }
287 }
288}
289
290#[cfg(not(target_arch = "wasm32"))]
291impl std::fmt::Debug for AppError {
292 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 std::fmt::Display::fmt(self, formatter)?;
294 match &self.kind {
295 AppErrorKind::Opening(_) => Ok(()),
296 AppErrorKind::Eframe(error) => write!(formatter, ": {error}"),
297 }
298 }
299}
300
301#[cfg(not(target_arch = "wasm32"))]
302impl std::fmt::Display for AppError {
303 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304 match &self.kind {
305 AppErrorKind::Opening(error) => write!(formatter, "the app cannot open: {error}"),
306 AppErrorKind::Eframe(_) => formatter.write_str("the app window failed"),
307 }
308 }
309}
310
311#[cfg(not(target_arch = "wasm32"))]
312impl std::error::Error for AppError {
313 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
314 match &self.kind {
315 AppErrorKind::Opening(_) => None,
316 AppErrorKind::Eframe(error) => Some(error),
317 }
318 }
319}
320
321#[cfg(target_arch = "wasm32")]
323#[derive(Debug)]
324pub struct WebStartError {
325 kind: WebStartErrorKind,
326}
327
328#[cfg(target_arch = "wasm32")]
329#[derive(Debug)]
330enum WebStartErrorKind {
331 NoWindow,
333 MissingCanvas(&'static str),
335 Opening(OpeningError),
336 Eframe(String),
338}
339
340#[cfg(target_arch = "wasm32")]
341impl WebStartError {
342 pub(crate) fn no_window() -> Self {
343 Self {
344 kind: WebStartErrorKind::NoWindow,
345 }
346 }
347
348 pub(crate) fn missing_canvas(id: &'static str) -> Self {
349 Self {
350 kind: WebStartErrorKind::MissingCanvas(id),
351 }
352 }
353
354 pub(crate) fn opening(error: OpeningError) -> Self {
355 Self {
356 kind: WebStartErrorKind::Opening(error),
357 }
358 }
359
360 pub(crate) fn eframe(error: &eframe::wasm_bindgen::JsValue) -> Self {
361 Self {
362 kind: WebStartErrorKind::Eframe(format!("{error:?}")),
363 }
364 }
365}
366
367#[cfg(target_arch = "wasm32")]
368impl std::fmt::Display for WebStartError {
369 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370 match &self.kind {
371 WebStartErrorKind::NoWindow => formatter.write_str("the app runs outside a browser window with a document"),
372 WebStartErrorKind::MissingCanvas(id) => write!(formatter, "the page has no canvas '{id}'"),
373 WebStartErrorKind::Opening(error) => write!(formatter, "the app cannot open: {error}"),
374 WebStartErrorKind::Eframe(error) => write!(formatter, "the app failed to start: {error}"),
375 }
376 }
377}
378
379#[cfg(target_arch = "wasm32")]
380impl std::error::Error for WebStartError {}
381
382#[cfg(test)]
383mod tests {
384 use henad_compute::entry::{ModelSet, register_grid_model};
385 use henad_core::action::{Schedule, Scheduled};
386 use henad_core::authoring::model::grid_model::GridModel;
387 use henad_core::explore::replay::Replay;
388 use henad_core::grid::Grid2D;
389 use henad_core::params::{ParamDescriptor, ParamValue};
390 use henad_core::topology::NeighborhoodKind;
391 use henad_core::view::{StatDescriptor, StatValue};
392
393 use super::{AppOpening, AppOptions, OpeningError};
394 use crate::state::OpenAt;
395
396 struct OtherSir;
398
399 impl GridModel for OtherSir {
400 const NAME: &'static str = "Other SIR";
401 const ID: &'static str = "sir";
402 const DESCRIPTION: &'static str = "A model under the example SIR's id, registered only by tests";
403 const PALETTE: &'static [[u8; 4]] = &[[0, 0, 0, 0xFF]];
404 const NEIGHBORHOOD: NeighborhoodKind = NeighborhoodKind::Moore;
405 const STATS: &'static [StatDescriptor] = &[StatDescriptor::new("Cells", [0xFF, 0xFF, 0xFF, 0xFF])];
406 type Params = ();
407
408 fn param_descriptors() -> Vec<ParamDescriptor> {
409 Vec::new()
410 }
411
412 fn from_params(_params: &[ParamValue]) {}
413
414 fn init(_grid: &mut Grid2D<u8>, _params: &[ParamValue], _rng: &mut u64) {}
415
416 fn step_cell(cell: u8, _neighbors: &[u8], _params: &(), _rng: &mut u64) -> u8 {
417 cell
418 }
419
420 fn stats(grid: &Grid2D<u8>) -> Vec<StatValue> {
421 vec![StatValue::Scalar(grid.current().len() as f64)]
422 }
423 }
424
425 fn options(models: ModelSet, opening: AppOpening) -> AppOptions {
426 AppOptions::new(models, "Test", henad_core::build_info!()).opening(opening)
427 }
428
429 fn run_of(model: &str, params: Vec<ParamValue>) -> AppOpening {
430 AppOpening::Run {
431 replay: Replay {
432 model: model.to_owned(),
433 params,
434 seed: 1,
435 schedule: Schedule::default(),
436 ticks: 10,
437 label: "Sweep run 0".to_owned(),
438 },
439 open_at: OpenAt::Start,
440 }
441 }
442
443 #[test]
444 fn a_mismatched_opening_is_refused() {
445 let examples = henad_models::example_models();
446 let sir = examples.get("sir").expect("the example models include sir");
447 let defaults = sir.setup().values().to_vec();
448 let declared = defaults.len();
449
450 assert_eq!(
451 options(examples.clone(), run_of("sir", defaults.clone())).check_opening(),
452 Ok(())
453 );
454 assert_eq!(
455 options(examples.clone(), run_of("absent", defaults.clone())).check_opening(),
456 Err(OpeningError::NotInSet("absent".to_owned()))
457 );
458 assert_eq!(
459 options(examples.clone(), run_of("sir", defaults[1..].to_vec())).check_opening(),
460 Err(OpeningError::ParamCount {
461 model: "sir".to_owned(),
462 given: declared - 1,
463 declared,
464 })
465 );
466
467 let rate = sir
468 .param_descriptors()
469 .iter()
470 .position(|descriptor| descriptor.id == "infection_rate")
471 .expect("sir declares infection_rate");
472 let mut out_of_bounds = defaults.clone();
473 out_of_bounds[rate] = ParamValue::F32(2.0);
474 assert!(
475 matches!(
476 options(examples.clone(), run_of("sir", out_of_bounds)).check_opening(),
477 Err(OpeningError::RunRefused { model, reason })
478 if model == "sir" && reason.starts_with("parameter 'infection_rate'")
479 ),
480 "a value out of bounds opened the window"
481 );
482 let mut unknown_action = run_of("sir", defaults.clone());
483 if let AppOpening::Run { replay, .. } = &mut unknown_action {
484 replay.schedule = Schedule::from_entries(vec![Scheduled {
485 index: 0,
486 id: "absent".to_owned(),
487 tick: 5,
488 }]);
489 }
490 assert_eq!(
491 options(examples.clone(), unknown_action).check_opening(),
492 Err(OpeningError::RunRefused {
493 model: "sir".to_owned(),
494 reason: "model has no action 'absent'".to_owned(),
495 })
496 );
497
498 let example_setup = || AppOpening::Setup {
499 setup: sir.setup(),
500 open_at: OpenAt::Start,
501 };
502 assert_eq!(options(examples.clone(), example_setup()).check_opening(), Ok(()));
503
504 let mut others = ModelSet::new(henad_core::build_info!());
505 others
506 .insert(register_grid_model::<OtherSir>())
507 .expect("one entry under a valid id");
508 assert_eq!(
509 options(others.clone(), example_setup()).check_opening(),
510 Err(OpeningError::OtherSchema("sir".to_owned())),
511 "a setup of the example sir opened over another model under its id"
512 );
513 let other_setup = AppOpening::Setup {
514 setup: others.get("sir").expect("just inserted").setup(),
515 open_at: OpenAt::Start,
516 };
517 assert_eq!(options(others, other_setup).check_opening(), Ok(()));
518
519 let empty = ModelSet::new(henad_core::build_info!());
520 assert_eq!(
521 options(empty, example_setup()).check_opening(),
522 Err(OpeningError::NotInSet("sir".to_owned()))
523 );
524 }
525}