diff --git a/src/app.rs b/src/app.rs index c8bac5a..1563e67 100644 --- a/src/app.rs +++ b/src/app.rs @@ -40,6 +40,10 @@ pub struct TemplateApp { #[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, @@ -69,12 +73,27 @@ impl Default for TemplateApp { text_cache: None, did_fit_start: false, last_cursor_pos: None, - reset_view_requested: false + 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..."); @@ -120,6 +139,83 @@ impl TemplateApp { 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; @@ -130,6 +226,64 @@ impl TemplateApp { } } +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); @@ -150,6 +304,7 @@ impl eframe::App for TemplateApp { primary_down, primary_pressed, primary_released, + space_pressed, ) = ctx.input(|i| { ( i.key_pressed(egui::Key::Escape), @@ -163,9 +318,14 @@ impl eframe::App for TemplateApp { 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); } @@ -237,43 +397,50 @@ impl eframe::App for TemplateApp { 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 { - 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; + 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; } } } - false - }; + self.did_fit_start = true; + ctx.request_repaint(); + } + + if space_pressed { + self.reset_view_requested = true; + } 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; + 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 @@ -397,9 +564,57 @@ impl eframe::App for TemplateApp { ) }; - let text_cache = self.text_cache.as_mut().expect("just initialized"); + 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); @@ -481,6 +696,7 @@ impl eframe::App for TemplateApp { } // 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 = @@ -528,6 +744,10 @@ impl eframe::App for TemplateApp { rendered_count += 1; } } + + if let Some(rect) = pending_navigation { + self.request_view_to_rect(rect, &canvas_rect, TemplateApp::NAV_DURATION); + } }); // Debug window diff --git a/src/svg.rs b/src/svg.rs index 862671d..cf583b1 100644 --- a/src/svg.rs +++ b/src/svg.rs @@ -1,7 +1,7 @@ //! SVG parsing module for extracting special elements from SVG files. -use quick_xml::Reader; use quick_xml::events::Event; +use quick_xml::Reader; use std::fs; /// Trait for elements that can be rendered with a bounding box. @@ -77,6 +77,16 @@ pub struct TextElement { 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() { @@ -111,6 +121,7 @@ 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]>, @@ -139,6 +150,56 @@ enum PendingElement { }, } +#[derive(Debug, Clone)] +struct LinkAccumulator { + target_id: String, + min_x: f32, + max_x: f32, + min_y: f32, + max_y: f32, +} + +impl LinkAccumulator { + fn new(target_id: String) -> Self { + Self { + target_id, + min_x: f32::INFINITY, + max_x: f32::NEG_INFINITY, + min_y: f32::INFINITY, + max_y: f32::NEG_INFINITY, + } + } + + fn update_bounds(&mut self, x: f32, y: f32, width: f32, height: f32) { + if width <= 0.0 || height <= 0.0 { + return; + } + self.min_x = self.min_x.min(x); + self.min_y = self.min_y.min(y); + self.max_x = self.max_x.max(x + width); + self.max_y = self.max_y.max(y + height); + } + + fn finalize(&self) -> Option<(f32, f32, f32, f32)> { + if self.min_x.is_finite() + && self.min_y.is_finite() + && self.max_x.is_finite() + && self.max_y.is_finite() + && self.max_x > self.min_x + && self.max_y > self.min_y + { + Some(( + self.min_x, + self.min_y, + self.max_x - self.min_x, + self.max_y - self.min_y, + )) + } else { + None + } + } +} + impl SvgContent { /// Parse an SVG file and extract special elements. pub fn from_file(path: &str) -> Result> { @@ -168,12 +229,28 @@ impl SvgContent { let mut tspan_y = 0.0f32; let mut tspan_content = String::new(); + // Track hyperlink bounds targeting anchors + let mut current_link: Option = None; + loop { match reader.read_event_into(&mut buf) { Ok(Event::Start(ref e)) => { let name_bytes = e.name(); let name = String::from_utf8_lossy(name_bytes.as_ref()); match name.as_ref() { + "a" => { + for attr in e.attributes().flatten() { + let key = String::from_utf8_lossy(attr.key.as_ref()); + let value = String::from_utf8_lossy(&attr.value); + if key.as_ref().ends_with("href") { + let cleaned = value.trim_start_matches('#'); + if cleaned.starts_with("anchor") { + current_link = + Some(LinkAccumulator::new(cleaned.to_string())); + } + } + } + } "svg" => { // Extract viewBox for attr in e.attributes().flatten() { @@ -220,6 +297,10 @@ impl SvgContent { width, height, }); + + if let Some(ref mut link) = current_link { + link.update_bounds(x, y, width, height); + } } "circle" => { let mut cx = 0.0f32; @@ -240,6 +321,10 @@ impl SvgContent { } let _: bool = has_id_with_desc_potential; // May check desc child later pending = Some(PendingElement::Circle { cx, cy, r }); + + if let Some(ref mut link) = current_link { + link.update_bounds(cx - r, cy - r, r * 2.0, r * 2.0); + } } "ellipse" => { let mut cx = 0.0f32; @@ -259,6 +344,12 @@ impl SvgContent { } } pending = Some(PendingElement::Ellipse { cx, cy, rx, ry }); + + if let Some(ref mut link) = current_link { + let width = rx * 2.0; + let height = ry * 2.0; + link.update_bounds(cx - rx, cy - ry, width, height); + } } "rect" => { let mut id = String::new(); @@ -291,6 +382,10 @@ impl SvgContent { } else if id == "start" { svg_content.start_rect = Some((x, y, width, height)); } + + if let Some(ref mut link) = current_link { + link.update_bounds(x, y, width, height); + } } "text" => { in_text = true; @@ -370,6 +465,10 @@ impl SvgContent { } else if id == "start" { svg_content.start_rect = Some((x, y, width, height)); } + + if let Some(ref mut link) = current_link { + link.update_bounds(x, y, width, height); + } } } Ok(Event::Text(ref e)) => { @@ -423,6 +522,19 @@ impl SvgContent { "image" | "circle" | "ellipse" => { pending = None; } + "a" => { + if let Some(link) = current_link.take() { + if let Some((x, y, width, height)) = link.finalize() { + svg_content.anchor_links.push(AnchorLink { + target_id: link.target_id, + x, + y, + width, + height, + }); + } + } + } "tspan" => { if in_tspan { let content = tspan_content.trim().to_owned(); @@ -444,6 +556,15 @@ impl SvgContent { lines: text_lines.clone(), font_size: text_font_size, }); + + if let Some(ref mut link) = current_link { + let text_elem = TextElement { + lines: text_lines.clone(), + font_size: text_font_size, + }; + let (x, y, w, h) = text_elem.bounds(); + link.update_bounds(x, y, w, h); + } } in_text = false; text_lines.clear();