use crate::svg::{Renderable as _, SvgContent}; use crate::text_cache::{TextCache, RENDER_SCALE}; /// We derive Deserialize/Serialize so we can persist app state on shutdown. #[derive(serde::Deserialize, serde::Serialize)] #[serde(default)] 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, #[serde(skip)] render_internal_areas: bool, /// Exponential moving average of frame time for stable FPS display. #[serde(skip)] fps_ema: f32, /// Whether we've auto-fitted the start viewport. #[serde(skip)] did_fit_start: bool, /// Last known cursor position (for edge scrolling even without movement). #[serde(skip)] last_cursor_pos: Option, /// Whether a reset-to-start was requested (from UI) #[serde(skip)] reset_view_requested: bool, /// 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, /// Text rendering cache for smooth scaling. #[serde(skip)] text_cache: Option, } 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, render_internal_areas: false, fps_ema: 60.0, last_pointer_pos: None, is_dragging: false, text_cache: None, did_fit_start: false, last_cursor_pos: None, reset_view_requested: false, } } } impl TemplateApp { /// Called once before the first frame. pub fn new(cc: &eframe::CreationContext<'_>) -> Self { log::info!("Initializing application..."); log::debug!("Installing image loaders..."); egui_extras::install_image_loaders(&cc.egui_ctx); log::debug!("Loading app state..."); let mut app: Self = cc .storage .and_then(|s| eframe::get_value(s, eframe::APP_KEY)) .unwrap_or_default(); // Load SVG content let svg_path = "../line-and-surface/content/intro.svg"; log::info!("Loading SVG from: {svg_path}"); let start = std::time::Instant::now(); match SvgContent::from_file(svg_path) { Ok(content) => { let elapsed = start.elapsed(); log::info!( "Loaded SVG in {:.2?}: {} video scrolls, {} audio areas, {} anchors, {} texts", elapsed, content.video_scrolls.len(), content.audio_areas.len(), content.anchors.len(), content.texts.len() ); if let Some((vb_x, vb_y, vb_w, vb_h)) = content.viewbox { log::debug!("SVG viewbox: ({vb_x}, {vb_y}, {vb_w}, {vb_h})"); app.pan_x = -vb_x; app.pan_y = -vb_y; } app.svg_content = Some(content); } Err(e) => { log::error!("Failed to load SVG: {e}"); } } log::info!("Application initialized"); app } /// Handle zoom towards a specific point. fn zoom_towards(&mut self, new_zoom: f32, pos: egui::Pos2, canvas_rect: &egui::Rect) { 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; } } impl eframe::App for TemplateApp { fn save(&mut self, storage: &mut dyn eframe::Storage) { eframe::set_value(storage, eframe::APP_KEY, self); } #[expect(clippy::too_many_lines)] fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Batch input reading 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, i.zoom_delta(), i.pointer.hover_pos(), i.stable_dt, i.pointer.primary_down(), i.pointer.primary_pressed(), i.pointer.primary_released(), ) }); // Update FPS EMA if frame_time > 0.0 { const ALPHA: f32 = 0.1; self.fps_ema = ALPHA * (1.0 / frame_time) + (1.0 - ALPHA) * self.fps_ema; } // Toggle UI elements if escape_pressed { self.show_menu_bar = !self.show_menu_bar; } if f3_pressed { self.show_debug = !self.show_debug; } // Menu bar if self.show_menu_bar { egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { egui::MenuBar::new().ui(ui, |ui| { #[cfg(not(target_arch = "wasm32"))] { 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(); ui.label(format!("Zoom: {:.0}%", self.zoom * 100.0)); if ui.button("Reset View").clicked() { self.reset_view_requested = true; } ui.separator(); let debug_label = if self.show_debug { "Hide Debug (F3)" } else { "Show Debug (F3)" }; if ui.button(debug_label).clicked() { self.show_debug = !self.show_debug; } }); }); } let mut rendered_count = 0u32; let background_rgb = self .svg_content .as_ref() .and_then(|content| content.background_color) .unwrap_or([0, 0, 0]); let background_color = egui::Color32::from_rgb(background_rgb[0], background_rgb[1], background_rgb[2]); egui::CentralPanel::default().show(ctx, |ui| { let (response, painter) = ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag()); let canvas_rect = response.rect; painter.rect_filled(canvas_rect, 0.0, background_color); if !self.did_fit_start { self.reset_view_requested = true; self.did_fit_start = true; } let mut maybe_fit_view = |rect: &egui::Rect| { if let Some(ref content) = self.svg_content { if let Some((sx, sy, sw, sh)) = content.start_rect.or(content.viewbox) { if sw > 0.0 && sh > 0.0 { let scale_x = rect.width() / sw; let scale_y = rect.height() / sh; let fit_zoom = scale_x.min(scale_y).clamp(0.01, 100.0); let visible_w = rect.width() / fit_zoom; let visible_h = rect.height() / fit_zoom; self.zoom = fit_zoom; self.pan_x = -sx + (visible_w - sw) * 0.5; self.pan_y = -sy + (visible_h - sh) * 0.5; return true; } } } false }; if self.reset_view_requested { if maybe_fit_view(&canvas_rect) { self.reset_view_requested = false; } else { // Nothing to fit, still clear the flag to avoid loops self.reset_view_requested = false; } } // Drag handling 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; } if self.is_dragging && primary_down { if let (Some(current), Some(last)) = (pointer_pos, self.last_pointer_pos) { let delta = current - last; self.pan_x += delta.x / self.zoom; self.pan_y += delta.y / self.zoom; } self.last_pointer_pos = pointer_pos; } // Zoom handling if response.hovered() { if scroll_delta != 0.0 { let factor = 1.0 + scroll_delta * 0.001; let new_zoom = (self.zoom * factor).clamp(0.01, 100.0); if let Some(pos) = pointer_pos { self.zoom_towards(new_zoom, pos, &canvas_rect); } else { self.zoom = new_zoom; } } if zoom_delta != 1.0 { let new_zoom = (self.zoom * zoom_delta).clamp(0.01, 100.0); if let Some(pos) = pointer_pos { self.zoom_towards(new_zoom, pos, &canvas_rect); } else { self.zoom = new_zoom; } } } // Element colors let internal_alpha = (0.3_f32 * 255.0).round() as u8; let video_scroll_color = egui::Color32::from_rgba_unmultiplied(255, 0, 0, internal_alpha); let audio_area_color = egui::Color32::from_rgba_unmultiplied(0, 0, 255, internal_alpha); let anchor_color = egui::Color32::from_rgba_unmultiplied(64, 255, 64, internal_alpha); let text_color = egui::Color32::from_rgb(255, 255, 255); // Initialize text cache (renders in white, color applied as tint) if self.text_cache.is_none() { self.text_cache = Some(TextCache::new()); } // Extract values for closures let pan_x = self.pan_x; let pan_y = self.pan_y; let zoom = self.zoom; let svg_to_screen = |x: f32, y: f32| -> egui::Pos2 { egui::pos2( (x + pan_x) * zoom + canvas_rect.left(), (y + pan_y) * zoom + canvas_rect.top(), ) }; let screen_to_svg = |pos: egui::Pos2| -> (f32, f32) { ( (pos.x - canvas_rect.left()) / zoom - pan_x, (pos.y - canvas_rect.top()) / zoom - pan_y, ) }; let text_cache = self.text_cache.as_mut().expect("just initialized"); if let Some(ref content) = self.svg_content { if self.render_internal_areas { let mut hovered_descs: Vec = Vec::new(); let pointer_svg = pointer_pos.map(screen_to_svg); // Video scrolls for vs in &content.video_scrolls { let (x, y, w, h) = vs.bounds(); let rect = egui::Rect::from_min_max( svg_to_screen(x, y), svg_to_screen(x + w, y + h), ); if rect.intersects(canvas_rect) { painter.rect_filled(rect, 0.0, video_scroll_color); rendered_count += 1; } if let Some((svg_x, svg_y)) = pointer_svg { if svg_x >= x && svg_x <= x + w && svg_y >= y && svg_y <= y + h { hovered_descs.push(format!("Video: {}", vs.desc)); } } } // Audio areas for aa in &content.audio_areas { let center = svg_to_screen(aa.cx, aa.cy); let radius = aa.radius * zoom; let rect = egui::Rect::from_min_max( egui::pos2(center.x - radius, center.y - radius), egui::pos2(center.x + radius, center.y + radius), ); if rect.intersects(canvas_rect) { painter.circle_filled(center, radius, audio_area_color); rendered_count += 1; } if let Some((svg_x, svg_y)) = pointer_svg { let dx = svg_x - aa.cx; let dy = svg_y - aa.cy; if dx * dx + dy * dy <= aa.radius * aa.radius { hovered_descs.push(format!("Audio: {}", aa.desc)); } } } // Anchors for anchor in &content.anchors { let (x, y, w, h) = anchor.bounds(); let rect = egui::Rect::from_min_max( svg_to_screen(x, y), svg_to_screen(x + w, y + h), ); if rect.intersects(canvas_rect) { painter.rect_filled(rect, 0.0, anchor_color); rendered_count += 1; } if let Some((svg_x, svg_y)) = pointer_svg { if svg_x >= x && svg_x <= x + w && svg_y >= y && svg_y <= y + h { hovered_descs.push(format!("Anchor: {}", anchor.id)); } } } if !hovered_descs.is_empty() { egui::Tooltip::always_open( ctx.clone(), ui.layer_id(), egui::Id::new("internal-area-desc"), egui::PopupAnchor::Pointer, ) .gap(12.0) .show(|ui| { for desc in hovered_descs { ui.label(desc); } }); } } // Text elements for text_elem in &content.texts { let (x, y, w, h) = text_elem.bounds(); let rect = egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h)); if !rect.intersects(canvas_rect) { continue; } let display_font_size = text_elem.font_size * zoom; if display_font_size >= 0.5 { for line in &text_elem.lines { if line.content.trim().is_empty() { continue; } let cached = text_cache.get_or_create(ctx, &line.content, text_elem.font_size); let scale_factor = zoom / RENDER_SCALE; let display_width = cached.width as f32 * scale_factor; let display_height = cached.height as f32 * scale_factor; let y_adjusted = line.y - text_elem.font_size * 0.8; let pos = svg_to_screen(line.x, y_adjusted); painter.image( cached.texture.id(), egui::Rect::from_min_size( pos, egui::vec2(display_width, display_height), ), egui::Rect::from_min_max( egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0), ), text_color, // Tint the white texture with desired color ); } } rendered_count += 1; } } }); // Debug 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) )); } ui.separator(); ui.checkbox(&mut self.render_internal_areas, "Render internal areas"); if let Some(ref cache) = self.text_cache { ui.separator(); ui.label(format!("Text cache: {} entries", cache.cache_size())); } }); } } }