From 3749bad02110c830d45f6411f3e531ce794e41ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Ml=C3=A1dek?= Date: Sat, 24 Jan 2026 22:29:21 +0100 Subject: [PATCH] feat: rudimentary text rendering --- .gitattributes | 1 + Cargo.lock | 1 + Cargo.toml | 1 + assets/NotoSans-Regular.ttf | 3 + src/app.rs | 265 +++++++++++++++++------------------- src/lib.rs | 1 + src/svg.rs | 126 ++++++++++++----- src/text_cache.rs | 238 ++++++++++++++++++++++++++++++++ 8 files changed, 466 insertions(+), 170 deletions(-) create mode 100644 assets/NotoSans-Regular.ttf create mode 100644 src/text_cache.rs diff --git a/.gitattributes b/.gitattributes index 4053317..606dfe8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ **/*.ico filter=lfs diff=lfs merge=lfs -text **/*.png filter=lfs diff=lfs merge=lfs -text +**/*.ttf filter=lfs diff=lfs merge=lfs -text diff --git a/Cargo.lock b/Cargo.lock index 7611524..1e52f63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1229,6 +1229,7 @@ dependencies = [ name = "las" version = "0.1.0" dependencies = [ + "ab_glyph", "eframe", "egui", "egui_extras", diff --git a/Cargo.toml b/Cargo.toml index c0472be..44d313b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ eframe = { version = "0.32", default-features = false, features = [ "x11", # To support older Linux distributions (restores one of the default features) ] } log = "0.4.27" +ab_glyph = "0.2" # You only need serde if you want app persistence: serde = { version = "1.0.219", features = ["derive"] } diff --git a/assets/NotoSans-Regular.ttf b/assets/NotoSans-Regular.ttf new file mode 100644 index 0000000..4e0d730 --- /dev/null +++ b/assets/NotoSans-Regular.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b85c38ecea8a7cfb39c24e395a4007474fa5a4fc864f6ee33309eb4948d232d5 +size 569208 diff --git a/src/app.rs b/src/app.rs index 3692551..7a56ae7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,17 +1,18 @@ 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)] // if we add new fields, give them default values when deserializing old state +#[serde(default)] pub struct TemplateApp { #[serde(skip)] svg_content: Option, - // Pan offset in SVG coordinates + /// Pan offset in SVG coordinates. pan_x: f32, pan_y: f32, - // Zoom factor (1.0 = 100%) + /// Zoom factor (1.0 = 100%). zoom: f32, #[serde(skip)] @@ -20,17 +21,21 @@ pub struct TemplateApp { #[serde(skip)] show_debug: bool, - /// Exponential moving average of frame time for stable FPS display + /// 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) + /// 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 + /// 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 { @@ -42,9 +47,10 @@ impl Default for TemplateApp { zoom: 1.0, show_menu_bar: false, show_debug: false, - fps_ema: 60.0, // Start with reasonable default + fps_ema: 60.0, last_pointer_pos: None, is_dragging: false, + text_cache: None, } } } @@ -52,18 +58,12 @@ impl Default for TemplateApp { 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() - }; + 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"; @@ -77,7 +77,6 @@ impl TemplateApp { 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; } @@ -91,24 +90,24 @@ impl TemplateApp { 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) + /// 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 { - /// 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. + #[expect(clippy::too_many_lines)] fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Batch input reading for efficiency - // Use smooth_scroll_delta for smoother panning experience + // Batch input reading let ( escape_pressed, f3_pressed, @@ -123,38 +122,36 @@ impl eframe::App for TemplateApp { ( i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::F3), - i.smooth_scroll_delta.y, // Use smoothed scroll instead of raw + i.smooth_scroll_delta.y, i.zoom_delta(), i.pointer.hover_pos(), - i.stable_dt, // Actual frame time for FPS calculation + i.stable_dt, 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 + // Update FPS EMA 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; + 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| { - let is_web = cfg!(target_arch = "wasm32"); - if !is_web { + #[cfg(not(target_arch = "wasm32"))] + { ui.menu_button("File", |ui| { if ui.button("Quit").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -164,12 +161,9 @@ impl eframe::App for TemplateApp { } 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; @@ -177,161 +171,163 @@ impl eframe::App for TemplateApp { } ui.separator(); - - if ui - .button(if self.show_debug { - "Hide Debug (F3)" - } else { - "Show Debug (F3)" - }) - .clicked() - { + 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; } }); }); } - // 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 + // 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; } - - // 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; - } + 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; } - // Handle scroll wheel (zoom) - only if hovered + // Zoom handling 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 + 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 { - 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_towards(new_zoom, pos, &canvas_rect); + } else { + self.zoom = new_zoom; } - - 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_towards(new_zoom, pos, &canvas_rect); + } else { + self.zoom = new_zoom; } - - 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 + // Element colors + let video_scroll_color = egui::Color32::from_rgb(70, 130, 180); + let audio_area_color = egui::Color32::from_rgb(60, 179, 113); + let anchor_color = egui::Color32::from_rgb(255, 215, 0); + 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 text_cache = self.text_cache.as_mut().expect("just initialized"); - // Render SVG content with frustum culling if let Some(ref content) = self.svg_content { - // Draw video scrolls + // 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; + 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; } - - painter.rect_filled(rect, 0.0, video_scroll_color); - rendered_count += 1; } - // Draw audio areas + // 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; + 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, (w.min(h) * zoom) / 2.0, audio_area_color); + rendered_count += 1; } - - painter.rect_filled(rect, (w.min(h) * self.zoom) / 2.0, audio_area_color); - rendered_count += 1; } - // Draw anchors + // 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); + 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; + } + } + + // 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; } - painter.rect_filled(rect, 0.0, anchor_color); - rendered_count += 1; - } + 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; + } - // 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); + 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; - if !rect.intersects(canvas_rect) { - continue; + 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 + ); + } } - painter.rect_filled(rect, 0.0, text_color); rendered_count += 1; } - // Draw a subtle border around the viewbox if available + // Viewbox border 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), + egui::Rect::from_min_max( + svg_to_screen(vb_x, vb_y), + svg_to_screen(vb_x + vb_w, vb_y + vb_h), + ), 0.0, egui::Stroke::new(1.0, egui::Color32::from_gray(100)), egui::StrokeKind::Inside, @@ -340,7 +336,7 @@ impl eframe::App for TemplateApp { } }); - // Debug overlay window + // Debug window if self.show_debug { egui::Window::new("Debug") .anchor(egui::Align2::RIGHT_TOP, [-10.0, 10.0]) @@ -348,14 +344,10 @@ impl eframe::App for TemplateApp { .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 { @@ -365,10 +357,7 @@ impl eframe::App for TemplateApp { + 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.label(format!("Culled: {}", total.saturating_sub(rendered_count as usize))); } }); } diff --git a/src/lib.rs b/src/lib.rs index f519358..0a778c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,5 +2,6 @@ mod app; pub mod svg; +mod text_cache; pub use app::TemplateApp; diff --git a/src/svg.rs b/src/svg.rs index c1a8830..d15740c 100644 --- a/src/svg.rs +++ b/src/svg.rs @@ -62,19 +62,42 @@ impl Renderable for Anchor { } } -/// A `` element. +/// A single line of text (tspan). #[derive(Debug, Clone)] -pub struct TextElement { +pub struct TextLine { pub x: f32, pub y: f32, - pub width: f32, - pub height: 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 +} + impl Renderable for TextElement { fn bounds(&self) -> (f32, f32, f32, f32) { - (self.x, self.y, self.width, self.height) + 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) } } @@ -118,6 +141,7 @@ impl SvgContent { } /// Parse SVG content from a string. + #[expect(clippy::too_many_lines)] pub fn parse(content: &str) -> Result> { let mut reader = Reader::from_str(content); reader.config_mut().trim_text(true); @@ -128,9 +152,12 @@ impl SvgContent { // Stack to track nested elements and their pending state let mut pending: Option = None; let mut in_text = false; - let mut text_x = 0.0f32; - let mut text_y = 0.0f32; - let mut text_content = String::new(); + let mut text_font_size = 10.5833f32; // Default from SVG + let mut text_lines: Vec = Vec::new(); + let mut in_tspan = false; + let mut tspan_x = 0.0f32; + let mut tspan_y = 0.0f32; + let mut tspan_content = String::new(); loop { match reader.read_event_into(&mut buf) { @@ -249,16 +276,37 @@ impl SvgContent { } "text" => { in_text = true; - text_content.clear(); - text_x = 0.0; - text_y = 0.0; + text_lines.clear(); + text_font_size = 10.5833; // Reset to default + + 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() == "style" { + // Parse font-size from style attribute + if let Some(size_start) = value.find("font-size:") { + let size_str = &value[size_start + 10..]; + if let Some(size_end) = size_str.find(|c: char| !c.is_numeric() && c != '.') { + if let Ok(size) = size_str[..size_end].parse::() { + text_font_size = size; + } + } + } + } + } + } + "tspan" if in_text => { + in_tspan = true; + tspan_content.clear(); + tspan_x = 0.0; + tspan_y = 0.0; for attr in e.attributes().flatten() { let key = String::from_utf8_lossy(attr.key.as_ref()); let value = String::from_utf8_lossy(&attr.value); match key.as_ref() { - "x" => text_x = value.parse().unwrap_or(0.0), - "y" => text_y = value.parse().unwrap_or(0.0), + "x" => tspan_x = value.parse().unwrap_or(0.0), + "y" => tspan_y = value.parse().unwrap_or(0.0), _ => {} } } @@ -305,8 +353,8 @@ impl SvgContent { Ok(Event::Text(ref e)) => { let text = e.unescape().unwrap_or_default(); - if in_text { - text_content.push_str(&text); + if in_tspan { + tspan_content.push_str(&text); } else if let Some(ref p) = pending { // This is desc content let desc = text.trim().to_owned(); @@ -353,23 +401,30 @@ impl SvgContent { "image" | "circle" | "ellipse" => { pending = None; } + "tspan" => { + if in_tspan { + let content = tspan_content.trim().to_owned(); + if !content.is_empty() && tspan_y != 0.0 { + text_lines.push(TextLine { + x: tspan_x, + y: tspan_y, + content, + }); + } + in_tspan = false; + tspan_content.clear(); + } + } "text" => { if in_text { - let content = text_content.trim().to_owned(); - // Estimate width/height based on content length - // Using rough character width of 8px and height of 16px - let estimated_width = content.len() as f32 * 8.0; - let estimated_height = 16.0; - - svg_content.texts.push(TextElement { - x: text_x, - y: text_y - estimated_height, // SVG text y is baseline - width: estimated_width, - height: estimated_height, - content, - }); + if !text_lines.is_empty() { + svg_content.texts.push(TextElement { + lines: text_lines.clone(), + font_size: text_font_size, + }); + } in_text = false; - text_content.clear(); + text_lines.clear(); } } _ => {} @@ -461,15 +516,22 @@ mod tests { fn test_parse_text() { let svg = r#" - Hello World + + 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.x, 100.0); - assert_eq!(text.content, "Hello World"); + 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] diff --git a/src/text_cache.rs b/src/text_cache.rs new file mode 100644 index 0000000..8d873f5 --- /dev/null +++ b/src/text_cache.rs @@ -0,0 +1,238 @@ +//! Text rendering cache that pre-renders text to textures for smooth scaling. +//! +//! Text is rendered at 4x the nominal font size to allow crisp display when +//! zooming in, while scaling down smoothly when zooming out. + +use ab_glyph::{Font as _, FontRef, PxScale, ScaleFont as _}; +use egui::{Color32, ColorImage, TextureHandle, TextureOptions}; +use std::collections::HashMap; + +/// Scale factor for pre-rendering text (4x nominal size for crisp scaling). +pub const RENDER_SCALE: f32 = 4.0; + +/// Texture filtering mode for text rendering. +/// - `LINEAR`: Smooth scaling, slight blur when scaled (good for most cases) +/// - `NEAREST`: Sharp/pixelated, no interpolation (good for pixel art style) +const TEXTURE_FILTER: TextureOptions = TextureOptions::LINEAR; + +/// Maximum texture dimension to prevent memory issues. +const MAX_TEXTURE_DIM: u32 = 4096; + +/// Maximum pixel count (16M pixels = 64MB for RGBA). +const MAX_PIXEL_COUNT: usize = 16 * 1024 * 1024; + +/// A cached rendered text texture. +pub struct CachedText { + /// The texture handle for the rendered text. + pub texture: TextureHandle, + /// Width of the texture in pixels (at render scale). + pub width: u32, + /// Height of the texture in pixels (at render scale). + pub height: u32, +} + +/// Cache for rendered text textures. +pub struct TextCache { + /// The font used for rendering. + font: FontRef<'static>, + /// Cached text textures, keyed by (content, `size_key`). + cache: HashMap<(String, u32), CachedText>, +} + +impl TextCache { + /// Create a new text cache with the embedded Noto Sans font. + /// + /// Text is rendered in white - apply color as a tint when drawing. + pub fn new() -> Self { + let font_data: &'static [u8] = include_bytes!("../assets/NotoSans-Regular.ttf"); + let font = FontRef::try_from_slice(font_data).expect("embedded font should be valid"); + + Self { + font, + cache: HashMap::new(), + } + } + + /// Get or create a cached texture for the given text. + /// + /// The texture is rendered at `RENDER_SCALE` times the nominal font size. + pub fn get_or_create( + &mut self, + ctx: &egui::Context, + text: &str, + nominal_font_size: f32, + ) -> &CachedText { + // Round font size to reduce cache entries (0.5px granularity) + let size_key = (nominal_font_size * 2.0).round() as u32; + let key = (text.to_owned(), size_key); + + if !self.cache.contains_key(&key) { + let cached = self.render_text(ctx, text, nominal_font_size, size_key); + self.cache.insert(key.clone(), cached); + } + + self.cache.get(&key).expect("just inserted") + } + + /// Render text to a texture at high resolution. + fn render_text( + &self, + ctx: &egui::Context, + text: &str, + nominal_font_size: f32, + size_key: u32, + ) -> CachedText { + let render_size = nominal_font_size * RENDER_SCALE; + let scale = PxScale::from(render_size); + let scaled_font = self.font.as_scaled(scale); + + // Calculate text dimensions + let height = scaled_font.height(); + let ascent = scaled_font.ascent(); + let width = Self::measure_text_width(text, &scaled_font); + + // Early return for empty/invalid text + if width <= 0.0 || height <= 0.0 || text.trim().is_empty() { + return Self::create_empty_texture(ctx, size_key); + } + + // Calculate image dimensions with padding + let padding = 2.0; + let img_width = ((width + padding * 2.0).ceil() as u32).min(MAX_TEXTURE_DIM); + let img_height = ((height + padding * 2.0).ceil() as u32).min(MAX_TEXTURE_DIM); + + if img_width == 0 || img_height == 0 { + return Self::create_empty_texture(ctx, size_key); + } + + let pixel_count = img_width as usize * img_height as usize; + if pixel_count > MAX_PIXEL_COUNT { + log::warn!( + "Text texture too large: {img_width}x{img_height} for '{}', skipping", + &text[..text.len().min(20)] + ); + return Self::create_empty_texture(ctx, size_key); + } + + // Render glyphs to pixel buffer + let pixels = Self::render_glyphs(text, &scaled_font, scale, ascent, padding, img_width, img_height); + + // Create egui texture + let image = ColorImage { + size: [img_width as usize, img_height as usize], + pixels, + source_size: egui::Vec2::new(img_width as f32, img_height as f32), + }; + + let texture = ctx.load_texture( + format!("text_{size_key}_{}", text.len()), + image, + TEXTURE_FILTER, + ); + + CachedText { + texture, + width: img_width, + height: img_height, + } + } + + /// Measure the width of text in pixels. + fn measure_text_width( + text: &str, + scaled_font: &ab_glyph::PxScaleFont<&FontRef<'static>>, + ) -> f32 { + let mut width = 0.0f32; + let mut last_glyph_id = None; + + for c in text.chars() { + let glyph_id = scaled_font.glyph_id(c); + + if let Some(last_id) = last_glyph_id { + width += scaled_font.kern(last_id, glyph_id); + } + + width += scaled_font.h_advance(glyph_id); + last_glyph_id = Some(glyph_id); + } + + width + } + + /// Render glyphs to a pixel buffer. + fn render_glyphs( + text: &str, + scaled_font: &ab_glyph::PxScaleFont<&FontRef<'static>>, + scale: PxScale, + ascent: f32, + padding: f32, + img_width: u32, + img_height: u32, + ) -> Vec { + let mut pixels = vec![Color32::TRANSPARENT; (img_width * img_height) as usize]; + let mut cursor_x = padding; + let mut last_glyph_id = None; + + for c in text.chars() { + let glyph_id = scaled_font.glyph_id(c); + + if let Some(last_id) = last_glyph_id { + cursor_x += scaled_font.kern(last_id, glyph_id); + } + + if let Some(outlined) = scaled_font.outline_glyph(ab_glyph::Glyph { + id: glyph_id, + scale, + position: ab_glyph::point(cursor_x, ascent + padding), + }) { + let bounds = outlined.px_bounds(); + + outlined.draw(|x, y, coverage| { + let px = bounds.min.x as i32 + x as i32; + let py = bounds.min.y as i32 + y as i32; + + if px >= 0 && py >= 0 { + let px = px as u32; + let py = py as u32; + + if px < img_width && py < img_height { + let idx = (py * img_width + px) as usize; + let alpha = (coverage * 255.0) as u8; + let existing = pixels[idx]; + let new_alpha = alpha.saturating_add(existing.a()); + + // Render in white - color is applied as tint when drawing + pixels[idx] = Color32::from_rgba_unmultiplied(255, 255, 255, new_alpha); + } + } + }); + } + + cursor_x += scaled_font.h_advance(glyph_id); + last_glyph_id = Some(glyph_id); + } + + pixels + } + + /// Create a minimal 1x1 transparent texture for empty/invalid text. + fn create_empty_texture(ctx: &egui::Context, size_key: u32) -> CachedText { + let image = ColorImage { + size: [1, 1], + pixels: vec![Color32::TRANSPARENT], + source_size: egui::Vec2::new(1.0, 1.0), + }; + + let texture = ctx.load_texture( + format!("text_empty_{size_key}"), + image, + TEXTURE_FILTER, + ); + + CachedText { + texture, + width: 1, + height: 1, + } + } +}