line-and-surface/src/app.rs

376 lines
14 KiB
Rust

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<SvgContent>,
// 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<egui::Pos2>,
/// 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)
));
}
});
}
}
}