1use std::{fs::File, io::BufWriter, path::PathBuf, sync::Arc};
4
5use anyhow::Result;
6use spacecurve::registry;
7
8pub const APP_NAME: &str = "spacecurve";
10
11pub const APP_REPO_URL: &str = "https://github.com/cortesi/spacecurve";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum Pane {
17 #[default]
19 TwoD,
20 ThreeD,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ScreenshotTarget {
27 TwoD,
29 ThreeD,
31 About,
33 Settings,
35 Settings3D,
37}
38
39#[derive(Debug, Clone)]
41pub struct ScreenshotConfig {
42 pub target: ScreenshotTarget,
44 pub output_path: PathBuf,
46}
47
48#[derive(Debug)]
49struct ActiveScreenshot {
51 output_path: PathBuf,
53 requested: bool,
55}
56
57#[derive(Debug, Clone, Default)]
59pub struct GuiOptions {
60 pub include_experimental_curves: bool,
62 pub screenshot: Option<ScreenshotConfig>,
64 pub show_dev_overlay: bool,
66}
67
68pub mod about;
70pub mod selection;
72pub mod snake;
74pub mod state;
76pub mod theme;
78pub mod threed;
80pub mod twod;
82pub mod widgets;
84
85pub use selection::{Selected3DCurve, SelectedCurve};
86use state::AnimationController;
87use threed::show_3d_pane;
88use twod::show_2d_pane;
89
90pub struct SharedSettings {
92 pub curve_opacity: f32,
94 pub curve_long_jumps: bool,
96 pub snake_long_jumps: bool,
98 pub snake_enabled: bool,
100 pub snake_length: f32, pub snake_speed: f32,
104 pub spin_speed: f32,
106}
107
108impl Default for SharedSettings {
109 fn default() -> Self {
110 Self {
111 curve_opacity: 0.35, curve_long_jumps: false,
113 snake_long_jumps: false,
114 snake_enabled: true,
115 snake_length: 5.0, snake_speed: 30.0, spin_speed: 50.0, }
119 }
120}
121
122pub struct AppState {
124 pub current_pane: Pane,
126 pub animation_time: f32,
128 pub paused: bool,
130 pub rotation_angle: f32,
132 pub mouse_dragging: bool,
134 pub last_mouse_x: f32,
136 pub snake_time: f32,
138 pub settings_dropdown_open: bool,
140 pub settings_dropdown_pos: Option<egui::Pos2>,
142 pub about_open: bool,
144 pub frame_time_ms: Option<f32>,
146 pub frame_time_display_ms: Option<f32>,
148 pub frame_time_last_display_s: Option<f64>,
150}
151
152impl Default for AppState {
153 fn default() -> Self {
154 Self {
155 current_pane: Pane::TwoD,
156 animation_time: 0.0,
157 paused: false,
158 rotation_angle: 0.0,
159 mouse_dragging: false,
160 last_mouse_x: 0.0,
161 snake_time: 0.0,
162 settings_dropdown_open: false,
163 settings_dropdown_pos: None,
164 about_open: false,
165 frame_time_ms: None,
166 frame_time_display_ms: None,
167 frame_time_last_display_s: None,
168 }
169 }
170}
171
172pub struct RenderCache {
174 pub snake_segments_2d: Vec<usize>,
176 pub snake_segments_3d: Vec<usize>,
178 pub snake_mask_2d: Vec<bool>,
180 pub snake_mask_3d: Vec<bool>,
182 pub snake_included_3d: Vec<bool>,
184 pub last_canvas_rect: Option<egui::Rect>,
186 pub cache_3d_points: Vec<[f32; 3]>,
188 pub cache_3d_screen: Vec<egui::Pos2>,
190 pub cache_connected: Vec<bool>,
192 pub cache_caps: Vec<(bool, bool)>,
194 pub cache_depths: Vec<(usize, f32)>,
196 pub cache_2d_screen: Vec<egui::Pos2>,
198 pub cache_2d_run: Vec<egui::Pos2>,
200 pub cache_bins: Vec<Vec<usize>>,
202}
203
204impl Default for RenderCache {
205 fn default() -> Self {
206 Self {
207 snake_segments_2d: Vec::new(),
208 snake_segments_3d: Vec::new(),
209 snake_mask_2d: Vec::new(),
210 snake_mask_3d: Vec::new(),
211 snake_included_3d: Vec::new(),
212 last_canvas_rect: None,
213 cache_3d_points: Vec::new(),
214 cache_3d_screen: Vec::new(),
215 cache_connected: Vec::new(),
216 cache_caps: Vec::new(),
217 cache_depths: Vec::new(),
218 cache_2d_screen: Vec::new(),
219 cache_2d_run: Vec::new(),
220 cache_bins: vec![Vec::new(); 128],
221 }
222 }
223}
224
225pub struct ScurveApp {
227 selected_curve: SelectedCurve,
229 selected_3d_curve: Selected3DCurve,
231 available_curves: Vec<&'static str>,
233 app_state: AppState,
235 render_cache: RenderCache,
237 shared_settings: SharedSettings,
239 screenshot: Option<ActiveScreenshot>,
241 last_time: Option<f64>,
243 commonmark_cache: egui_commonmark::CommonMarkCache,
245 show_dev_overlay: bool,
247}
248
249impl ScurveApp {
250 pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
252 Self::with_options(cc, GuiOptions::default())
253 }
254
255 pub fn with_screenshot_config(
257 cc: &eframe::CreationContext<'_>,
258 screenshot_config: Option<ScreenshotConfig>,
259 ) -> Self {
260 Self::with_options(
261 cc,
262 GuiOptions {
263 screenshot: screenshot_config,
264 ..GuiOptions::default()
265 },
266 )
267 }
268
269 pub fn with_options(cc: &eframe::CreationContext<'_>, options: GuiOptions) -> Self {
271 theme::configure_visuals(&cc.egui_ctx);
273
274 let include_experimental = options.include_experimental_curves;
275 let mut available_curves = registry::curve_names(include_experimental);
276 if available_curves.is_empty() {
277 available_curves = registry::curve_names(true);
279 }
280
281 let default_curve = available_curves
282 .first()
283 .copied()
284 .unwrap_or(registry::CURVE_NAMES[0]);
285
286 let mut app_state = AppState::default();
287 let render_cache = RenderCache::default();
288 let screenshot_config = options.screenshot;
289 let mut screenshot_runtime = screenshot_config.as_ref().map(|cfg| ActiveScreenshot {
290 output_path: cfg.output_path.clone(),
291 requested: false,
292 });
293
294 if let Some(config) = screenshot_config {
296 match config.target {
297 ScreenshotTarget::TwoD => {
298 app_state.current_pane = Pane::TwoD;
299 }
300 ScreenshotTarget::ThreeD => {
301 app_state.current_pane = Pane::ThreeD;
302 }
303 ScreenshotTarget::About => {
304 app_state.current_pane = Pane::TwoD;
305 app_state.about_open = true;
306 }
307 ScreenshotTarget::Settings => {
308 app_state.current_pane = Pane::TwoD;
309 app_state.settings_dropdown_open = true;
310 }
311 ScreenshotTarget::Settings3D => {
312 app_state.current_pane = Pane::ThreeD;
313 app_state.settings_dropdown_open = true;
314 }
315 }
316 app_state.paused = true;
318 }
319
320 Self {
321 selected_curve: SelectedCurve::with_name(default_curve),
322 selected_3d_curve: Selected3DCurve::with_name(default_curve),
323 available_curves,
324 app_state,
325 render_cache,
326 shared_settings: Default::default(),
327 screenshot: screenshot_runtime.take(),
328 last_time: None,
329 commonmark_cache: Default::default(),
330 show_dev_overlay: options.show_dev_overlay,
331 }
332 }
333
334 fn show_menu_bar(&mut self, ctx: &egui::Context) {
336 egui::TopBottomPanel::top("menu_bar")
337 .frame(egui::Frame::new().inner_margin(egui::Margin {
338 left: theme::menu_bar::PADDING_HORIZONTAL as i8,
339 right: theme::menu_bar::PADDING_HORIZONTAL as i8,
340 top: theme::menu_bar::PADDING_VERTICAL as i8,
341 bottom: theme::menu_bar::PADDING_VERTICAL as i8,
342 }))
343 .show(ctx, |ui| {
344 ui.horizontal(|ui| {
345 if ui
347 .link(
348 egui::RichText::new(APP_NAME)
349 .size(theme::font_size::TITLE)
350 .strong()
351 .color(theme::TEXT_HEADING),
352 )
353 .clicked()
354 && let Err(e) = webbrowser::open(APP_REPO_URL)
355 {
356 eprintln!("Failed to open browser: {e}");
357 }
358
359 ui.add_space(theme::menu_bar::TITLE_SPACING);
360
361 let tab_text_size = 15.0;
363 if ui
364 .selectable_label(
365 self.app_state.current_pane == Pane::TwoD,
366 egui::RichText::new("2D").size(tab_text_size),
367 )
368 .clicked()
369 {
370 self.app_state.current_pane = Pane::TwoD;
371 }
372 ui.add_space(theme::menu_bar::TAB_SPACING);
373 if ui
374 .selectable_label(
375 self.app_state.current_pane == Pane::ThreeD,
376 egui::RichText::new("3D").size(tab_text_size),
377 )
378 .clicked()
379 {
380 self.app_state.current_pane = Pane::ThreeD;
381 }
382
383 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
385 ui.add_space(theme::menu_bar::BUTTON_PADDING);
386 if ui.button("About").clicked() {
387 self.app_state.about_open = !self.app_state.about_open;
388 }
389 });
390 });
391 });
392 }
393
394 fn handle_screenshot(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
396 let Some(screenshot) = self.screenshot.as_mut() else {
397 return;
398 };
399
400 if !screenshot.requested {
402 screenshot.requested = true;
403 ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(Default::default()));
404 ctx.request_repaint();
405 return;
406 }
407
408 let mut captured: Option<Arc<egui::ColorImage>> = None;
409 ctx.input(|input| {
410 for event in &input.events {
411 if let egui::Event::Screenshot { image, .. } = event {
412 captured = Some(image.clone());
413 break;
414 }
415 }
416 });
417
418 if let Some(image) = captured {
419 if let Err(err) = save_color_image(&screenshot.output_path, &image) {
420 eprintln!("Failed to save screenshot: {err}");
421 }
422 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
423 } else {
424 ctx.request_repaint();
426 }
427 }
428
429 fn update_frame_time(&mut self, delta_seconds: f32, now_seconds: f64) {
431 const DISPLAY_INTERVAL_S: f64 = 0.25;
432
433 if !self.show_dev_overlay {
434 return;
435 }
436
437 let ms = delta_seconds * 1000.0;
438 let smoothed = match self.app_state.frame_time_ms {
439 Some(prev) => prev * 0.85 + ms * 0.15,
440 None => ms,
441 };
442 self.app_state.frame_time_ms = Some(smoothed);
443
444 let should_update = match self.app_state.frame_time_last_display_s {
446 Some(last) => now_seconds - last >= DISPLAY_INTERVAL_S,
447 None => true,
448 };
449
450 if should_update {
451 self.app_state.frame_time_display_ms = Some(smoothed);
452 self.app_state.frame_time_last_display_s = Some(now_seconds);
453 }
454 }
455
456 fn show_frame_time_overlay(&self, ctx: &egui::Context) {
458 let Some(ms) = self
459 .app_state
460 .frame_time_display_ms
461 .or(self.app_state.frame_time_ms)
462 else {
463 return;
464 };
465 let fps = if ms > 0.0 { 1000.0 / ms } else { 0.0 };
466
467 let pos = if let Some(rect) = self.render_cache.last_canvas_rect {
468 egui::pos2(rect.max.x - 12.0, rect.min.y + 12.0)
469 } else {
470 let screen_rect = ctx.viewport_rect();
472 egui::pos2(screen_rect.max.x - 12.0, screen_rect.min.y + 12.0)
473 };
474
475 egui::Area::new(egui::Id::new("dev_frame_time_overlay"))
476 .order(egui::Order::Tooltip)
477 .fixed_pos(pos)
478 .show(ctx, |ui| {
479 egui::Frame::new()
480 .fill(theme::PANEL_BACKGROUND)
481 .stroke(egui::Stroke::new(1.0, theme::BORDER))
482 .corner_radius(egui::CornerRadius::same(4))
483 .inner_margin(egui::Margin::symmetric(8, 6))
484 .show(ui, |ui| {
485 ui.set_min_width(130.0);
486 ui.horizontal(|ui| {
487 ui.label(
488 egui::RichText::new(format!("{ms:.1} ms"))
489 .color(theme::TEXT_PRIMARY)
490 .size(theme::font_size::INFO),
491 );
492 ui.add_space(6.0);
493 ui.label(
494 egui::RichText::new(format!("{fps:.1} fps"))
495 .color(theme::TEXT_PRIMARY)
496 .size(theme::font_size::INFO),
497 );
498 });
499 });
500 });
501 }
502}
503
504impl eframe::App for ScurveApp {
505 fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
506 let now = ctx.input(|i| i.time);
508 if let Some(prev) = self.last_time {
509 let delta = (now - prev) as f32;
510 let clamped_delta = delta.max(0.0);
511 self.update_frame_time(clamped_delta, now);
512 AnimationController::update(
513 clamped_delta,
514 &mut self.app_state,
515 &self.shared_settings,
516 &mut self.selected_curve,
517 &mut self.selected_3d_curve,
518 );
519 }
520 self.last_time = Some(now);
521
522 let needs_repaint = self.shared_settings.snake_enabled
524 || (self.app_state.current_pane == Pane::ThreeD
525 && (!self.app_state.paused || self.app_state.mouse_dragging));
526 if needs_repaint {
527 ctx.request_repaint();
528 }
529
530 self.show_menu_bar(ctx);
531
532 if self.app_state.about_open {
534 about::show_about_dialog(
535 ctx,
536 &mut self.app_state.about_open,
537 &mut self.commonmark_cache,
538 );
539 }
540
541 egui::CentralPanel::default().show(ctx, |ui| match self.app_state.current_pane {
542 Pane::TwoD => {
543 show_2d_pane(
544 ui,
545 &mut self.app_state,
546 &mut self.render_cache,
547 &mut self.selected_curve,
548 &self.available_curves,
549 &mut self.shared_settings,
550 );
551 }
552 Pane::ThreeD => {
553 show_3d_pane(
554 ui,
555 &mut self.app_state,
556 &mut self.render_cache,
557 &mut self.selected_3d_curve,
558 &self.available_curves,
559 &mut self.shared_settings,
560 );
561 }
562 });
563
564 AnimationController::sync_panes(
566 self.app_state.current_pane,
567 &mut self.selected_curve,
568 &mut self.selected_3d_curve,
569 &self.available_curves,
570 );
571
572 self.handle_screenshot(ctx, frame);
573
574 if self.show_dev_overlay {
575 self.show_frame_time_overlay(ctx);
576 }
577 }
578}
579
580fn save_color_image(path: &PathBuf, image: &egui::ColorImage) -> anyhow::Result<()> {
582 use png::{BitDepth, ColorType, Encoder};
583
584 let file = File::create(path)?;
585 let buffered_file = BufWriter::new(file);
586 let mut encoder = Encoder::new(buffered_file, image.size[0] as u32, image.size[1] as u32);
587 encoder.set_color(ColorType::Rgba);
588 encoder.set_depth(BitDepth::Eight);
589 let mut writer = encoder.write_header()?;
590
591 let mut data = Vec::with_capacity(image.pixels.len() * 4);
592 for color in &image.pixels {
593 let [red, green, blue, alpha] = color.to_srgba_unmultiplied();
594 data.extend_from_slice(&[red, green, blue, alpha]);
595 }
596
597 writer.write_image_data(&data)?;
598 Ok(())
599}
600
601#[cfg(not(target_arch = "wasm32"))]
603pub fn gui() -> Result<()> {
604 gui_with_options(GuiOptions::default())
605}
606
607#[cfg(not(target_arch = "wasm32"))]
615pub fn gui_with_screenshot(screenshot_config: Option<ScreenshotConfig>) -> Result<()> {
616 gui_with_options(GuiOptions {
617 screenshot: screenshot_config,
618 ..GuiOptions::default()
619 })
620}
621
622#[cfg(not(target_arch = "wasm32"))]
624pub fn gui_with_options(options: GuiOptions) -> Result<()> {
625 let native_options = eframe::NativeOptions {
626 viewport: egui::ViewportBuilder::default()
627 .with_inner_size(theme::window::DEFAULT_SIZE)
628 .with_title(format!("{APP_NAME} gui")),
629 ..Default::default()
630 };
631
632 let options_clone = options;
633
634 eframe::run_native(
635 &format!("{APP_NAME} gui"),
636 native_options,
637 Box::new(move |cc| Ok(Box::new(ScurveApp::with_options(cc, options_clone)))),
638 )
639 .map_err(|e| anyhow::anyhow!(e.to_string()))?;
640
641 Ok(())
642}
643
644#[cfg(target_arch = "wasm32")]
646pub fn gui() -> Result<()> {
647 Ok(())
649}