1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
//! Core GUI application built on [`eframe`] / [`egui`].
//!
//! [`CellaApp`] is the main application struct that implements `eframe::App`.
//! It manages simulation state, rule editing, grid rendering, playback
//! controls, statistics, drawing/painting, and GIF export.
use super::actions::Action;
use super::state::{
Chrome, EditState, EditorState, ExportState, Inputs, Playback, Scenario, StatsState,
ViewSettings,
};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::time::Duration;
use super::render::color_for;
use cella_lib::*;
use egui::scroll_area::{DragScroll, ScrollSource};
use egui::{Color32, Context};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::gui) enum Dim {
D1,
D2,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::gui) enum DrawMode {
Cycle,
Paint,
/// Click to drop the selected pattern (2D only).
Stamp,
}
/// Run the native GUI application. `config` is an optional config file to
/// show instead of the Life demo (the `--config` command-line option).
pub fn run_gui(size: Option<(f32, f32)>, config: Option<PathBuf>) -> eframe::Result<()> {
// Configure the initial window via NativeOptions/ViewportBuilder.
// If size is provided, use it; otherwise default to a 16:9 reasonable size.
let (mut w, mut h) = if let Some((w, h)) = size {
(w, h)
} else {
let aspect = 16.0 / 9.0;
let w = 1280.0_f32; // default width
let h = w / aspect;
(w, h)
};
// Clamp minimums for the initial inner size
w = w.max(800.0);
h = h.max(450.0);
let viewport = egui::ViewportBuilder::default()
.with_inner_size(egui::vec2(w, h))
.with_min_inner_size(egui::vec2(800.0, 450.0))
.with_title("Cella GUI")
.with_resizable(true);
let options = eframe::NativeOptions {
viewport,
..eframe::NativeOptions::default()
};
eframe::run_native(
"Cella GUI",
options,
Box::new(move |cc| Ok(Box::new(CellaApp::new(cc, config.as_deref())))),
)
}
/// Main GUI application state and logic.
///
/// This struct owns the currently loaded grid (1D or 2D), UI state such as
/// play/pause, per-type colors, and render/export settings.
pub(in crate::gui) struct CellaApp {
/// What is being simulated.
pub(in crate::gui) scenario: Scenario,
/// Play/pause, run-to, and the step stopwatch.
pub(in crate::gui) playback: Playback,
/// Zoom, colours, and the 1D history strip.
pub(in crate::gui) view: ViewSettings,
/// Painting tools and the undo stack.
pub(in crate::gui) edit: EditState,
/// GIF export settings and its worker thread.
pub(in crate::gui) export: ExportState,
/// Population series behind the chart.
pub(in crate::gui) stats: StatsState,
/// The rule editor's working copy.
pub(in crate::gui) editor: EditorState,
/// The Explore tab: ensemble / evolution settings and its worker thread.
pub(in crate::gui) explore: crate::gui::explore::ExploreState,
/// Text scaling and the status line.
pub(in crate::gui) chrome: Chrome,
/// Pending values typed into form fields.
pub(in crate::gui) inputs: Inputs,
/// Actions queued by the panels this frame, applied by `drain_actions`.
pub(in crate::gui) actions: VecDeque<Action>,
}
impl CellaApp {
pub(in crate::gui) fn set_status<S: Into<String>>(&mut self, msg: S) {
self.chrome.status_message = Some(msg.into());
}
pub(in crate::gui) fn new(cc: &eframe::CreationContext<'_>, config: Option<&Path>) -> Self {
let mut app = Self {
scenario: Scenario::default(),
playback: Playback::default(),
view: ViewSettings::default(),
edit: EditState::default(),
export: ExportState::default(),
stats: StatsState::default(),
editor: EditorState::default(),
chrome: Chrome::new(&cc.egui_ctx),
inputs: Inputs::default(),
actions: VecDeque::new(),
explore: crate::gui::explore::ExploreState::default(),
};
app.apply_startup_config(config);
app
}
#[inline]
pub(in crate::gui) fn inactive_color(&self) -> Color32 {
self.view.inactive_color
}
pub(in crate::gui) fn set_color_for(&mut self, ty: &CellType, color: Color32) {
if ty.as_str() == INACTIVE {
self.view.inactive_color = color;
return;
}
self.view.colors.insert(ty.0, color);
}
pub(in crate::gui) fn color_of(&self, ty: &CellType) -> Color32 {
color_for(
*ty,
&self.view.colors,
&self.view.palette,
self.inactive_color(),
)
}
/// Apply user font scaling to egui text styles.
///
/// Restyling forces egui to re-layout every galley, so this is a no-op unless
/// the scale actually changed since the last frame.
/// Push the design tokens for the chosen theme into egui, only on frames
/// where the choice changed (the restyle is not free).
pub(in crate::gui) fn apply_theme_if_changed(&mut self, ctx: &Context) {
if self.chrome.applied_theme == Some(self.chrome.theme) {
return;
}
super::theme::apply(ctx, self.chrome.theme);
self.chrome.applied_theme = Some(self.chrome.theme);
}
pub(in crate::gui) fn apply_font_scale(&mut self, ctx: &Context) {
if self.chrome.font_scale == self.chrome.applied_font_scale {
return;
}
self.chrome.applied_font_scale = self.chrome.font_scale;
let mut map = self.chrome.base_text_styles.clone();
for font in map.values_mut() {
font.size = (font.size * self.chrome.font_scale).max(6.0);
}
ctx.all_styles_mut(|style| style.text_styles = map.clone());
}
}
impl CellaApp {
/// The bottom status strip: step counter, run timer, and the latest message.
pub(in crate::gui) fn ui_status_bar(&mut self, ui: &mut egui::Ui) {
egui::Panel::bottom("bottom_status").show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label(format!("Step: {}", self.current_step()));
if self.playback.playing && self.playback.steps_per_s > 0.0 {
ui.separator();
ui.label(format!("{:.0} steps/s", self.playback.steps_per_s));
}
// Show simulation timer when steps have been timed
if self.playback.timed_steps > 0 || self.playback.play_start.is_some() {
let total = if let Some(start) = self.playback.play_start {
self.playback.elapsed + start.elapsed()
} else {
self.playback.elapsed
};
let total_secs = total.as_secs_f64();
let steps = self.playback.timed_steps.max(1);
let avg_ms = (total_secs * 1000.0) / steps as f64;
ui.separator();
ui.label(format!("Time: {:.2}s", total_secs));
ui.label(format!("Avg: {:.2} ms/step", avg_ms));
}
if let Some((done, total)) = self.export_progress() {
ui.separator();
ui.add(
crate::gui::panels::toolbar::export_progress_bar(done, total)
.desired_width(160.0),
);
}
if let Some(msg) = &self.chrome.status_message {
ui.separator();
ui.label(egui::RichText::new(msg.clone()).italics());
}
});
});
}
/// The central grid viewport, plus every gesture that acts on it.
/// The central grid viewport, plus every gesture that acts on it.
///
/// The gesture handlers live in [`super::interact`]; this only decides the
/// order they run in and how the scroll area is configured.
pub(in crate::gui) fn ui_viewport(&mut self, ui: &mut egui::Ui) {
egui::CentralPanel::default().show(ui, |ui| {
self.handle_hotkeys(ui);
// Remembered for "zoom to fit", which the toolbar asks for before
// this frame's viewport exists.
self.view.last_viewport_size = Some(ui.available_size());
egui::ScrollArea::both()
// Left-drag is reserved for painting, so never drag-to-scroll.
.scroll_source(ScrollSource {
drag: DragScroll::Never,
scroll_bar: true,
mouse_wheel: true,
})
.show(ui, |ui| match self.paint_grid_viewport(ui) {
Some(response) => {
self.handle_zoom(ui, &response);
self.handle_pan(ui, &response);
self.handle_paint(ui, &response);
self.handle_cycle_click(&response);
self.handle_stamp_click(&response);
self.draw_tool_ghost(ui, &response);
self.show_hover_inspector(ui, &response);
}
None => {
ui.label("No grid loaded.");
}
});
});
}
/// Collect a finished GIF export thread and report how it went.
pub(in crate::gui) fn poll_export(&mut self) {
// If an export thread is active, poll for completion and finalize
if let Some(handle) = &self.export.join
&& handle.is_finished()
{
if let Some(handle) = self.export.join.take() {
match handle
.join()
.unwrap_or_else(|_| Err("export thread panicked".to_string()))
{
Ok(()) => {
self.export.message = Some("Export complete".into());
self.set_status("Export complete");
}
Err(e) => {
let msg = format!("Export failed: {}", e);
self.export.message = Some(msg.clone());
self.set_status(msg);
}
}
}
self.export.progress = None;
self.export.total = 0;
}
}
/// Ask egui to wake up again only when something is actually animating.
///
/// `stepped` is what [`CellaApp::tick_play`] just reported. The panels were
/// built before it ran, so a frame that stepped is already out of date the
/// moment it is drawn — including the frame a "Run to +N" finishes on,
/// which clears the run and so matches none of the branches below. Without
/// this the screen would keep showing the old step counter and "Running
/// to N" until the next mouse move.
pub(in crate::gui) fn request_next_repaint(&self, ctx: &Context, stepped: bool) {
// Only drive continuous repaints when something is actually animating.
// Previously this pinned the app at ~100 fps (and full CPU/GPU) even while
// paused with nothing on screen changing; egui repaints on input anyway.
if stepped {
// Draw the state this frame's steps produced.
ctx.request_repaint();
} else if self.burst_target().is_some() {
// Bursting: come straight back so consecutive frame budgets run
// back-to-back and throughput is set by the engine, not the clock.
ctx.request_repaint();
} else if self.playback.playing {
// Wake up in time for the next simulation tick, capped so the timer
// readout in the status bar still updates smoothly.
ctx.request_repaint_after(Duration::from_millis(self.playback.refresh_ms.min(100)));
} else if self.export.join.is_some() {
// Poll the export thread's progress a few times a second.
ctx.request_repaint_after(Duration::from_millis(100));
} else if self.explore.worker.as_ref().is_some_and(|w| w.busy) {
// The Explore worker wakes us itself after each message; this is
// the safety net in case it is between messages for a while.
ctx.request_repaint_after(Duration::from_millis(250));
}
}
}
impl eframe::App for CellaApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
let ctx = &ctx;
self.apply_font_scale(ctx);
self.apply_theme_if_changed(ctx);
egui::Panel::top("top_controls").show(ui, |ui| {
self.ui_top_controls(ui, ctx);
});
self.ui_control_panel(ui);
self.ui_workbench_panel(ui);
self.ui_status_bar(ui);
self.ui_viewport(ui);
self.ui_shortcuts_overlay(ctx);
self.ui_snapshot_load_modal(ctx);
self.ui_export_modal(ctx);
self.ui_notice_modal(ctx);
// Everything the panels asked for lands here, after they were drawn.
self.drain_actions();
self.poll_export();
self.poll_explore();
let stepped = self.tick_play();
self.request_next_repaint(ctx, stepped);
}
}