use crate::svg::{Renderable as _, SvgContent}; /// We derive Deserialize/Serialize so we can persist app state on shutdown. #[derive(serde::Deserialize, serde::Serialize)] #[serde(default)] // if we add new fields, give them default values when deserializing old state pub struct TemplateApp { #[serde(skip)] svg_content: Option, // Pan offset in SVG coordinates pan_x: f32, pan_y: f32, // Zoom factor (1.0 = 100%) zoom: f32, #[serde(skip)] show_menu_bar: bool, #[serde(skip)] show_debug: bool, /// Exponential moving average of frame time for stable FPS display #[serde(skip)] fps_ema: f32, /// Last pointer position for manual drag tracking (smoother than Sense::drag) #[serde(skip)] last_pointer_pos: Option, /// Whether we're currently in a drag operation #[serde(skip)] is_dragging: bool, } impl Default for TemplateApp { fn default() -> Self { Self { svg_content: None, pan_x: 0.0, pan_y: 0.0, zoom: 1.0, show_menu_bar: false, show_debug: false, fps_ema: 60.0, // Start with reasonable default last_pointer_pos: None, is_dragging: false, } } } impl TemplateApp { /// Called once before the first frame. pub fn new(cc: &eframe::CreationContext<'_>) -> Self { // This is also where you can customize the look and feel of egui using // `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`. // Ensure image loaders (including SVG) are available on both native and web: egui_extras::install_image_loaders(&cc.egui_ctx); // Load previous app state (if any). let mut app: Self = if let Some(storage) = cc.storage { eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default() } else { Default::default() }; // Load SVG content let svg_path = "../line-and-surface/content/intro.svg"; match SvgContent::from_file(svg_path) { Ok(content) => { log::info!( "Loaded SVG: {} video scrolls, {} audio areas, {} anchors, {} texts", content.video_scrolls.len(), content.audio_areas.len(), content.anchors.len(), content.texts.len() ); if let Some((min_x, min_y, _, _)) = content.viewbox { // Initialize pan to center on content app.pan_x = -min_x; app.pan_y = -min_y; } app.svg_content = Some(content); } Err(e) => { log::error!("Failed to load SVG: {e}"); } } app } /// Convert from SVG coordinates to screen coordinates. fn svg_to_screen(&self, x: f32, y: f32, canvas_rect: &egui::Rect) -> egui::Pos2 { let screen_x = (x + self.pan_x) * self.zoom + canvas_rect.left(); let screen_y = (y + self.pan_y) * self.zoom + canvas_rect.top(); egui::pos2(screen_x, screen_y) } } impl eframe::App for TemplateApp { /// Called by the framework to save state before shutdown. fn save(&mut self, storage: &mut dyn eframe::Storage) { eframe::set_value(storage, eframe::APP_KEY, self); } /// Called each time the UI needs repainting, which may be many times per second. fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Batch input reading for efficiency // Use smooth_scroll_delta for smoother panning experience let ( escape_pressed, f3_pressed, scroll_delta, zoom_delta, pointer_pos, frame_time, primary_down, primary_pressed, primary_released, ) = ctx.input(|i| { ( i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::F3), i.smooth_scroll_delta.y, // Use smoothed scroll instead of raw i.zoom_delta(), i.pointer.hover_pos(), i.stable_dt, // Actual frame time for FPS calculation i.pointer.primary_down(), i.pointer.primary_pressed(), i.pointer.primary_released(), ) }); // Update FPS using exponential moving average for stable display // Alpha of 0.1 means ~10 frames to reach 63% of a new stable value // This provides good smoothing while still being responsive if frame_time > 0.0 { let current_fps = 1.0 / frame_time; const ALPHA: f32 = 0.1; self.fps_ema = ALPHA * current_fps + (1.0 - ALPHA) * self.fps_ema; } if escape_pressed { self.show_menu_bar = !self.show_menu_bar; } if f3_pressed { self.show_debug = !self.show_debug; } if self.show_menu_bar { egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { egui::MenuBar::new().ui(ui, |ui| { let is_web = cfg!(target_arch = "wasm32"); if !is_web { ui.menu_button("File", |ui| { if ui.button("Quit").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } }); ui.add_space(16.0); } egui::widgets::global_theme_preference_buttons(ui); ui.separator(); // Display current zoom level ui.label(format!("Zoom: {:.0}%", self.zoom * 100.0)); if ui.button("Reset View").clicked() { self.pan_x = 0.0; self.pan_y = 0.0; self.zoom = 1.0; } ui.separator(); if ui .button(if self.show_debug { "Hide Debug (F3)" } else { "Show Debug (F3)" }) .clicked() { self.show_debug = !self.show_debug; } }); }); } // Track rendered element count for debug let mut rendered_count = 0u32; egui::CentralPanel::default().show(ctx, |ui| { // Allocate the full panel - use click_and_drag to capture hover and clicks let (response, painter) = ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag()); let canvas_rect = response.rect; // Manual drag handling for smoother panning if primary_pressed && response.hovered() { self.is_dragging = true; self.last_pointer_pos = pointer_pos; } if primary_released { self.is_dragging = false; self.last_pointer_pos = None; } // Calculate delta from pointer movement if self.is_dragging && primary_down { if let (Some(current_pos), Some(last_pos)) = (pointer_pos, self.last_pointer_pos) { let delta = current_pos - last_pos; if delta.x != 0.0 || delta.y != 0.0 { self.pan_x += delta.x / self.zoom; self.pan_y += delta.y / self.zoom; } } self.last_pointer_pos = pointer_pos; } // Handle scroll wheel (zoom) - only if hovered if response.hovered() { if scroll_delta != 0.0 { let zoom_factor = 1.0 + scroll_delta * 0.001; let new_zoom = (self.zoom * zoom_factor).clamp(0.01, 100.0); // Zoom towards pointer position if let Some(pos) = pointer_pos { let svg_x = (pos.x - canvas_rect.left()) / self.zoom - self.pan_x; let svg_y = (pos.y - canvas_rect.top()) / self.zoom - self.pan_y; self.pan_x = (pos.x - canvas_rect.left()) / new_zoom - svg_x; self.pan_y = (pos.y - canvas_rect.top()) / new_zoom - svg_y; } self.zoom = new_zoom; } // Also support trackpad pinch zoom if zoom_delta != 1.0 { let new_zoom = (self.zoom * zoom_delta).clamp(0.01, 100.0); if let Some(pos) = pointer_pos { let svg_x = (pos.x - canvas_rect.left()) / self.zoom - self.pan_x; let svg_y = (pos.y - canvas_rect.top()) / self.zoom - self.pan_y; self.pan_x = (pos.x - canvas_rect.left()) / new_zoom - svg_x; self.pan_y = (pos.y - canvas_rect.top()) / new_zoom - svg_y; } self.zoom = new_zoom; } } // Define colors for each element type let video_scroll_color = egui::Color32::from_rgb(70, 130, 180); // Steel Blue let audio_area_color = egui::Color32::from_rgb(60, 179, 113); // Medium Sea Green let anchor_color = egui::Color32::from_rgb(255, 215, 0); // Gold let text_color = egui::Color32::from_rgb(147, 112, 219); // Medium Purple // Render SVG content with frustum culling if let Some(ref content) = self.svg_content { // Draw video scrolls for vs in &content.video_scrolls { let (x, y, w, h) = vs.bounds(); let min = self.svg_to_screen(x, y, &canvas_rect); let max = self.svg_to_screen(x + w, y + h, &canvas_rect); let rect = egui::Rect::from_min_max(min, max); // Frustum culling: skip if completely outside canvas if !rect.intersects(canvas_rect) { continue; } painter.rect_filled(rect, 0.0, video_scroll_color); rendered_count += 1; } // Draw audio areas for aa in &content.audio_areas { let (x, y, w, h) = aa.bounds(); let min = self.svg_to_screen(x, y, &canvas_rect); let max = self.svg_to_screen(x + w, y + h, &canvas_rect); let rect = egui::Rect::from_min_max(min, max); if !rect.intersects(canvas_rect) { continue; } painter.rect_filled(rect, (w.min(h) * self.zoom) / 2.0, audio_area_color); rendered_count += 1; } // Draw anchors for anchor in &content.anchors { let (x, y, w, h) = anchor.bounds(); let min = self.svg_to_screen(x, y, &canvas_rect); let max = self.svg_to_screen(x + w, y + h, &canvas_rect); let rect = egui::Rect::from_min_max(min, max); if !rect.intersects(canvas_rect) { continue; } painter.rect_filled(rect, 0.0, anchor_color); rendered_count += 1; } // Draw text elements for text in &content.texts { let (x, y, w, h) = text.bounds(); let min = self.svg_to_screen(x, y, &canvas_rect); let max = self.svg_to_screen(x + w, y + h, &canvas_rect); let rect = egui::Rect::from_min_max(min, max); if !rect.intersects(canvas_rect) { continue; } painter.rect_filled(rect, 0.0, text_color); rendered_count += 1; } // Draw a subtle border around the viewbox if available if let Some((vb_x, vb_y, vb_w, vb_h)) = content.viewbox { let min = self.svg_to_screen(vb_x, vb_y, &canvas_rect); let max = self.svg_to_screen(vb_x + vb_w, vb_y + vb_h, &canvas_rect); painter.rect_stroke( egui::Rect::from_min_max(min, max), 0.0, egui::Stroke::new(1.0, egui::Color32::from_gray(100)), egui::StrokeKind::Inside, ); } } }); // Debug overlay window if self.show_debug { egui::Window::new("Debug") .anchor(egui::Align2::RIGHT_TOP, [-10.0, 10.0]) .resizable(false) .collapsible(false) .show(ctx, |ui| { ui.label(format!("FPS: {:.1}", self.fps_ema)); ui.label(format!("Pixels per point: {:.2}", ctx.pixels_per_point())); ui.separator(); ui.label(format!("Pan: ({:.1}, {:.1})", self.pan_x, self.pan_y)); ui.label(format!("Zoom: {:.2}x", self.zoom)); ui.separator(); if let Some(ref content) = self.svg_content { let total = content.video_scrolls.len() + content.audio_areas.len() + content.anchors.len() + content.texts.len(); ui.label(format!("Total elements: {total}")); ui.label(format!("Rendered: {rendered_count}")); ui.label(format!( "Culled: {}", total.saturating_sub(rendered_count as usize) )); } }); } } }