feat: rudimentary text rendering
This commit is contained in:
parent
3ce0fce53a
commit
3749bad021
8 changed files with 466 additions and 170 deletions
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1,2 +1,3 @@
|
||||||
**/*.ico filter=lfs diff=lfs merge=lfs -text
|
**/*.ico filter=lfs diff=lfs merge=lfs -text
|
||||||
**/*.png filter=lfs diff=lfs merge=lfs -text
|
**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
**/*.ttf filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1229,6 +1229,7 @@ dependencies = [
|
||||||
name = "las"
|
name = "las"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ab_glyph",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
"egui_extras",
|
"egui_extras",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ eframe = { version = "0.32", default-features = false, features = [
|
||||||
"x11", # To support older Linux distributions (restores one of the default features)
|
"x11", # To support older Linux distributions (restores one of the default features)
|
||||||
] }
|
] }
|
||||||
log = "0.4.27"
|
log = "0.4.27"
|
||||||
|
ab_glyph = "0.2"
|
||||||
|
|
||||||
# You only need serde if you want app persistence:
|
# You only need serde if you want app persistence:
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
|
|
|
||||||
3
assets/NotoSans-Regular.ttf
Normal file
3
assets/NotoSans-Regular.ttf
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b85c38ecea8a7cfb39c24e395a4007474fa5a4fc864f6ee33309eb4948d232d5
|
||||||
|
size 569208
|
||||||
265
src/app.rs
265
src/app.rs
|
|
@ -1,17 +1,18 @@
|
||||||
use crate::svg::{Renderable as _, SvgContent};
|
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.
|
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
|
||||||
#[derive(serde::Deserialize, serde::Serialize)]
|
#[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 {
|
pub struct TemplateApp {
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
svg_content: Option<SvgContent>,
|
svg_content: Option<SvgContent>,
|
||||||
|
|
||||||
// Pan offset in SVG coordinates
|
/// Pan offset in SVG coordinates.
|
||||||
pan_x: f32,
|
pan_x: f32,
|
||||||
pan_y: f32,
|
pan_y: f32,
|
||||||
|
|
||||||
// Zoom factor (1.0 = 100%)
|
/// Zoom factor (1.0 = 100%).
|
||||||
zoom: f32,
|
zoom: f32,
|
||||||
|
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
|
|
@ -20,17 +21,21 @@ pub struct TemplateApp {
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
show_debug: bool,
|
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)]
|
#[serde(skip)]
|
||||||
fps_ema: f32,
|
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)]
|
#[serde(skip)]
|
||||||
last_pointer_pos: Option<egui::Pos2>,
|
last_pointer_pos: Option<egui::Pos2>,
|
||||||
|
|
||||||
/// Whether we're currently in a drag operation
|
/// Whether we're currently in a drag operation.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
is_dragging: bool,
|
is_dragging: bool,
|
||||||
|
|
||||||
|
/// Text rendering cache for smooth scaling.
|
||||||
|
#[serde(skip)]
|
||||||
|
text_cache: Option<TextCache>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TemplateApp {
|
impl Default for TemplateApp {
|
||||||
|
|
@ -42,9 +47,10 @@ impl Default for TemplateApp {
|
||||||
zoom: 1.0,
|
zoom: 1.0,
|
||||||
show_menu_bar: false,
|
show_menu_bar: false,
|
||||||
show_debug: false,
|
show_debug: false,
|
||||||
fps_ema: 60.0, // Start with reasonable default
|
fps_ema: 60.0,
|
||||||
last_pointer_pos: None,
|
last_pointer_pos: None,
|
||||||
is_dragging: false,
|
is_dragging: false,
|
||||||
|
text_cache: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -52,18 +58,12 @@ impl Default for TemplateApp {
|
||||||
impl TemplateApp {
|
impl TemplateApp {
|
||||||
/// Called once before the first frame.
|
/// Called once before the first frame.
|
||||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
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);
|
egui_extras::install_image_loaders(&cc.egui_ctx);
|
||||||
|
|
||||||
// Load previous app state (if any).
|
let mut app: Self = cc
|
||||||
let mut app: Self = if let Some(storage) = cc.storage {
|
.storage
|
||||||
eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default()
|
.and_then(|s| eframe::get_value(s, eframe::APP_KEY))
|
||||||
} else {
|
.unwrap_or_default();
|
||||||
Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load SVG content
|
// Load SVG content
|
||||||
let svg_path = "../line-and-surface/content/intro.svg";
|
let svg_path = "../line-and-surface/content/intro.svg";
|
||||||
|
|
@ -77,7 +77,6 @@ impl TemplateApp {
|
||||||
content.texts.len()
|
content.texts.len()
|
||||||
);
|
);
|
||||||
if let Some((min_x, min_y, _, _)) = content.viewbox {
|
if let Some((min_x, min_y, _, _)) = content.viewbox {
|
||||||
// Initialize pan to center on content
|
|
||||||
app.pan_x = -min_x;
|
app.pan_x = -min_x;
|
||||||
app.pan_y = -min_y;
|
app.pan_y = -min_y;
|
||||||
}
|
}
|
||||||
|
|
@ -91,24 +90,24 @@ impl TemplateApp {
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert from SVG coordinates to screen coordinates.
|
/// Handle zoom towards a specific point.
|
||||||
fn svg_to_screen(&self, x: f32, y: f32, canvas_rect: &egui::Rect) -> egui::Pos2 {
|
fn zoom_towards(&mut self, new_zoom: f32, pos: egui::Pos2, canvas_rect: &egui::Rect) {
|
||||||
let screen_x = (x + self.pan_x) * self.zoom + canvas_rect.left();
|
let svg_x = (pos.x - canvas_rect.left()) / self.zoom - self.pan_x;
|
||||||
let screen_y = (y + self.pan_y) * self.zoom + canvas_rect.top();
|
let svg_y = (pos.y - canvas_rect.top()) / self.zoom - self.pan_y;
|
||||||
egui::pos2(screen_x, screen_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 {
|
impl eframe::App for TemplateApp {
|
||||||
/// Called by the framework to save state before shutdown.
|
|
||||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||||
eframe::set_value(storage, eframe::APP_KEY, self);
|
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) {
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
// Batch input reading for efficiency
|
// Batch input reading
|
||||||
// Use smooth_scroll_delta for smoother panning experience
|
|
||||||
let (
|
let (
|
||||||
escape_pressed,
|
escape_pressed,
|
||||||
f3_pressed,
|
f3_pressed,
|
||||||
|
|
@ -123,38 +122,36 @@ impl eframe::App for TemplateApp {
|
||||||
(
|
(
|
||||||
i.key_pressed(egui::Key::Escape),
|
i.key_pressed(egui::Key::Escape),
|
||||||
i.key_pressed(egui::Key::F3),
|
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.zoom_delta(),
|
||||||
i.pointer.hover_pos(),
|
i.pointer.hover_pos(),
|
||||||
i.stable_dt, // Actual frame time for FPS calculation
|
i.stable_dt,
|
||||||
i.pointer.primary_down(),
|
i.pointer.primary_down(),
|
||||||
i.pointer.primary_pressed(),
|
i.pointer.primary_pressed(),
|
||||||
i.pointer.primary_released(),
|
i.pointer.primary_released(),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update FPS using exponential moving average for stable display
|
// Update FPS EMA
|
||||||
// Alpha of 0.1 means ~10 frames to reach 63% of a new stable value
|
|
||||||
// This provides good smoothing while still being responsive
|
|
||||||
if frame_time > 0.0 {
|
if frame_time > 0.0 {
|
||||||
let current_fps = 1.0 / frame_time;
|
|
||||||
const ALPHA: f32 = 0.1;
|
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 {
|
if escape_pressed {
|
||||||
self.show_menu_bar = !self.show_menu_bar;
|
self.show_menu_bar = !self.show_menu_bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
if f3_pressed {
|
if f3_pressed {
|
||||||
self.show_debug = !self.show_debug;
|
self.show_debug = !self.show_debug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Menu bar
|
||||||
if self.show_menu_bar {
|
if self.show_menu_bar {
|
||||||
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
||||||
egui::MenuBar::new().ui(ui, |ui| {
|
egui::MenuBar::new().ui(ui, |ui| {
|
||||||
let is_web = cfg!(target_arch = "wasm32");
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
if !is_web {
|
{
|
||||||
ui.menu_button("File", |ui| {
|
ui.menu_button("File", |ui| {
|
||||||
if ui.button("Quit").clicked() {
|
if ui.button("Quit").clicked() {
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
|
|
@ -164,12 +161,9 @@ impl eframe::App for TemplateApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
egui::widgets::global_theme_preference_buttons(ui);
|
egui::widgets::global_theme_preference_buttons(ui);
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
// Display current zoom level
|
|
||||||
ui.label(format!("Zoom: {:.0}%", self.zoom * 100.0));
|
ui.label(format!("Zoom: {:.0}%", self.zoom * 100.0));
|
||||||
|
|
||||||
if ui.button("Reset View").clicked() {
|
if ui.button("Reset View").clicked() {
|
||||||
self.pan_x = 0.0;
|
self.pan_x = 0.0;
|
||||||
self.pan_y = 0.0;
|
self.pan_y = 0.0;
|
||||||
|
|
@ -177,161 +171,163 @@ impl eframe::App for TemplateApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
let debug_label = if self.show_debug {
|
||||||
if ui
|
"Hide Debug (F3)"
|
||||||
.button(if self.show_debug {
|
} else {
|
||||||
"Hide Debug (F3)"
|
"Show Debug (F3)"
|
||||||
} else {
|
};
|
||||||
"Show Debug (F3)"
|
if ui.button(debug_label).clicked() {
|
||||||
})
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
self.show_debug = !self.show_debug;
|
self.show_debug = !self.show_debug;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track rendered element count for debug
|
|
||||||
let mut rendered_count = 0u32;
|
let mut rendered_count = 0u32;
|
||||||
|
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
// Allocate the full panel - use click_and_drag to capture hover and clicks
|
|
||||||
let (response, painter) =
|
let (response, painter) =
|
||||||
ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag());
|
ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag());
|
||||||
|
|
||||||
let canvas_rect = response.rect;
|
let canvas_rect = response.rect;
|
||||||
|
|
||||||
// Manual drag handling for smoother panning
|
// Drag handling
|
||||||
if primary_pressed && response.hovered() {
|
if primary_pressed && response.hovered() {
|
||||||
self.is_dragging = true;
|
self.is_dragging = true;
|
||||||
self.last_pointer_pos = pointer_pos;
|
self.last_pointer_pos = pointer_pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
if primary_released {
|
if primary_released {
|
||||||
self.is_dragging = false;
|
self.is_dragging = false;
|
||||||
self.last_pointer_pos = None;
|
self.last_pointer_pos = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate delta from pointer movement
|
|
||||||
if self.is_dragging && primary_down {
|
if self.is_dragging && primary_down {
|
||||||
if let (Some(current_pos), Some(last_pos)) = (pointer_pos, self.last_pointer_pos) {
|
if let (Some(current), Some(last)) = (pointer_pos, self.last_pointer_pos) {
|
||||||
let delta = current_pos - last_pos;
|
let delta = current - last;
|
||||||
if delta.x != 0.0 || delta.y != 0.0 {
|
self.pan_x += delta.x / self.zoom;
|
||||||
self.pan_x += delta.x / self.zoom;
|
self.pan_y += delta.y / self.zoom;
|
||||||
self.pan_y += delta.y / self.zoom;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.last_pointer_pos = pointer_pos;
|
self.last_pointer_pos = pointer_pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle scroll wheel (zoom) - only if hovered
|
// Zoom handling
|
||||||
if response.hovered() {
|
if response.hovered() {
|
||||||
if scroll_delta != 0.0 {
|
if scroll_delta != 0.0 {
|
||||||
let zoom_factor = 1.0 + scroll_delta * 0.001;
|
let factor = 1.0 + scroll_delta * 0.001;
|
||||||
let new_zoom = (self.zoom * zoom_factor).clamp(0.01, 100.0);
|
let new_zoom = (self.zoom * factor).clamp(0.01, 100.0);
|
||||||
|
|
||||||
// Zoom towards pointer position
|
|
||||||
if let Some(pos) = pointer_pos {
|
if let Some(pos) = pointer_pos {
|
||||||
let svg_x = (pos.x - canvas_rect.left()) / self.zoom - self.pan_x;
|
self.zoom_towards(new_zoom, pos, &canvas_rect);
|
||||||
let svg_y = (pos.y - canvas_rect.top()) / self.zoom - self.pan_y;
|
} else {
|
||||||
self.pan_x = (pos.x - canvas_rect.left()) / new_zoom - svg_x;
|
self.zoom = new_zoom;
|
||||||
self.pan_y = (pos.y - canvas_rect.top()) / new_zoom - svg_y;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.zoom = new_zoom;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also support trackpad pinch zoom
|
|
||||||
if zoom_delta != 1.0 {
|
if zoom_delta != 1.0 {
|
||||||
let new_zoom = (self.zoom * zoom_delta).clamp(0.01, 100.0);
|
let new_zoom = (self.zoom * zoom_delta).clamp(0.01, 100.0);
|
||||||
|
|
||||||
if let Some(pos) = pointer_pos {
|
if let Some(pos) = pointer_pos {
|
||||||
let svg_x = (pos.x - canvas_rect.left()) / self.zoom - self.pan_x;
|
self.zoom_towards(new_zoom, pos, &canvas_rect);
|
||||||
let svg_y = (pos.y - canvas_rect.top()) / self.zoom - self.pan_y;
|
} else {
|
||||||
self.pan_x = (pos.x - canvas_rect.left()) / new_zoom - svg_x;
|
self.zoom = new_zoom;
|
||||||
self.pan_y = (pos.y - canvas_rect.top()) / new_zoom - svg_y;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.zoom = new_zoom;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define colors for each element type
|
// Element colors
|
||||||
let video_scroll_color = egui::Color32::from_rgb(70, 130, 180); // Steel Blue
|
let video_scroll_color = egui::Color32::from_rgb(70, 130, 180);
|
||||||
let audio_area_color = egui::Color32::from_rgb(60, 179, 113); // Medium Sea Green
|
let audio_area_color = egui::Color32::from_rgb(60, 179, 113);
|
||||||
let anchor_color = egui::Color32::from_rgb(255, 215, 0); // Gold
|
let anchor_color = egui::Color32::from_rgb(255, 215, 0);
|
||||||
let text_color = egui::Color32::from_rgb(147, 112, 219); // Medium Purple
|
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 {
|
if let Some(ref content) = self.svg_content {
|
||||||
// Draw video scrolls
|
// Video scrolls
|
||||||
for vs in &content.video_scrolls {
|
for vs in &content.video_scrolls {
|
||||||
let (x, y, w, h) = vs.bounds();
|
let (x, y, w, h) = vs.bounds();
|
||||||
let min = self.svg_to_screen(x, y, &canvas_rect);
|
let rect = egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h));
|
||||||
let max = self.svg_to_screen(x + w, y + h, &canvas_rect);
|
if rect.intersects(canvas_rect) {
|
||||||
let rect = egui::Rect::from_min_max(min, max);
|
painter.rect_filled(rect, 0.0, video_scroll_color);
|
||||||
|
rendered_count += 1;
|
||||||
// Frustum culling: skip if completely outside canvas
|
|
||||||
if !rect.intersects(canvas_rect) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
painter.rect_filled(rect, 0.0, video_scroll_color);
|
|
||||||
rendered_count += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw audio areas
|
// Audio areas
|
||||||
for aa in &content.audio_areas {
|
for aa in &content.audio_areas {
|
||||||
let (x, y, w, h) = aa.bounds();
|
let (x, y, w, h) = aa.bounds();
|
||||||
let min = self.svg_to_screen(x, y, &canvas_rect);
|
let rect = egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h));
|
||||||
let max = self.svg_to_screen(x + w, y + h, &canvas_rect);
|
if rect.intersects(canvas_rect) {
|
||||||
let rect = egui::Rect::from_min_max(min, max);
|
painter.rect_filled(rect, (w.min(h) * zoom) / 2.0, audio_area_color);
|
||||||
|
rendered_count += 1;
|
||||||
if !rect.intersects(canvas_rect) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
painter.rect_filled(rect, (w.min(h) * self.zoom) / 2.0, audio_area_color);
|
|
||||||
rendered_count += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw anchors
|
// Anchors
|
||||||
for anchor in &content.anchors {
|
for anchor in &content.anchors {
|
||||||
let (x, y, w, h) = anchor.bounds();
|
let (x, y, w, h) = anchor.bounds();
|
||||||
let min = self.svg_to_screen(x, y, &canvas_rect);
|
let rect = egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h));
|
||||||
let max = self.svg_to_screen(x + w, y + h, &canvas_rect);
|
if rect.intersects(canvas_rect) {
|
||||||
let rect = egui::Rect::from_min_max(min, max);
|
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) {
|
if !rect.intersects(canvas_rect) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
painter.rect_filled(rect, 0.0, anchor_color);
|
let display_font_size = text_elem.font_size * zoom;
|
||||||
rendered_count += 1;
|
if display_font_size >= 0.5 {
|
||||||
}
|
for line in &text_elem.lines {
|
||||||
|
if line.content.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Draw text elements
|
let cached = text_cache.get_or_create(ctx, &line.content, text_elem.font_size);
|
||||||
for text in &content.texts {
|
let scale_factor = zoom / RENDER_SCALE;
|
||||||
let (x, y, w, h) = text.bounds();
|
let display_width = cached.width as f32 * scale_factor;
|
||||||
let min = self.svg_to_screen(x, y, &canvas_rect);
|
let display_height = cached.height as f32 * scale_factor;
|
||||||
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) {
|
let y_adjusted = line.y - text_elem.font_size * 0.8;
|
||||||
continue;
|
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;
|
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 {
|
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(
|
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,
|
0.0,
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(100)),
|
egui::Stroke::new(1.0, egui::Color32::from_gray(100)),
|
||||||
egui::StrokeKind::Inside,
|
egui::StrokeKind::Inside,
|
||||||
|
|
@ -340,7 +336,7 @@ impl eframe::App for TemplateApp {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug overlay window
|
// Debug window
|
||||||
if self.show_debug {
|
if self.show_debug {
|
||||||
egui::Window::new("Debug")
|
egui::Window::new("Debug")
|
||||||
.anchor(egui::Align2::RIGHT_TOP, [-10.0, 10.0])
|
.anchor(egui::Align2::RIGHT_TOP, [-10.0, 10.0])
|
||||||
|
|
@ -348,14 +344,10 @@ impl eframe::App for TemplateApp {
|
||||||
.collapsible(false)
|
.collapsible(false)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.label(format!("FPS: {:.1}", self.fps_ema));
|
ui.label(format!("FPS: {:.1}", self.fps_ema));
|
||||||
|
|
||||||
ui.label(format!("Pixels per point: {:.2}", ctx.pixels_per_point()));
|
ui.label(format!("Pixels per point: {:.2}", ctx.pixels_per_point()));
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
ui.label(format!("Pan: ({:.1}, {:.1})", self.pan_x, self.pan_y));
|
ui.label(format!("Pan: ({:.1}, {:.1})", self.pan_x, self.pan_y));
|
||||||
ui.label(format!("Zoom: {:.2}x", self.zoom));
|
ui.label(format!("Zoom: {:.2}x", self.zoom));
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
if let Some(ref content) = self.svg_content {
|
if let Some(ref content) = self.svg_content {
|
||||||
|
|
@ -365,10 +357,7 @@ impl eframe::App for TemplateApp {
|
||||||
+ content.texts.len();
|
+ content.texts.len();
|
||||||
ui.label(format!("Total elements: {total}"));
|
ui.label(format!("Total elements: {total}"));
|
||||||
ui.label(format!("Rendered: {rendered_count}"));
|
ui.label(format!("Rendered: {rendered_count}"));
|
||||||
ui.label(format!(
|
ui.label(format!("Culled: {}", total.saturating_sub(rendered_count as usize)));
|
||||||
"Culled: {}",
|
|
||||||
total.saturating_sub(rendered_count as usize)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,6 @@
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
pub mod svg;
|
pub mod svg;
|
||||||
|
mod text_cache;
|
||||||
|
|
||||||
pub use app::TemplateApp;
|
pub use app::TemplateApp;
|
||||||
|
|
|
||||||
126
src/svg.rs
126
src/svg.rs
|
|
@ -62,19 +62,42 @@ impl Renderable for Anchor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `<text>` element.
|
/// A single line of text (tspan).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TextElement {
|
pub struct TextLine {
|
||||||
pub x: f32,
|
pub x: f32,
|
||||||
pub y: f32,
|
pub y: f32,
|
||||||
pub width: f32,
|
|
||||||
pub height: f32,
|
|
||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `<text>` element with multiple lines.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TextElement {
|
||||||
|
pub lines: Vec<TextLine>,
|
||||||
|
pub font_size: f32, // Parsed from style attribute
|
||||||
|
}
|
||||||
|
|
||||||
impl Renderable for TextElement {
|
impl Renderable for TextElement {
|
||||||
fn bounds(&self) -> (f32, f32, f32, f32) {
|
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.
|
/// Parse SVG content from a string.
|
||||||
|
#[expect(clippy::too_many_lines)]
|
||||||
pub fn parse(content: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn parse(content: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let mut reader = Reader::from_str(content);
|
let mut reader = Reader::from_str(content);
|
||||||
reader.config_mut().trim_text(true);
|
reader.config_mut().trim_text(true);
|
||||||
|
|
@ -128,9 +152,12 @@ impl SvgContent {
|
||||||
// Stack to track nested elements and their pending state
|
// Stack to track nested elements and their pending state
|
||||||
let mut pending: Option<PendingElement> = None;
|
let mut pending: Option<PendingElement> = None;
|
||||||
let mut in_text = false;
|
let mut in_text = false;
|
||||||
let mut text_x = 0.0f32;
|
let mut text_font_size = 10.5833f32; // Default from SVG
|
||||||
let mut text_y = 0.0f32;
|
let mut text_lines: Vec<TextLine> = Vec::new();
|
||||||
let mut text_content = String::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 {
|
loop {
|
||||||
match reader.read_event_into(&mut buf) {
|
match reader.read_event_into(&mut buf) {
|
||||||
|
|
@ -249,16 +276,37 @@ impl SvgContent {
|
||||||
}
|
}
|
||||||
"text" => {
|
"text" => {
|
||||||
in_text = true;
|
in_text = true;
|
||||||
text_content.clear();
|
text_lines.clear();
|
||||||
text_x = 0.0;
|
text_font_size = 10.5833; // Reset to default
|
||||||
text_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);
|
||||||
|
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::<f32>() {
|
||||||
|
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() {
|
for attr in e.attributes().flatten() {
|
||||||
let key = String::from_utf8_lossy(attr.key.as_ref());
|
let key = String::from_utf8_lossy(attr.key.as_ref());
|
||||||
let value = String::from_utf8_lossy(&attr.value);
|
let value = String::from_utf8_lossy(&attr.value);
|
||||||
match key.as_ref() {
|
match key.as_ref() {
|
||||||
"x" => text_x = value.parse().unwrap_or(0.0),
|
"x" => tspan_x = value.parse().unwrap_or(0.0),
|
||||||
"y" => text_y = 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)) => {
|
Ok(Event::Text(ref e)) => {
|
||||||
let text = e.unescape().unwrap_or_default();
|
let text = e.unescape().unwrap_or_default();
|
||||||
|
|
||||||
if in_text {
|
if in_tspan {
|
||||||
text_content.push_str(&text);
|
tspan_content.push_str(&text);
|
||||||
} else if let Some(ref p) = pending {
|
} else if let Some(ref p) = pending {
|
||||||
// This is desc content
|
// This is desc content
|
||||||
let desc = text.trim().to_owned();
|
let desc = text.trim().to_owned();
|
||||||
|
|
@ -353,23 +401,30 @@ impl SvgContent {
|
||||||
"image" | "circle" | "ellipse" => {
|
"image" | "circle" | "ellipse" => {
|
||||||
pending = None;
|
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" => {
|
"text" => {
|
||||||
if in_text {
|
if in_text {
|
||||||
let content = text_content.trim().to_owned();
|
if !text_lines.is_empty() {
|
||||||
// Estimate width/height based on content length
|
svg_content.texts.push(TextElement {
|
||||||
// Using rough character width of 8px and height of 16px
|
lines: text_lines.clone(),
|
||||||
let estimated_width = content.len() as f32 * 8.0;
|
font_size: text_font_size,
|
||||||
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,
|
|
||||||
});
|
|
||||||
in_text = false;
|
in_text = false;
|
||||||
text_content.clear();
|
text_lines.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -461,15 +516,22 @@ mod tests {
|
||||||
fn test_parse_text() {
|
fn test_parse_text() {
|
||||||
let svg = r#"
|
let svg = r#"
|
||||||
<svg viewBox="0 0 1000 1000">
|
<svg viewBox="0 0 1000 1000">
|
||||||
<text x="100" y="200">Hello World</text>
|
<text x="100" y="200" style="font-size:12px">
|
||||||
|
<tspan x="100" y="200">Hello World</tspan>
|
||||||
|
<tspan x="100" y="220">Second Line</tspan>
|
||||||
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
||||||
assert_eq!(content.texts.len(), 1);
|
assert_eq!(content.texts.len(), 1);
|
||||||
let text = &content.texts[0];
|
let text = &content.texts[0];
|
||||||
assert_eq!(text.x, 100.0);
|
assert_eq!(text.lines.len(), 2);
|
||||||
assert_eq!(text.content, "Hello World");
|
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]
|
#[test]
|
||||||
|
|
|
||||||
238
src/text_cache.rs
Normal file
238
src/text_cache.rs
Normal file
|
|
@ -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<Color32> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue