Skip to main content

henad_app/
lib.rs

1//! The app of Henad, a parallel agent-based modelling engine, as a library.
2//!
3//! `run_native` opens a window over the models an [`AppOptions`] holds, and `start_web` starts the same app in a
4//! browser. The official `henad-app` binary calls them with the example models. A project that defines its own models
5//! opens the same app over its [`ModelSet`](henad_compute::entry::ModelSet).
6//!
7//! ```no_run
8//! use henad_app::AppOptions;
9//! use henad_compute::entry::ModelSet;
10//!
11//! fn main() -> Result<(), henad_app::AppError> {
12//!     let models = ModelSet::new(henad_core::build_info!());
13//!     let options = AppOptions::new(models, "My Models", henad_core::build_info!()).cli_command("my-models-cli");
14//!     henad_app::run_native(options)
15//! }
16//! ```
17//!
18//! An [`AppOpening`] opens the app on a results folder, a recorded run or a setup the host built, instead of the
19//! first model of the set.
20
21#![cfg_attr(docsrs, feature(doc_cfg))]
22#![warn(missing_docs)]
23// Proving a type that holds wgpu handles `Send` or `Sync` walks wgpu-core's registries, deeper than the default
24// limit of 128.
25#![recursion_limit = "256"]
26
27henad_compute::include_shaders!();
28
29mod icons;
30mod init;
31#[cfg(not(target_arch = "wasm32"))]
32mod native;
33mod options;
34mod sim_runner;
35mod state;
36mod ui;
37#[cfg(target_arch = "wasm32")]
38mod web;
39
40use eframe::egui_wgpu;
41use egui_dock::{DockArea, DockState, Style};
42
43use crate::init::{setup_custom_fonts, setup_custom_styles};
44
45#[cfg(not(target_arch = "wasm32"))]
46pub use crate::native::{results_folder, run_native};
47#[cfg(not(target_arch = "wasm32"))]
48pub use crate::options::AppError;
49#[cfg(target_arch = "wasm32")]
50pub use crate::options::WebStartError;
51pub use crate::options::{AppOpening, AppOptions};
52pub use crate::state::OpenAt;
53#[cfg(target_arch = "wasm32")]
54pub use crate::web::{init_web_logger, start_web};
55
56use crate::sim_runner::SimRunner;
57use crate::state::AppState;
58use crate::ui::dock::{Tab, default_dock_state, focus_tab};
59use henad_compute::fault::{FaultSink, install_panic_hook};
60use henad_compute::runner::CAN_SPAWN_THREADS;
61use henad_compute::runtime_info::{RuntimeInfo, supports_compute};
62/// Re-exported so wasm-bindgen emits the worker glue `wasm_bindgen_rayon` builds its pool from.
63#[cfg(all(target_arch = "wasm32", target_feature = "atomics"))]
64pub use wasm_bindgen_rayon::init_thread_pool;
65
66/// Returns the pool width that a `?threads=N` query in `search` requests, clamped to `available` cores, or
67/// `available` when the query sets no count.
68///
69/// `?threads=1` requests a single worker. The threaded build then behaves like a build without a thread pool.
70#[cfg(any(all(target_arch = "wasm32", target_feature = "atomics"), test))]
71pub(crate) fn requested_threads(search: &str, available: usize) -> usize {
72    let available = available.max(1);
73    search
74        .trim_start_matches('?')
75        .split('&')
76        .find_map(|pair| pair.strip_prefix("threads="))
77        .and_then(|value| value.parse::<usize>().ok())
78        .unwrap_or(available)
79        .clamp(1, available)
80}
81
82use crate::state::FrameTimings;
83
84/// Longest time between two repaints while a sweep runs on its own thread.
85const SWEEP_REPAINT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250);
86
87struct HenadApp {
88    dock: DockState<Tab>,
89    state: AppState,
90}
91
92impl HenadApp {
93    /// Returns the app that `options` describe, on the device that eframe created from
94    /// [`init::wgpu_configuration`], opened on the options' opening.
95    ///
96    /// Note that the device has to be requested for the models' `gpu_needs()`. Otherwise a GPU model that binds more
97    /// storage buffers than the WebGPU baseline allows will fail to build.
98    fn new(cc: &eframe::CreationContext<'_>, options: AppOptions) -> Self {
99        install_panic_hook();
100
101        let render_state = &cc
102            .wgpu_render_state
103            .as_ref()
104            .expect("wgpu_render_state must exist for wgpu backend");
105
106        let adapter_info = render_state.adapter.get_info();
107        log::info!("{}", egui_wgpu::adapter_info_summary(&adapter_info));
108
109        // A live GPU model and the renderer share egui's device. Building the context also replaces the device's
110        // default error handling. Left to wgpu, every error is fatal.
111        let render_ctx = henad_compute::gpu::GpuContext::new(
112            render_state.device.clone(),
113            render_state.queue.clone(),
114            render_state.target_format,
115            FaultSink::new(),
116        );
117
118        let gpu_ctx = supports_compute(&adapter_info).then(|| render_ctx.clone());
119
120        setup_custom_fonts(&cc.egui_ctx);
121        setup_custom_styles(&cc.egui_ctx);
122
123        let AppOptions {
124            models,
125            product,
126            opening,
127            thread_pool_note,
128        } = options;
129        let mut state = AppState::new(
130            cc.egui_ctx.clone(),
131            models,
132            product,
133            render_ctx,
134            gpu_ctx,
135            RuntimeInfo::collect(&render_state.adapter, &render_state.device),
136        );
137        state.thread_pool_note = thread_pool_note;
138        if let Some(opening) = opening {
139            state.open(opening);
140        }
141        Self {
142            dock: default_dock_state(),
143            state,
144        }
145    }
146}
147
148impl eframe::App for HenadApp {
149    fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
150        // Advances the sim where it has no dedicated thread. Where it has one, this does nothing.
151        let dt = ctx.input(|i| f64::from(i.unstable_dt));
152        if let Some(thread) = &mut self.state.sim_thread {
153            thread.update(dt);
154        }
155        ui::sweep::update(&mut self.state, dt);
156
157        // Takes the latest snapshot from the sim thread.
158        let fresh = self.state.sim_thread.as_mut().and_then(SimRunner::take_snapshot);
159        if let Some(snap) = fresh {
160            // The loop can be past the target by the time it handles `RunTo`, and then pauses where it is. A second
161            // run to the target sees this snapshot's tick, rebuilds, and stops there.
162            let passed_target = self
163                .state
164                .run_to_target
165                .filter(|&target| snap.tick > target && self.state.selection_is_loaded());
166            if self.state.run_to_target.is_some_and(|target| snap.tick >= target) {
167                self.state.run_to_target = None;
168            }
169            // A publish at the newest row's tick replaces that row. After an action it carries the
170            // action's effect, and after a paused layout step the same stats again.
171            if let Some(history) = &mut self.state.stats_history {
172                history.push_entries(&snap.stats, snap.tick);
173            }
174            self.state.record(&snap);
175            // Passing the outgoing snapshot back lets the sim thread refill it instead of allocating.
176            if let Some(previous) = self.state.snapshot.replace(snap)
177                && let Some(thread) = &mut self.state.sim_thread
178            {
179                thread.recycle(previous);
180            }
181            if let Some(target) = passed_target {
182                self.state.run_to(target);
183            }
184        }
185
186        if let Some(fault) = self.state.render_ctx.faults.take() {
187            self.state.report_fault(fault);
188        }
189
190        self.state.poll_saves();
191        self.state.poll_opens();
192        ui::results::poll(ctx, &mut self.state);
193        self.state.poll_capture();
194
195        // Request continuous repaint while running. A run to a tick wakes the UI on each publish, and needs the
196        // repaint only where the frame steps the sim.
197        let frame_drives_sim = !CAN_SPAWN_THREADS;
198        if self.state.sim_running || (frame_drives_sim && self.state.run_to_target.is_some()) {
199            ctx.request_repaint_after(std::time::Duration::ZERO);
200        }
201        // A sweep wakes the UI on each finished run. Between runs only the clock and the ticks move.
202        if frame_drives_sim && self.state.sweep.is_stepping() {
203            ctx.request_repaint_after(std::time::Duration::ZERO);
204        } else if self.state.sweep.is_running() {
205            ctx.request_repaint_after(SWEEP_REPAINT_INTERVAL);
206        }
207    }
208
209    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
210        let frame_start = web_time::Instant::now();
211        self.state.timings.frame_render_ms = 0.0;
212
213        ui::menu_bar::menu_bar_panel(ui, &mut self.dock, &mut self.state);
214        ui::fault::fault_modal(ui.ctx(), &mut self.state);
215        ui::about::about_modal(ui.ctx(), &mut self.state);
216
217        let mut dock_style = Style::from_egui(ui.style());
218        // `from_egui` adds 2 to the widget radius for the tab bar's top corners.
219        dock_style.tab_bar.corner_radius = egui::CornerRadius::ZERO;
220        // `egui_dock` draws an overflowing tab bar's scroll bar as a pill. The wheel still scrolls the bar without it.
221        dock_style.tab_bar.show_scroll_bar_on_overflow = false;
222        DockArea::new(&mut self.dock)
223            .style(dock_style)
224            .show_close_buttons(true)
225            .show_leaf_close_all_buttons(true)
226            .show_inside(ui, &mut self.state);
227
228        // Applied after the dock has drawn. The panels draw while it is borrowed.
229        if let Some(tab) = self.state.focus_request.take() {
230            focus_tab(&mut self.dock, tab);
231            ui.ctx().request_repaint();
232        }
233
234        // The viewport tab times itself. Whatever is left over is UI.
235        let total_ms = frame_start.elapsed().as_secs_f64() * 1000.0;
236        let render_ms = self.state.timings.frame_render_ms;
237        FrameTimings::update_ema(&mut self.state.timings.render_ms, render_ms);
238        FrameTimings::update_ema(&mut self.state.timings.ui_ms, (total_ms - render_ms).max(0.0));
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::requested_threads;
245
246    #[test]
247    fn an_absent_query_asks_for_every_core() {
248        assert_eq!(requested_threads("", 14), 14);
249        assert_eq!(requested_threads("?debug=1", 14), 14);
250    }
251
252    #[test]
253    fn threads_one_is_how_a_single_threaded_run_is_asked_for() {
254        assert_eq!(requested_threads("?threads=1", 14), 1);
255        assert_eq!(requested_threads("?foo=a&threads=4", 14), 4);
256    }
257
258    /// More workers than cores only adds contention, and zero would build no pool at all.
259    #[test]
260    fn a_request_is_clamped_to_the_host() {
261        assert_eq!(requested_threads("?threads=99", 14), 14);
262        assert_eq!(requested_threads("?threads=0", 14), 1);
263        assert_eq!(requested_threads("?threads=nonsense", 14), 14);
264    }
265}