From adfbe04d30a57f114a4d284b328053a8210a6650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Ml=C3=A1dek?= Date: Sun, 25 Jan 2026 01:13:51 +0100 Subject: [PATCH] refactor: split svg.rs & app.rs --- src/app.rs | 795 ---------------------------------- src/app/animation.rs | 164 +++++++ src/app/input.rs | 34 ++ src/app/mod.rs | 186 ++++++++ src/app/render.rs | 428 ++++++++++++++++++ src/lib.rs | 4 +- src/main.rs | 4 +- src/svg/color.rs | 29 ++ src/svg/mod.rs | 12 + src/{svg.rs => svg/parser.rs} | 258 +---------- src/svg/tests.rs | 91 ++++ src/svg/types.rs | 123 ++++++ src/text_cache.rs | 1 + 13 files changed, 1077 insertions(+), 1052 deletions(-) delete mode 100644 src/app.rs create mode 100644 src/app/animation.rs create mode 100644 src/app/input.rs create mode 100644 src/app/mod.rs create mode 100644 src/app/render.rs create mode 100644 src/svg/color.rs create mode 100644 src/svg/mod.rs rename src/{svg.rs => svg/parser.rs} (74%) create mode 100644 src/svg/tests.rs create mode 100644 src/svg/types.rs diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index 1563e67..0000000 --- a/src/app.rs +++ /dev/null @@ -1,795 +0,0 @@ -use crate::svg::{Renderable as _, SvgContent}; -use crate::text_cache::TextCache; - -/// 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, - - /// Smooth camera animation state. - #[serde(skip)] - view_animation: Option, - - /// 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, - view_animation: None, - } - } -} - -#[derive(Debug, Clone)] -struct ViewAnimation { - start_center_x: f32, - start_center_y: f32, - start_zoom: f32, - target_center_x: f32, - target_center_y: f32, - target_zoom: f32, - elapsed: f32, - duration: f32, -} - -impl TemplateApp { - const NAV_DURATION: f32 = 1.5; - - /// 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 - } - - fn cancel_animation(&mut self) { - self.view_animation = None; - } - - fn begin_view_animation( - &mut self, - target_pan_x: f32, - target_pan_y: f32, - target_zoom: f32, - duration: f32, - canvas_rect: &egui::Rect, - ) { - if duration <= 0.0 { - self.pan_x = target_pan_x; - self.pan_y = target_pan_y; - self.zoom = target_zoom; - self.view_animation = None; - return; - } - - let (start_center_x, start_center_y) = - view_center(self.pan_x, self.pan_y, self.zoom, canvas_rect); - let (target_center_x, target_center_y) = - view_center(target_pan_x, target_pan_y, target_zoom, canvas_rect); - - self.view_animation = Some(ViewAnimation { - start_center_x, - start_center_y, - start_zoom: self.zoom, - target_center_x, - target_center_y, - target_zoom, - elapsed: 0.0, - duration, - }); - } - - fn update_animation(&mut self, dt: f32, canvas_rect: &egui::Rect) { - if let Some(anim) = &mut self.view_animation { - anim.elapsed += dt; - let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0); - let eased = ease_in_out_cubic(t); - - let zoom = lerp( - anim.start_zoom, - anim.target_zoom, - zoom_ease(t, anim.start_zoom, anim.target_zoom), - ); - let center_x = lerp(anim.start_center_x, anim.target_center_x, eased); - let center_y = lerp(anim.start_center_y, anim.target_center_y, eased); - - if canvas_rect.width() > 0.0 && canvas_rect.height() > 0.0 { - let visible_w = canvas_rect.width() / zoom; - let visible_h = canvas_rect.height() / zoom; - self.pan_x = -center_x + visible_w * 0.5; - self.pan_y = -center_y + visible_h * 0.5; - } - - self.zoom = zoom; - - if t >= 1.0 { - self.view_animation = None; - } - } - } - - fn request_view_to_rect( - &mut self, - rect: (f32, f32, f32, f32), - canvas_rect: &egui::Rect, - duration: f32, - ) { - if let Some((pan_x, pan_y, zoom)) = compute_view_for_rect(rect, canvas_rect) { - self.begin_view_animation(pan_x, pan_y, zoom, duration, canvas_rect); - } - } - - /// 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; - } -} - -fn ease_in_out_cubic(t: f32) -> f32 { - if t < 0.5 { - 4.0 * t * t * t - } else { - 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 - } -} - -fn ease_out_cubic(t: f32) -> f32 { - 1.0 - (1.0 - t).powi(3) -} - -fn zoom_ease(t: f32, start: f32, target: f32) -> f32 { - if target > start { - // Zooming in: bias progress late but soften landing - let biased = t.powf(1.6); - ease_in_out_cubic(biased) - } else { - // Zooming out: shed zoom early - ease_out_cubic(t) - } -} - -fn lerp(a: f32, b: f32, t: f32) -> f32 { - a + (b - a) * t -} - -fn compute_view_for_rect( - rect: (f32, f32, f32, f32), - canvas_rect: &egui::Rect, -) -> Option<(f32, f32, f32)> { - if canvas_rect.width() <= 0.0 || canvas_rect.height() <= 0.0 { - return None; - } - - let (sx, sy, sw, sh) = rect; - if sw <= 0.0 || sh <= 0.0 { - return None; - } - - let scale_x = canvas_rect.width() / sw; - let scale_y = canvas_rect.height() / sh; - let fit_zoom = scale_x.min(scale_y).clamp(0.01, 100.0); - let visible_w = canvas_rect.width() / fit_zoom; - let visible_h = canvas_rect.height() / fit_zoom; - - let pan_x = -sx + (visible_w - sw) * 0.5; - let pan_y = -sy + (visible_h - sh) * 0.5; - - Some((pan_x, pan_y, fit_zoom)) -} - -fn view_center(pan_x: f32, pan_y: f32, zoom: f32, canvas_rect: &egui::Rect) -> (f32, f32) { - let visible_w = canvas_rect.width() / zoom; - let visible_h = canvas_rect.height() / zoom; - (-pan_x + visible_w * 0.5, -pan_y + visible_h * 0.5) -} - -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, - f_pressed, - scroll_delta, - zoom_delta, - pointer_latest, - pointer_pos, - frame_time, - primary_down, - primary_pressed, - primary_released, - space_pressed, - ) = ctx.input(|i| { - ( - i.key_pressed(egui::Key::Escape), - i.key_pressed(egui::Key::F3), - i.key_pressed(egui::Key::F), - i.smooth_scroll_delta.y, - i.zoom_delta(), - i.pointer.latest_pos(), - i.pointer.hover_pos(), - i.stable_dt, - i.pointer.primary_down(), - i.pointer.primary_pressed(), - i.pointer.primary_released(), - i.key_pressed(egui::Key::Space), - ) - }); - - if primary_pressed || scroll_delta != 0.0 || (zoom_delta - 1.0).abs() > f32::EPSILON { - self.cancel_animation(); - } - - if let Some(pos) = pointer_latest.or(pointer_pos) { - self.last_cursor_pos = Some(pos); - } - - let is_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); - - // 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); - - // Advance any pending camera animation (needs canvas size) - let anim_dt = if frame_time > 0.0 { - frame_time - } else { - 1.0 / 60.0 - }; - self.update_animation(anim_dt, &canvas_rect); - if self.view_animation.is_some() { - ctx.request_repaint(); - } - - // Fullscreen toggle via double-click or 'F' - if response.double_clicked() || f_pressed { - ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!is_fullscreen)); - } - - if !self.did_fit_start { - if let Some(ref content) = self.svg_content { - if let Some(rect) = content.start_rect.or(content.viewbox) { - if let Some((pan_x, pan_y, zoom)) = - compute_view_for_rect(rect, &canvas_rect) - { - self.pan_x = pan_x; - self.pan_y = pan_y; - self.zoom = zoom; - } - } - } - self.did_fit_start = true; - ctx.request_repaint(); - } - - if space_pressed { - self.reset_view_requested = true; - } - - if self.reset_view_requested { - if let Some(ref content) = self.svg_content { - if let Some(rect) = content.start_rect.or(content.viewbox) { - self.request_view_to_rect(rect, &canvas_rect, TemplateApp::NAV_DURATION); - } - } - // Always clear to avoid loops even if no rect present - 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; - } - } - } - - // Edge scrolling (only in fullscreen) - let edge_pointer = pointer_latest.or(self.last_cursor_pos); - if is_fullscreen { - if let Some(mouse_pos) = edge_pointer { - let dt = if frame_time > 0.0 { - frame_time - } else { - 1.0 / 60.0 - }; - let nx = if canvas_rect.width() > 0.0 { - ((mouse_pos.x - canvas_rect.left()) / canvas_rect.width()).clamp(0.0, 1.0) - } else { - 0.5 - }; - let ny = if canvas_rect.height() > 0.0 { - ((mouse_pos.y - canvas_rect.top()) / canvas_rect.height()).clamp(0.0, 1.0) - } else { - 0.5 - }; - - let edge_threshold = 0.15; - let mut vx = 0.0; - let mut vy = 0.0; - - if nx < edge_threshold { - vx = (edge_threshold - nx) / edge_threshold; - } else if nx > 1.0 - edge_threshold { - vx = -(nx - (1.0 - edge_threshold)) / edge_threshold; - } - - if ny < edge_threshold { - vy = (edge_threshold - ny) / edge_threshold; - } else if ny > 1.0 - edge_threshold { - vy = -(ny - (1.0 - edge_threshold)) / edge_threshold; - } - - if vx != 0.0 || vy != 0.0 { - let view_w = canvas_rect.width() / self.zoom; - let view_h = canvas_rect.height() / self.zoom; - let speed_w = view_w; // per second at full intensity - let speed_h = view_h; // per second at full intensity - - self.pan_x += vx * speed_w * dt; - self.pan_y += vy * speed_h * dt; - - // keep scrolling even without new input events - ctx.request_repaint(); - } - } - } - - // 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 mut pending_navigation: Option<(f32, f32, f32, f32)> = None; - - if let Some(ref content) = self.svg_content { - let pointer_svg_hover = pointer_latest.or(pointer_pos).and_then(|p| { - if canvas_rect.contains(p) { - Some(screen_to_svg(p)) - } else { - None - } - }); - - let click_svg_pos = if primary_released && !self.is_dragging { - pointer_latest.or(pointer_pos).and_then(|p| { - if canvas_rect.contains(p) { - Some(screen_to_svg(p)) - } else { - None - } - }) - } else { - None - }; - - if let Some((svg_x, svg_y)) = click_svg_pos { - if let Some(link) = content.anchor_links.iter().find(|link| { - svg_x >= link.x - && svg_x <= link.x + link.width - && svg_y >= link.y - && svg_y <= link.y + link.height - }) { - if let Some(anchor) = - content.anchors.iter().find(|a| a.id == link.target_id) - { - pending_navigation = Some(anchor.bounds()); - } else { - log::warn!("Anchor link target not found: {}", link.target_id); - } - } - } - - if let Some((svg_x, svg_y)) = pointer_svg_hover { - if content.anchor_links.iter().any(|link| { - svg_x >= link.x - && svg_x <= link.x + link.width - && svg_y >= link.y - && svg_y <= link.y + link.height - }) { - ctx.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand); - } - } - - 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 - let text_cache = self.text_cache.as_mut().expect("just initialized"); - 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, - zoom, - ); - let scale_factor = zoom / cached.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; - } - } - - if let Some(rect) = pending_navigation { - self.request_view_to_rect(rect, &canvas_rect, TemplateApp::NAV_DURATION); - } - }); - - // 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(); - let bytes = cache.cache_memory_bytes(); - let mb = bytes as f32 / (1024.0 * 1024.0); - ui.label(format!( - "Text cache: {} entries (~{mb:.2} MB)", - cache.cache_size() - )); - } - }); - } - } -} diff --git a/src/app/animation.rs b/src/app/animation.rs new file mode 100644 index 0000000..b568af9 --- /dev/null +++ b/src/app/animation.rs @@ -0,0 +1,164 @@ +use super::LasApp; + +#[derive(Debug, Clone)] +pub(super) struct ViewAnimation { + start_center_x: f32, + start_center_y: f32, + start_zoom: f32, + target_center_x: f32, + target_center_y: f32, + target_zoom: f32, + elapsed: f32, + duration: f32, +} + +impl LasApp { + pub(super) fn cancel_animation(&mut self) { + self.view_animation = None; + } + + pub(super) fn begin_view_animation( + &mut self, + target_pan_x: f32, + target_pan_y: f32, + target_zoom: f32, + duration: f32, + canvas_rect: &egui::Rect, + ) { + if duration <= 0.0 { + self.pan_x = target_pan_x; + self.pan_y = target_pan_y; + self.zoom = target_zoom; + self.view_animation = None; + return; + } + + let (start_center_x, start_center_y) = + view_center(self.pan_x, self.pan_y, self.zoom, canvas_rect); + let (target_center_x, target_center_y) = + view_center(target_pan_x, target_pan_y, target_zoom, canvas_rect); + + self.view_animation = Some(ViewAnimation { + start_center_x, + start_center_y, + start_zoom: self.zoom, + target_center_x, + target_center_y, + target_zoom, + elapsed: 0.0, + duration, + }); + } + + pub(super) fn update_animation(&mut self, dt: f32, canvas_rect: &egui::Rect) { + if let Some(anim) = &mut self.view_animation { + anim.elapsed += dt; + let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0); + let eased = ease_in_out_cubic(t); + + let zoom = lerp( + anim.start_zoom, + anim.target_zoom, + zoom_ease(t, anim.start_zoom, anim.target_zoom), + ); + let center_x = lerp(anim.start_center_x, anim.target_center_x, eased); + let center_y = lerp(anim.start_center_y, anim.target_center_y, eased); + + if canvas_rect.width() > 0.0 && canvas_rect.height() > 0.0 { + let visible_w = canvas_rect.width() / zoom; + let visible_h = canvas_rect.height() / zoom; + self.pan_x = -center_x + visible_w * 0.5; + self.pan_y = -center_y + visible_h * 0.5; + } + + self.zoom = zoom; + + if t >= 1.0 { + self.view_animation = None; + } + } + } + + pub(super) fn request_view_to_rect( + &mut self, + rect: (f32, f32, f32, f32), + canvas_rect: &egui::Rect, + duration: f32, + ) { + if let Some((pan_x, pan_y, zoom)) = compute_view_for_rect(rect, canvas_rect) { + self.begin_view_animation(pan_x, pan_y, zoom, duration, canvas_rect); + } + } + + /// Handle zoom towards a specific point. + pub(super) 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; + } +} + +fn ease_in_out_cubic(t: f32) -> f32 { + if t < 0.5 { + 4.0 * t * t * t + } else { + 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 + } +} + +fn ease_out_cubic(t: f32) -> f32 { + 1.0 - (1.0 - t).powi(3) +} + +fn zoom_ease(t: f32, start: f32, target: f32) -> f32 { + if target > start { + // Zooming in: bias progress late but soften landing + let biased = t.powf(1.6); + ease_in_out_cubic(biased) + } else { + // Zooming out: shed zoom early + ease_out_cubic(t) + } +} + +fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} + +pub(super) fn compute_view_for_rect( + rect: (f32, f32, f32, f32), + canvas_rect: &egui::Rect, +) -> Option<(f32, f32, f32)> { + if canvas_rect.width() <= 0.0 || canvas_rect.height() <= 0.0 { + return None; + } + + let (sx, sy, sw, sh) = rect; + if sw <= 0.0 || sh <= 0.0 { + return None; + } + + let scale_x = canvas_rect.width() / sw; + let scale_y = canvas_rect.height() / sh; + let fit_zoom = scale_x.min(scale_y).clamp(0.01, 100.0); + let visible_w = canvas_rect.width() / fit_zoom; + let visible_h = canvas_rect.height() / fit_zoom; + + let pan_x = -sx + (visible_w - sw) * 0.5; + let pan_y = -sy + (visible_h - sh) * 0.5; + + Some((pan_x, pan_y, fit_zoom)) +} + +fn view_center(pan_x: f32, pan_y: f32, zoom: f32, canvas_rect: &egui::Rect) -> (f32, f32) { + let visible_w = canvas_rect.width() / zoom; + let visible_h = canvas_rect.height() / zoom; + (-pan_x + visible_w * 0.5, -pan_y + visible_h * 0.5) +} diff --git a/src/app/input.rs b/src/app/input.rs new file mode 100644 index 0000000..e1d64cb --- /dev/null +++ b/src/app/input.rs @@ -0,0 +1,34 @@ +#[derive(Clone, Copy)] +pub(super) struct InputSnapshot { + pub(super) escape_pressed: bool, + pub(super) f3_pressed: bool, + pub(super) f_pressed: bool, + pub(super) scroll_delta: f32, + pub(super) zoom_delta: f32, + pub(super) pointer_latest: Option, + pub(super) pointer_pos: Option, + pub(super) frame_time: f32, + pub(super) primary_down: bool, + pub(super) primary_pressed: bool, + pub(super) primary_released: bool, + pub(super) space_pressed: bool, +} + +impl InputSnapshot { + pub(super) fn collect(ctx: &egui::Context) -> Self { + ctx.input(|i| Self { + escape_pressed: i.key_pressed(egui::Key::Escape), + f3_pressed: i.key_pressed(egui::Key::F3), + f_pressed: i.key_pressed(egui::Key::F), + scroll_delta: i.smooth_scroll_delta.y, + zoom_delta: i.zoom_delta(), + pointer_latest: i.pointer.latest_pos(), + pointer_pos: i.pointer.hover_pos(), + frame_time: i.stable_dt, + primary_down: i.pointer.primary_down(), + primary_pressed: i.pointer.primary_pressed(), + primary_released: i.pointer.primary_released(), + space_pressed: i.key_pressed(egui::Key::Space), + }) + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..4536964 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,186 @@ +use crate::svg::SvgContent; +use crate::text_cache::TextCache; + +mod animation; +mod input; +mod render; + +use input::InputSnapshot; + +/// We derive Deserialize/Serialize so we can persist app state on shutdown. +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(default)] +pub struct LasApp { + #[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, + + /// Smooth camera animation state. + #[serde(skip)] + view_animation: Option, + + /// 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 LasApp { + 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, + view_animation: None, + } + } +} + +impl LasApp { + const NAV_DURATION: f32 = 1.5; + + /// 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 + } + + fn update_fps(&mut self, frame_time: f32) { + if frame_time > 0.0 { + const ALPHA: f32 = 0.1; + self.fps_ema = ALPHA * (1.0 / frame_time) + (1.0 - ALPHA) * self.fps_ema; + } + } +} + +impl eframe::App for LasApp { + fn save(&mut self, storage: &mut dyn eframe::Storage) { + eframe::set_value(storage, eframe::APP_KEY, self); + } + + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + let input = InputSnapshot::collect(ctx); + + if input.primary_pressed + || input.scroll_delta != 0.0 + || (input.zoom_delta - 1.0).abs() > f32::EPSILON + { + self.cancel_animation(); + } + + if let Some(pos) = input.pointer_latest.or(input.pointer_pos) { + self.last_cursor_pos = Some(pos); + } + + let is_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); + + self.update_fps(input.frame_time); + + if input.escape_pressed { + self.show_menu_bar = !self.show_menu_bar; + } + if input.f3_pressed { + self.show_debug = !self.show_debug; + } + + if self.show_menu_bar { + self.render_menu_bar(ctx); + } + + let rendered_count = egui::CentralPanel::default() + .show(ctx, |ui| self.render_canvas(ctx, ui, &input, is_fullscreen)) + .inner; + + if self.show_debug { + self.render_debug_window(ctx, rendered_count); + } + } +} diff --git a/src/app/render.rs b/src/app/render.rs new file mode 100644 index 0000000..954a648 --- /dev/null +++ b/src/app/render.rs @@ -0,0 +1,428 @@ +use super::LasApp; +use super::animation::compute_view_for_rect; +use super::input::InputSnapshot; +use crate::svg::Renderable as _; + +impl LasApp { + pub(super) fn render_menu_bar(&mut self, ctx: &egui::Context) { + 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; + } + }); + }); + } + + pub(super) fn render_debug_window(&mut self, ctx: &egui::Context, rendered_count: u32) { + 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(); + let bytes = cache.cache_memory_bytes(); + let mb = bytes as f32 / (1024.0 * 1024.0); + ui.label(format!( + "Text cache: {} entries (~{mb:.2} MB)", + cache.cache_size() + )); + } + }); + } + + #[expect(clippy::too_many_lines)] + pub(super) fn render_canvas( + &mut self, + ctx: &egui::Context, + ui: &mut egui::Ui, + input: &InputSnapshot, + is_fullscreen: bool, + ) -> u32 { + let (response, painter) = + ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag()); + let canvas_rect = response.rect; + + 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]); + painter.rect_filled(canvas_rect, 0.0, background_color); + + let anim_dt = if input.frame_time > 0.0 { + input.frame_time + } else { + 1.0 / 60.0 + }; + self.update_animation(anim_dt, &canvas_rect); + if self.view_animation.is_some() { + ctx.request_repaint(); + } + + if response.double_clicked() || input.f_pressed { + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!is_fullscreen)); + } + + if !self.did_fit_start { + if let Some(ref content) = self.svg_content { + if let Some(rect) = content.start_rect.or(content.viewbox) { + if let Some((pan_x, pan_y, zoom)) = compute_view_for_rect(rect, &canvas_rect) { + self.pan_x = pan_x; + self.pan_y = pan_y; + self.zoom = zoom; + } + } + } + self.did_fit_start = true; + ctx.request_repaint(); + } + + if input.space_pressed { + self.reset_view_requested = true; + } + + if self.reset_view_requested { + if let Some(ref content) = self.svg_content { + if let Some(rect) = content.start_rect.or(content.viewbox) { + self.request_view_to_rect(rect, &canvas_rect, Self::NAV_DURATION); + } + } + self.reset_view_requested = false; + } + + if input.primary_pressed && response.hovered() { + self.is_dragging = true; + self.last_pointer_pos = input.pointer_pos; + } + if input.primary_released { + self.is_dragging = false; + self.last_pointer_pos = None; + } + if self.is_dragging && input.primary_down { + if let (Some(current), Some(last)) = (input.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 = input.pointer_pos; + } + + if response.hovered() { + if input.scroll_delta != 0.0 { + let factor = 1.0 + input.scroll_delta * 0.001; + let new_zoom = (self.zoom * factor).clamp(0.01, 100.0); + if let Some(pos) = input.pointer_pos { + self.zoom_towards(new_zoom, pos, &canvas_rect); + } else { + self.zoom = new_zoom; + } + } + if input.zoom_delta != 1.0 { + let new_zoom = (self.zoom * input.zoom_delta).clamp(0.01, 100.0); + if let Some(pos) = input.pointer_pos { + self.zoom_towards(new_zoom, pos, &canvas_rect); + } else { + self.zoom = new_zoom; + } + } + } + + let edge_pointer = input.pointer_latest.or(self.last_cursor_pos); + if is_fullscreen { + if let Some(mouse_pos) = edge_pointer { + let dt = if input.frame_time > 0.0 { + input.frame_time + } else { + 1.0 / 60.0 + }; + let nx = if canvas_rect.width() > 0.0 { + ((mouse_pos.x - canvas_rect.left()) / canvas_rect.width()).clamp(0.0, 1.0) + } else { + 0.5 + }; + let ny = if canvas_rect.height() > 0.0 { + ((mouse_pos.y - canvas_rect.top()) / canvas_rect.height()).clamp(0.0, 1.0) + } else { + 0.5 + }; + + let edge_threshold = 0.15; + let mut vx = 0.0; + let mut vy = 0.0; + + if nx < edge_threshold { + vx = (edge_threshold - nx) / edge_threshold; + } else if nx > 1.0 - edge_threshold { + vx = -(nx - (1.0 - edge_threshold)) / edge_threshold; + } + + if ny < edge_threshold { + vy = (edge_threshold - ny) / edge_threshold; + } else if ny > 1.0 - edge_threshold { + vy = -(ny - (1.0 - edge_threshold)) / edge_threshold; + } + + if vx != 0.0 || vy != 0.0 { + let view_w = canvas_rect.width() / self.zoom; + let view_h = canvas_rect.height() / self.zoom; + let speed_w = view_w; + let speed_h = view_h; + + self.pan_x += vx * speed_w * dt; + self.pan_y += vy * speed_h * dt; + + ctx.request_repaint(); + } + } + } + + 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); + + if self.text_cache.is_none() { + self.text_cache = Some(crate::text_cache::TextCache::new()); + } + + 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 mut pending_navigation: Option<(f32, f32, f32, f32)> = None; + let mut rendered_count = 0u32; + + if let Some(ref content) = self.svg_content { + let pointer_svg_hover = input.pointer_latest.or(input.pointer_pos).and_then(|p| { + if canvas_rect.contains(p) { + Some(screen_to_svg(p)) + } else { + None + } + }); + + let click_svg_pos = if input.primary_released && !self.is_dragging { + input.pointer_latest.or(input.pointer_pos).and_then(|p| { + if canvas_rect.contains(p) { + Some(screen_to_svg(p)) + } else { + None + } + }) + } else { + None + }; + + if let Some((svg_x, svg_y)) = click_svg_pos { + if let Some(link) = content.anchor_links.iter().find(|link| { + svg_x >= link.x + && svg_x <= link.x + link.width + && svg_y >= link.y + && svg_y <= link.y + link.height + }) { + if let Some(anchor) = content.anchors.iter().find(|a| a.id == link.target_id) { + pending_navigation = Some(anchor.bounds()); + } else { + log::warn!("Anchor link target not found: {}", link.target_id); + } + } + } + + if let Some((svg_x, svg_y)) = pointer_svg_hover { + if content.anchor_links.iter().any(|link| { + svg_x >= link.x + && svg_x <= link.x + link.width + && svg_y >= link.y + && svg_y <= link.y + link.height + }) { + ctx.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand); + } + } + + if self.render_internal_areas { + let mut hovered_descs: Vec = Vec::new(); + let pointer_svg = input.pointer_pos.map(screen_to_svg); + + 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)); + } + } + } + + 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)); + } + } + } + + 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); + } + }); + } + } + + let text_cache = self.text_cache.as_mut().expect("just initialized"); + 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, zoom); + let scale_factor = zoom / cached.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, + ); + } + } + + rendered_count += 1; + } + } + + if let Some(rect) = pending_navigation { + self.request_view_to_rect(rect, &canvas_rect, Self::NAV_DURATION); + } + + rendered_count + } +} diff --git a/src/lib.rs b/src/lib.rs index 0a778c3..90601d3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ -#![warn(clippy::all, rust_2018_idioms)] +#![warn(clippy::all)] mod app; pub mod svg; mod text_cache; -pub use app::TemplateApp; +pub use app::LasApp; diff --git a/src/main.rs b/src/main.rs index 1031007..a8c4ccf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,7 @@ fn main() -> eframe::Result { eframe::run_native( "Line and Surface", native_options, - Box::new(|cc| Ok(Box::new(las::TemplateApp::new(cc)))), + Box::new(|cc| Ok(Box::new(las::LasApp::new(cc)))), ) } @@ -56,7 +56,7 @@ fn main() { .start( canvas, web_options, - Box::new(|cc| Ok(Box::new(las::TemplateApp::new(cc)))), + Box::new(|cc| Ok(Box::new(las::LasApp::new(cc)))), ) .await; diff --git a/src/svg/color.rs b/src/svg/color.rs new file mode 100644 index 0000000..e1923a7 --- /dev/null +++ b/src/svg/color.rs @@ -0,0 +1,29 @@ +pub(super) fn parse_background_color(style: &str) -> Option<[u8; 3]> { + for entry in style.split(';') { + let entry = entry.trim(); + if let Some(value) = entry.strip_prefix("background-color:") { + let value = value.trim(); + return parse_hex_color(value); + } + } + None +} + +fn parse_hex_color(value: &str) -> Option<[u8; 3]> { + let hex = value.strip_prefix('#')?.trim(); + match hex.len() { + 3 => { + let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?; + let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?; + let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?; + Some([r, g, b]) + } + 6 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some([r, g, b]) + } + _ => None, + } +} diff --git a/src/svg/mod.rs b/src/svg/mod.rs new file mode 100644 index 0000000..b669dc1 --- /dev/null +++ b/src/svg/mod.rs @@ -0,0 +1,12 @@ +//! SVG parsing module for extracting special elements from SVG files. + +mod color; +mod parser; +mod types; + +#[cfg(test)] +mod tests; + +pub use types::{ + Anchor, AnchorLink, AudioArea, Renderable, SvgContent, TextElement, TextLine, VideoScroll, +}; diff --git a/src/svg.rs b/src/svg/parser.rs similarity index 74% rename from src/svg.rs rename to src/svg/parser.rs index cf583b1..e5913ce 100644 --- a/src/svg.rs +++ b/src/svg/parser.rs @@ -1,133 +1,10 @@ -//! SVG parsing module for extracting special elements from SVG files. - -use quick_xml::events::Event; +use super::Renderable as _; +use super::color::parse_background_color; +use super::types::{Anchor, AnchorLink, AudioArea, SvgContent, TextElement, TextLine, VideoScroll}; use quick_xml::Reader; +use quick_xml::events::Event; use std::fs; -/// Trait for elements that can be rendered with a bounding box. -pub trait Renderable { - /// Returns the bounding box: (x, y, width, height) in SVG coordinates. - fn bounds(&self) -> (f32, f32, f32, f32); -} - -/// An `` element with a `` child (video scroll area). -#[derive(Debug, Clone)] -pub struct VideoScroll { - pub x: f32, - pub y: f32, - pub width: f32, - pub height: f32, - pub desc: String, -} - -impl Renderable for VideoScroll { - fn bounds(&self) -> (f32, f32, f32, f32) { - (self.x, self.y, self.width, self.height) - } -} - -/// A `` or `` element with a `` child (audio area). -#[derive(Debug, Clone)] -pub struct AudioArea { - pub cx: f32, - pub cy: f32, - pub radius: f32, - pub desc: String, -} - -impl Renderable for AudioArea { - fn bounds(&self) -> (f32, f32, f32, f32) { - ( - self.cx - self.radius, - self.cy - self.radius, - self.radius * 2.0, - self.radius * 2.0, - ) - } -} - -/// A `` element with id starting with "anchor". -#[derive(Debug, Clone)] -pub struct Anchor { - pub id: String, - pub x: f32, - pub y: f32, - pub width: f32, - pub height: f32, -} - -impl Renderable for Anchor { - fn bounds(&self) -> (f32, f32, f32, f32) { - (self.x, self.y, self.width, self.height) - } -} - -/// A single line of text (tspan). -#[derive(Debug, Clone)] -pub struct TextLine { - pub x: f32, - pub y: f32, - pub content: String, -} - -/// A `` element with multiple lines. -#[derive(Debug, Clone)] -pub struct TextElement { - pub lines: Vec, - pub font_size: f32, // Parsed from style attribute -} - -/// A hyperlink pointing to an anchor by id. -#[derive(Debug, Clone)] -pub struct AnchorLink { - pub target_id: String, - pub x: f32, - pub y: f32, - pub width: f32, - pub height: f32, -} - -impl Renderable for TextElement { - fn bounds(&self) -> (f32, f32, f32, f32) { - if self.lines.is_empty() { - return (0.0, 0.0, 0.0, 0.0); - } - - let min_x = self.lines.iter().map(|l| l.x).fold(f32::INFINITY, f32::min); - let min_y = self - .lines - .iter() - .map(|l| l.y - self.font_size) - .fold(f32::INFINITY, f32::min); - - let max_x = self - .lines - .iter() - .map(|l| l.x + l.content.len() as f32 * self.font_size * 0.6) - .fold(f32::NEG_INFINITY, f32::max); - let max_y = self - .lines - .iter() - .map(|l| l.y) - .fold(f32::NEG_INFINITY, f32::max); - - (min_x, min_y, max_x - min_x, max_y - min_y) - } -} - -/// Container for all parsed SVG content. -#[derive(Debug, Clone, Default)] -pub struct SvgContent { - pub video_scrolls: Vec, - pub audio_areas: Vec, - pub anchors: Vec, - pub anchor_links: Vec, - pub texts: Vec, - pub viewbox: Option<(f32, f32, f32, f32)>, // (min_x, min_y, width, height) - pub background_color: Option<[u8; 3]>, - pub start_rect: Option<(f32, f32, f32, f32)>, -} - /// State for tracking current element during parsing. #[derive(Debug, Clone)] enum PendingElement { @@ -246,7 +123,7 @@ impl SvgContent { let cleaned = value.trim_start_matches('#'); if cleaned.starts_with("anchor") { current_link = - Some(LinkAccumulator::new(cleaned.to_string())); + Some(LinkAccumulator::new(cleaned.to_owned())); } } } @@ -598,128 +475,3 @@ impl SvgContent { Ok(svg_content) } } - -fn parse_background_color(style: &str) -> Option<[u8; 3]> { - for entry in style.split(';') { - let entry = entry.trim(); - if let Some(value) = entry.strip_prefix("background-color:") { - let value = value.trim(); - return parse_hex_color(value); - } - } - None -} - -fn parse_hex_color(value: &str) -> Option<[u8; 3]> { - let hex = value.strip_prefix('#')?.trim(); - match hex.len() { - 3 => { - let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?; - let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?; - let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?; - Some([r, g, b]) - } - 6 => { - let r = u8::from_str_radix(&hex[0..2], 16).ok()?; - let g = u8::from_str_radix(&hex[2..4], 16).ok()?; - let b = u8::from_str_radix(&hex[4..6], 16).ok()?; - Some([r, g, b]) - } - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_video_scroll() { - let svg = r#" - - - horizontal video.mp4 - - - "#; - - let content = SvgContent::parse(svg).expect("Failed to parse SVG"); - assert_eq!(content.video_scrolls.len(), 1); - let vs = &content.video_scrolls[0]; - assert_eq!(vs.x, 100.0); - assert_eq!(vs.y, 200.0); - assert_eq!(vs.width, 300.0); - assert_eq!(vs.height, 400.0); - assert_eq!(vs.desc, "horizontal video.mp4"); - } - - #[test] - fn test_parse_audio_area_circle() { - let svg = r#" - - - ambient.mp3 - - - "#; - - let content = SvgContent::parse(svg).expect("Failed to parse SVG"); - assert_eq!(content.audio_areas.len(), 1); - let aa = &content.audio_areas[0]; - assert_eq!(aa.cx, 500.0); - assert_eq!(aa.cy, 500.0); - assert_eq!(aa.radius, 100.0); - - let (x, y, w, h) = aa.bounds(); - assert_eq!(x, 400.0); - assert_eq!(y, 400.0); - assert_eq!(w, 200.0); - assert_eq!(h, 200.0); - } - - #[test] - fn test_parse_anchor() { - let svg = r#" - - - - "#; - - let content = SvgContent::parse(svg).expect("Failed to parse SVG"); - assert_eq!(content.anchors.len(), 1); - let anchor = &content.anchors[0]; - assert_eq!(anchor.id, "anchor-home"); - assert_eq!(anchor.x, 10.0); - assert_eq!(anchor.y, 20.0); - } - - #[test] - fn test_parse_text() { - let svg = r#" - - - Hello World - Second Line - - - "#; - - let content = SvgContent::parse(svg).expect("Failed to parse SVG"); - assert_eq!(content.texts.len(), 1); - let text = &content.texts[0]; - assert_eq!(text.lines.len(), 2); - assert_eq!(text.lines[0].x, 100.0); - assert_eq!(text.lines[0].y, 200.0); - assert_eq!(text.lines[0].content, "Hello World"); - assert_eq!(text.lines[1].content, "Second Line"); - assert_eq!(text.font_size, 12.0); - } - - #[test] - fn test_viewbox() { - let svg = r#""#; - - let content = SvgContent::parse(svg).expect("Failed to parse SVG"); - assert_eq!(content.viewbox, Some((0.0, 0.0, 1920.0, 1080.0))); - } -} diff --git a/src/svg/tests.rs b/src/svg/tests.rs new file mode 100644 index 0000000..a2d9cc0 --- /dev/null +++ b/src/svg/tests.rs @@ -0,0 +1,91 @@ +use super::{Renderable, SvgContent}; + +#[test] +fn test_parse_video_scroll() { + let svg = r#" + + + horizontal video.mp4 + + + "#; + + let content = SvgContent::parse(svg).expect("Failed to parse SVG"); + assert_eq!(content.video_scrolls.len(), 1); + let vs = &content.video_scrolls[0]; + assert_eq!(vs.x, 100.0); + assert_eq!(vs.y, 200.0); + assert_eq!(vs.width, 300.0); + assert_eq!(vs.height, 400.0); + assert_eq!(vs.desc, "horizontal video.mp4"); +} + +#[test] +fn test_parse_audio_area_circle() { + let svg = r#" + + + ambient.mp3 + + + "#; + + let content = SvgContent::parse(svg).expect("Failed to parse SVG"); + assert_eq!(content.audio_areas.len(), 1); + let aa = &content.audio_areas[0]; + assert_eq!(aa.cx, 500.0); + assert_eq!(aa.cy, 500.0); + assert_eq!(aa.radius, 100.0); + + let (x, y, w, h) = aa.bounds(); + assert_eq!(x, 400.0); + assert_eq!(y, 400.0); + assert_eq!(w, 200.0); + assert_eq!(h, 200.0); +} + +#[test] +fn test_parse_anchor() { + let svg = r#" + + + + "#; + + let content = SvgContent::parse(svg).expect("Failed to parse SVG"); + assert_eq!(content.anchors.len(), 1); + let anchor = &content.anchors[0]; + assert_eq!(anchor.id, "anchor-home"); + assert_eq!(anchor.x, 10.0); + assert_eq!(anchor.y, 20.0); +} + +#[test] +fn test_parse_text() { + let svg = r#" + + + Hello World + Second Line + + + "#; + + let content = SvgContent::parse(svg).expect("Failed to parse SVG"); + assert_eq!(content.texts.len(), 1); + let text = &content.texts[0]; + assert_eq!(text.lines.len(), 2); + assert_eq!(text.lines[0].x, 100.0); + assert_eq!(text.lines[0].y, 200.0); + assert_eq!(text.lines[0].content, "Hello World"); + assert_eq!(text.lines[1].content, "Second Line"); + assert_eq!(text.font_size, 12.0); +} + +#[test] +fn test_viewbox() { + let svg = r#""#; + + let content = SvgContent::parse(svg).expect("Failed to parse SVG"); + assert_eq!(content.viewbox, Some((0.0, 0.0, 1920.0, 1080.0))); +} diff --git a/src/svg/types.rs b/src/svg/types.rs new file mode 100644 index 0000000..726c718 --- /dev/null +++ b/src/svg/types.rs @@ -0,0 +1,123 @@ +/// Trait for elements that can be rendered with a bounding box. +pub trait Renderable { + /// Returns the bounding box: (x, y, width, height) in SVG coordinates. + fn bounds(&self) -> (f32, f32, f32, f32); +} + +/// An `` element with a `` child (video scroll area). +#[derive(Debug, Clone)] +pub struct VideoScroll { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + pub desc: String, +} + +impl Renderable for VideoScroll { + fn bounds(&self) -> (f32, f32, f32, f32) { + (self.x, self.y, self.width, self.height) + } +} + +/// A `` or `` element with a `` child (audio area). +#[derive(Debug, Clone)] +pub struct AudioArea { + pub cx: f32, + pub cy: f32, + pub radius: f32, + pub desc: String, +} + +impl Renderable for AudioArea { + fn bounds(&self) -> (f32, f32, f32, f32) { + ( + self.cx - self.radius, + self.cy - self.radius, + self.radius * 2.0, + self.radius * 2.0, + ) + } +} + +/// A `` element with id starting with "anchor". +#[derive(Debug, Clone)] +pub struct Anchor { + pub id: String, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +impl Renderable for Anchor { + fn bounds(&self) -> (f32, f32, f32, f32) { + (self.x, self.y, self.width, self.height) + } +} + +/// A single line of text (tspan). +#[derive(Debug, Clone)] +pub struct TextLine { + pub x: f32, + pub y: f32, + pub content: String, +} + +/// A `` element with multiple lines. +#[derive(Debug, Clone)] +pub struct TextElement { + pub lines: Vec, + pub font_size: f32, // Parsed from style attribute +} + +/// A hyperlink pointing to an anchor by id. +#[derive(Debug, Clone)] +pub struct AnchorLink { + pub target_id: String, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +impl Renderable for TextElement { + fn bounds(&self) -> (f32, f32, f32, f32) { + if self.lines.is_empty() { + return (0.0, 0.0, 0.0, 0.0); + } + + let min_x = self.lines.iter().map(|l| l.x).fold(f32::INFINITY, f32::min); + let min_y = self + .lines + .iter() + .map(|l| l.y - self.font_size) + .fold(f32::INFINITY, f32::min); + + let max_x = self + .lines + .iter() + .map(|l| l.x + l.content.len() as f32 * self.font_size * 0.6) + .fold(f32::NEG_INFINITY, f32::max); + let max_y = self + .lines + .iter() + .map(|l| l.y) + .fold(f32::NEG_INFINITY, f32::max); + + (min_x, min_y, max_x - min_x, max_y - min_y) + } +} + +/// Container for all parsed SVG content. +#[derive(Debug, Clone, Default)] +pub struct SvgContent { + pub video_scrolls: Vec, + pub audio_areas: Vec, + pub anchors: Vec, + pub anchor_links: Vec, + pub texts: Vec, + pub viewbox: Option<(f32, f32, f32, f32)>, // (min_x, min_y, width, height) + pub background_color: Option<[u8; 3]>, + pub start_rect: Option<(f32, f32, f32, f32)>, +} diff --git a/src/text_cache.rs b/src/text_cache.rs index fbb7df1..bc13d40 100644 --- a/src/text_cache.rs +++ b/src/text_cache.rs @@ -184,6 +184,7 @@ impl TextCache { } /// Render text to a texture at high resolution. + #[expect(clippy::too_many_lines)] fn render_text( &self, ctx: &egui::Context,