feat: add background color support, internal element rendering toggle

This commit is contained in:
Tomáš Mládek 2026-01-24 23:36:06 +01:00
parent abdfff92ca
commit 714d00fbb9
3 changed files with 148 additions and 49 deletions

View file

@ -1,5 +1,5 @@
use crate::svg::{Renderable as _, SvgContent};
use crate::text_cache::{RENDER_SCALE, TextCache};
use crate::text_cache::{TextCache, RENDER_SCALE};
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
#[derive(serde::Deserialize, serde::Serialize)]
@ -21,6 +21,9 @@ pub struct TemplateApp {
#[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,
@ -47,6 +50,7 @@ impl Default for TemplateApp {
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,
@ -196,10 +200,19 @@ impl eframe::App for TemplateApp {
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);
// Drag handling
if primary_pressed && response.hovered() {
@ -241,9 +254,12 @@ impl eframe::App for TemplateApp {
}
// 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 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)
@ -262,40 +278,93 @@ impl eframe::App for TemplateApp {
(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 {
// 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 self.render_internal_areas {
let mut hovered_descs: Vec<String> = Vec::new();
let pointer_svg = pointer_pos.map(screen_to_svg);
// Audio areas
for aa in &content.audio_areas {
let (x, y, w, h) = aa.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, (w.min(h) * zoom) / 2.0, audio_area_color);
rendered_count += 1;
}
}
// 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;
}
// 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!("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);
}
});
}
}
@ -342,19 +411,6 @@ impl eframe::App for TemplateApp {
rendered_count += 1;
}
// Viewbox border
if let Some((vb_x, vb_y, vb_w, vb_h)) = content.viewbox {
painter.rect_stroke(
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,
);
}
}
});
@ -385,6 +441,9 @@ impl eframe::App for TemplateApp {
));
}
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()));

View file

@ -1,13 +1,15 @@
#![warn(clippy::all, rust_2018_idioms)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
use log::LevelFilter;
// When compiling natively:
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result {
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("debug"),
)
.init();
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.filter_level(LevelFilter::Info)
.filter_module("las", LevelFilter::Debug)
.init();
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()

View file

@ -113,6 +113,7 @@ pub struct SvgContent {
pub anchors: Vec<Anchor>,
pub texts: Vec<TextElement>,
pub viewbox: Option<(f32, f32, f32, f32)>, // (min_x, min_y, width, height)
pub background_color: Option<[u8; 3]>,
}
/// State for tracking current element during parsing.
@ -186,6 +187,13 @@ impl SvgContent {
Some((parts[0], parts[1], parts[2], parts[3]));
}
}
if attr.key.as_ref() == b"style" {
let value = String::from_utf8_lossy(&attr.value);
if svg_content.background_color.is_none() {
svg_content.background_color =
parse_background_color(&value);
}
}
}
}
"image" => {
@ -465,6 +473,36 @@ impl SvgContent {
}
}
fn parse_background_color(style: &str) -> Option<[u8; 3]> {
for entry in style.split(';') {
let entry = entry.trim();
if let Some(value) = entry.strip_prefix("background-color:") {
let value = value.trim();
return parse_hex_color(value);
}
}
None
}
fn parse_hex_color(value: &str) -> Option<[u8; 3]> {
let hex = value.strip_prefix('#')?.trim();
match hex.len() {
3 => {
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
Some([r, g, b])
}
6 => {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some([r, g, b])
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;