Skip to main content

scurve_gui/
lib.rs

1//! GUI application for exploring space‑filling curves using egui/eframe.
2
3use std::{fs::File, io::BufWriter, path::PathBuf, sync::Arc};
4
5use anyhow::Result;
6use spacecurve::registry;
7
8/// Canonical application name used across the GUI.
9pub const APP_NAME: &str = "spacecurve";
10
11/// Primary repository URL for the application.
12pub const APP_REPO_URL: &str = "https://github.com/cortesi/spacecurve";
13
14/// Represents the currently active view pane.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum Pane {
17    /// The 2D curve visualization pane.
18    #[default]
19    TwoD,
20    /// The 3D curve visualization pane.
21    ThreeD,
22}
23
24/// Screenshot target specifying which UI state to capture.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ScreenshotTarget {
27    /// Capture the 2D pane.
28    TwoD,
29    /// Capture the 3D pane.
30    ThreeD,
31    /// Capture the About dialog.
32    About,
33    /// Capture the settings dropdown (on 2D pane).
34    Settings,
35    /// Capture the settings dropdown on the 3D pane.
36    Settings3D,
37}
38
39/// Configuration for screenshot mode.
40#[derive(Debug, Clone)]
41pub struct ScreenshotConfig {
42    /// Which UI element to screenshot.
43    pub target: ScreenshotTarget,
44    /// Output file path for the PNG.
45    pub output_path: PathBuf,
46}
47
48#[derive(Debug)]
49/// Runtime state for screenshot capture requests.
50struct ActiveScreenshot {
51    /// Destination path for the PNG output.
52    output_path: PathBuf,
53    /// Whether we've already requested a frame capture.
54    requested: bool,
55}
56
57/// Launch configuration for the GUI.
58#[derive(Debug, Clone, Default)]
59pub struct GuiOptions {
60    /// Include experimental curves in selectors when true.
61    pub include_experimental_curves: bool,
62    /// Optional screenshot capture settings.
63    pub screenshot: Option<ScreenshotConfig>,
64    /// Enable developer overlay (frame timing, etc.).
65    pub show_dev_overlay: bool,
66}
67
68/// About dialog contents and helpers.
69pub mod about;
70/// Shared selection/cache helpers for 2D and 3D panes.
71pub mod selection;
72/// Shared helpers for snake overlays.
73pub mod snake;
74/// State management logic.
75pub mod state;
76/// Centralized theme constants (colors, fonts, spacing).
77pub mod theme;
78/// 3D view and interactions.
79pub mod threed;
80/// 2D view and interactions.
81pub mod twod;
82/// Reusable GUI widgets.
83pub mod widgets;
84
85pub use selection::{Selected3DCurve, SelectedCurve};
86use state::AnimationController;
87use threed::show_3d_pane;
88use twod::show_2d_pane;
89
90/// Settings shared between the 2D and 3D views.
91pub struct SharedSettings {
92    /// Opacity of the main curve rendering (0.0–1.0).
93    pub curve_opacity: f32,
94    /// Whether to draw long-jump segments in the main curve.
95    pub curve_long_jumps: bool,
96    /// Whether to draw long-jump segments in the snake overlay.
97    pub snake_long_jumps: bool,
98    /// Enable the animated snake overlay.
99    pub snake_enabled: bool,
100    /// Snake length as a percentage of curve length (0–50).
101    pub snake_length: f32, // Percentage of curve length (0-50%)
102    /// Snake speed, measured in segments per second.
103    pub snake_speed: f32,
104    /// Rotation speed of the 3D view (0–100 scale).
105    pub spin_speed: f32,
106}
107
108impl Default for SharedSettings {
109    fn default() -> Self {
110        Self {
111            curve_opacity: 0.35, // Default to 35% opacity
112            curve_long_jumps: false,
113            snake_long_jumps: false,
114            snake_enabled: true,
115            snake_length: 5.0, // Default to 5% of curve length
116            snake_speed: 30.0, // Default snake speed (segments per second)
117            spin_speed: 50.0,  // Default rotation speed (0-100 scale)
118        }
119    }
120}
121
122/// Mutable application state used by the GUI.
123pub struct AppState {
124    /// Currently selected pane.
125    pub current_pane: Pane,
126    /// Accumulated animation time in seconds.
127    pub animation_time: f32,
128    /// Global pause state for animations.
129    pub paused: bool,
130    /// Current rotation angle for the 3D view (radians).
131    pub rotation_angle: f32,
132    /// Whether the user is currently dragging in the 3D view.
133    pub mouse_dragging: bool,
134    /// Last X coordinate recorded during a drag gesture.
135    pub last_mouse_x: f32,
136    /// Accumulated time used to advance the snake animation.
137    pub snake_time: f32,
138    /// Whether the settings dropdown is currently open.
139    pub settings_dropdown_open: bool,
140    /// Persisted position for the settings dropdown to avoid frame-to-frame jitter.
141    pub settings_dropdown_pos: Option<egui::Pos2>,
142    /// Whether the About dialog is currently open.
143    pub about_open: bool,
144    /// Smoothed frame time in milliseconds (for dev overlay).
145    pub frame_time_ms: Option<f32>,
146    /// Latched frame time used for the UI (updates slowly for readability).
147    pub frame_time_display_ms: Option<f32>,
148    /// Last time (seconds) the display value was latched.
149    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
172/// Transient rendering buffers and cache state.
173pub struct RenderCache {
174    /// Reusable buffer for 2D snake segment indices.
175    pub snake_segments_2d: Vec<usize>,
176    /// Reusable buffer for 3D snake segment indices.
177    pub snake_segments_3d: Vec<usize>,
178    /// Reusable membership mask for 2D snake lookups.
179    pub snake_mask_2d: Vec<bool>,
180    /// Reusable membership mask for 3D snake lookups.
181    pub snake_mask_3d: Vec<bool>,
182    /// Reusable inclusion mask for visible 3D snake segments.
183    pub snake_included_3d: Vec<bool>,
184    /// Latest canvas rect for positioning overlays relative to the view.
185    pub last_canvas_rect: Option<egui::Rect>,
186    /// Reusable buffer for 3D rendering (projected points).
187    pub cache_3d_points: Vec<[f32; 3]>,
188    /// Reusable buffer for 3D rendering (screen points).
189    pub cache_3d_screen: Vec<egui::Pos2>,
190    /// Reusable buffer for 3D rendering (connectivity).
191    pub cache_connected: Vec<bool>,
192    /// Reusable buffer for 3D rendering (shorten caps).
193    pub cache_caps: Vec<(bool, bool)>,
194    /// Reusable buffer for 3D rendering (depth sorting).
195    pub cache_depths: Vec<(usize, f32)>,
196    /// Reusable buffer for 2D rendering (screen points).
197    pub cache_2d_screen: Vec<egui::Pos2>,
198    /// Reusable buffer for 2D line segments.
199    pub cache_2d_run: Vec<egui::Pos2>,
200    /// Reusable buffer for depth binning (3D).
201    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
225/// Root eframe application.
226pub struct ScurveApp {
227    /// 2D selection and cache state.
228    selected_curve: SelectedCurve,
229    /// 3D selection and cache state.
230    selected_3d_curve: Selected3DCurve,
231    /// Curves available for selection in this run.
232    available_curves: Vec<&'static str>,
233    /// Mutable app state shared across panes.
234    app_state: AppState,
235    /// Transient rendering caches.
236    render_cache: RenderCache,
237    /// Settings shared between panes.
238    shared_settings: SharedSettings,
239    /// Active screenshot request state (when running in screenshot mode).
240    screenshot: Option<ActiveScreenshot>,
241    /// Last frame time used to compute deltas.
242    last_time: Option<f64>,
243    /// CommonMark cache for the About dialog.
244    commonmark_cache: egui_commonmark::CommonMarkCache,
245    /// Whether to show developer diagnostics overlay.
246    show_dev_overlay: bool,
247}
248
249impl ScurveApp {
250    /// Construct a new app instance.
251    pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
252        Self::with_options(cc, GuiOptions::default())
253    }
254
255    /// Construct a new app instance with optional screenshot configuration.
256    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    /// Construct a new app instance with explicit launch options.
270    pub fn with_options(cc: &eframe::CreationContext<'_>, options: GuiOptions) -> Self {
271        // Configure visuals with our custom terminal theme
272        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            // Ensure we always have something to show even if filters change.
278            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        // Configure initial state based on screenshot target
295        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            // Pause animations for consistent screenshots
317            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    /// Render the top menu bar with title, tabs, and About button.
335    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                    // Title on the far left that links to GitHub
346                    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                    // Tab buttons with more visual weight
362                    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                    // Right-aligned About button with padding
384                    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    /// Handle multi-frame screenshot capture and saving to disk.
395    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        // Request a screenshot on the second frame to ensure overlays are fully drawn.
401        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            // Keep driving frames until the platform delivers the screenshot event.
425            ctx.request_repaint();
426        }
427    }
428
429    /// Smooth and store the latest frame time (ms) for dev overlay.
430    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        // Latch the display value at a slower cadence for readability
445        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    /// Render a lightweight developer overlay showing smoothed frame time.
457    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            // Fallback to top-right of the window if no canvas was drawn yet
471            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        // Compute delta time using egui input time
507        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        // Only request a repaint when there is time-based animation to show
523        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        // Show About dialog if open
533        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        // Synchronize selection between panes based on the active pane
565        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
580/// Persist an egui `ColorImage` to disk as a PNG file.
581fn 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/// Launch the native GUI application (non‑wasm targets).
602#[cfg(not(target_arch = "wasm32"))]
603pub fn gui() -> Result<()> {
604    gui_with_options(GuiOptions::default())
605}
606
607/// Launch the native GUI application with optional screenshot configuration.
608///
609/// When `screenshot_config` is provided, the app will:
610/// 1. Set the `EFRAME_SCREENSHOT_TO` environment variable
611/// 2. Initialize the UI to show the requested target
612/// 3. Render one frame and save the screenshot
613/// 4. Exit automatically (when compiled with `__screenshot` feature)
614#[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/// Launch the native GUI with custom options, including dev/experimental curves.
623#[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/// Stub entrypoint used by the wasm target; the real start is in `src/web.rs`.
645#[cfg(target_arch = "wasm32")]
646pub fn gui() -> Result<()> {
647    // Web is launched from src/web.rs using eframe's WebRunner
648    Ok(())
649}