line-and-surface/src/app.rs

500 lines
19 KiB
Rust

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<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,
#[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<egui::Pos2>,
/// 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<egui::Pos2>,
/// Whether we're currently in a drag operation.
#[serde(skip)]
is_dragging: bool,
/// Text rendering cache for smooth scaling.
#[serde(skip)]
text_cache: Option<TextCache>,
}
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<String> = 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()));
}
});
}
}
}