diff --git a/crates/gosub_engine/src/engine/context.rs b/crates/gosub_engine/src/engine/context.rs index a5e9e98bd..474972f6d 100644 --- a/crates/gosub_engine/src/engine/context.rs +++ b/crates/gosub_engine/src/engine/context.rs @@ -43,13 +43,133 @@ use url::Url; #[cfg(feature = "pipeline")] use crate::html::HtmlEngineConfig; #[cfg(feature = "pipeline")] +use gosub_css3::stylesheet::CssSelectorPart; +#[cfg(feature = "pipeline")] use gosub_interface::document::Document as _; #[cfg(feature = "pipeline")] use gosub_render_pipeline::layering::layer::LayerList; #[cfg(feature = "pipeline")] +use gosub_render_pipeline::layouter::LayoutElementId; +#[cfg(feature = "pipeline")] use gosub_render_pipeline::render::backend::{CachedTile, ExternalHandle}; #[cfg(feature = "pipeline")] use gosub_shared::node::NodeId; + +/// Fingerprints of nodes that are the subject of a `:hover` CSS rule. +/// Built once per document load; used to skip style recalcs when hover moves +/// between elements that have no `:hover` rules. +#[cfg(feature = "pipeline")] +#[derive(Default)] +struct HoverFingerprints { + /// True when a bare `:hover` or `*:hover` rule exists — every node is sensitive. + has_universal: bool, + types: std::collections::HashSet, + classes: std::collections::HashSet, + ids: std::collections::HashSet, +} + +#[cfg(feature = "pipeline")] +impl HoverFingerprints { + fn empty() -> Self { + Self::default() + } + + /// Scan all stylesheets in `doc` and collect the hover-subject fingerprints. + fn build(doc: &EngineDocument) -> Self { + let mut fp = Self::empty(); + let sheet_count = doc.stylesheets().len(); + + for sheet in doc.stylesheets() { + for rule in &sheet.rules { + for selector in &rule.selectors { + for part_list in &selector.parts { + // Split the part list into compounds (groups between Combinators). + // :hover belongs to the compound it appears in; that compound's + // Type/Class/Id parts are the hover-subject fingerprint. + let mut compound: Vec<&CssSelectorPart> = Vec::new(); + for part in part_list { + if matches!(part, CssSelectorPart::Combinator(_)) { + compound.clear(); + continue; + } + compound.push(part); + if !matches!(part, CssSelectorPart::PseudoClass(n) if n == "hover") { + continue; + } + // Found :hover — classify this compound. + let mut specific = false; + for p in &compound { + match p { + CssSelectorPart::Type(t) => { + fp.types.insert(t.clone()); + specific = true; + } + CssSelectorPart::Class(c) => { + fp.classes.insert(c.clone()); + specific = true; + } + CssSelectorPart::Id(id) => { + fp.ids.insert(id.clone()); + specific = true; + } + _ => {} + } + } + if !specific { + // Bare :hover or *:hover — everything is sensitive. + fp.has_universal = true; + log::info!( + "[hover] fingerprints: universal :hover (from {} stylesheet(s))", + sheet_count + ); + return fp; + } + } + } + } + } + } + + log::info!( + "[hover] fingerprints built from {} stylesheet(s): types={:?} classes={:?} ids={:?} universal={}", + sheet_count, + fp.types.iter().collect::>(), + fp.classes.iter().collect::>(), + fp.ids.iter().collect::>(), + fp.has_universal, + ); + fp + } + + #[allow(dead_code)] + fn is_empty(&self) -> bool { + !self.has_universal && self.types.is_empty() && self.classes.is_empty() && self.ids.is_empty() + } + + fn matches(&self, doc: &EngineDocument, node_id: NodeId) -> bool { + if self.has_universal { + return true; + } + if let Some(tag) = doc.tag_name(node_id) { + if self.types.contains(tag) { + return true; + } + } + for cls in &self.classes { + if doc.has_class(node_id, cls) { + return true; + } + } + if !self.ids.is_empty() { + if let Some(id_attr) = doc.attribute(node_id, "id") { + if self.ids.contains(id_attr) { + return true; + } + } + } + false + } +} // #[derive(Debug, thiserror::Error)] // pub enum LoadError { // #[error("navigation cancelled")] @@ -133,9 +253,22 @@ pub struct BrowsingContext { /// Cached rasterized tiles for the full page. Valid until render_dirty is set. #[cfg(feature = "pipeline")] pipeline_cache: Option, + /// Set when only hover state changed — triggers a paint-only repaint (stages 4–6), + /// skipping the expensive render-tree rebuild (stage 1) and layout (stage 2). + #[cfg(feature = "pipeline")] + hover_dirty: bool, /// The DOM node currently under the pointer (for :hover matching). #[cfg(feature = "pipeline")] hover_leaf: Option, + /// The layout element currently under the pointer, used for bounding-box pre-check. + #[cfg(feature = "pipeline")] + hover_layout_element: Option, + /// Cached :hover fingerprints for the current document; rebuilt on document change. + #[cfg(feature = "pipeline")] + hover_fingerprints: Option, + /// True when the last hover chain contained a fingerprint-sensitive node. + #[cfg(feature = "pipeline")] + hover_chain_sensitive: bool, /// The href of the link currently under the pointer, if any. #[cfg(feature = "pipeline")] pub hover_link_url: Option, @@ -167,8 +300,16 @@ impl BrowsingContext { #[cfg(feature = "pipeline")] pipeline_cache: None, #[cfg(feature = "pipeline")] + hover_dirty: false, + #[cfg(feature = "pipeline")] hover_leaf: None, #[cfg(feature = "pipeline")] + hover_layout_element: None, + #[cfg(feature = "pipeline")] + hover_fingerprints: None, + #[cfg(feature = "pipeline")] + hover_chain_sensitive: false, + #[cfg(feature = "pipeline")] hover_link_url: None, #[cfg(feature = "backend_vello")] vello_resources: None, @@ -196,7 +337,11 @@ impl BrowsingContext { #[cfg(feature = "pipeline")] { self.pipeline_cache = None; + self.hover_dirty = false; self.hover_leaf = None; + self.hover_layout_element = None; + self.hover_fingerprints = None; + self.hover_chain_sensitive = false; } } @@ -267,7 +412,7 @@ impl BrowsingContext { /// on the host thread and never consume the render list. #[cfg(feature = "pipeline")] pub fn rebuild_pipeline_cache_if_needed(&mut self) { - if !self.render_dirty && !self.scroll_dirty { + if !self.render_dirty && !self.hover_dirty && !self.scroll_dirty { return; } if self.render_dirty { @@ -286,9 +431,41 @@ impl BrowsingContext { )); } self.render_dirty = false; + self.hover_dirty = false; self.dom_dirty = false; self.style_dirty = false; self.layout_dirty = false; + } else if self.hover_dirty { + log::warn!("[hover] hover_dirty → dispatching paint-only repaint (stages 4–6)"); + // Paint-only repaint: reuse the cached layout tree, skip stages 1–2. + if let Some(old_cache) = self.pipeline_cache.take() { + let PipelineCache { + layer_list, + page_height, + tile_pixel_cache: prev_tile_cache, + .. + } = old_cache; + self.pipeline_cache = Some(pipeline_hover_repaint( + layer_list, + page_height, + &self.viewport, + #[cfg(feature = "backend_vello")] + self.vello_resources.clone(), + prev_tile_cache, + )); + } else { + // No cached layout yet — fall back to a full rebuild. + if let Some(doc) = &self.document { + self.pipeline_cache = Some(pipeline_build_cache( + doc.clone(), + &self.viewport, + #[cfg(feature = "backend_vello")] + self.vello_resources.clone(), + std::collections::HashMap::new(), + )); + } + } + self.hover_dirty = false; } self.scroll_dirty = false; self.scene_epoch = self.scene_epoch.wrapping_add(1); @@ -319,6 +496,7 @@ impl BrowsingContext { )); } self.render_dirty = false; + self.hover_dirty = false; self.dom_dirty = false; self.style_dirty = false; self.layout_dirty = false; @@ -364,7 +542,7 @@ impl BrowsingContext { /// Calling this consumes the scroll-dirty flag and advances the scene epoch. #[cfg(feature = "pipeline")] pub fn take_scroll_handle(&mut self, dpr: u32) -> Option { - if !self.scroll_dirty || self.render_dirty { + if !self.scroll_dirty || self.render_dirty || self.hover_dirty { return None; } let cache = self.pipeline_cache.as_ref()?; @@ -408,48 +586,105 @@ impl BrowsingContext { } /// Hit-test at viewport coordinates `(vp_x, vp_y)` and update hover state. - /// Returns `(dirty, link_url)`: dirty=true when the hovered element changed, - /// link_url=Some(href) when the cursor is over a link (None otherwise). + /// + /// Returns `(visual_dirty, url_changed, link_url)`: + /// - `visual_dirty`: a node with a `:hover` CSS rule entered or left the hover chain → needs repaint. + /// - `url_changed`: the link URL under the cursor changed → caller should emit a `HoverUrl` event. + /// - `link_url`: the href of the nearest `` ancestor, if any. #[cfg(feature = "pipeline")] - pub fn update_hover(&mut self, vp_x: f64, vp_y: f64) -> (bool, Option) { + pub fn update_hover(&mut self, vp_x: f64, vp_y: f64) -> (bool, bool, Option) { + let _t_total = gosub_shared::timing_guard!("hover.total"); + let page_x = vp_x + self.scroll_x; let page_y = vp_y + self.scroll_y; - let new_leaf = self.pipeline_cache.as_ref().and_then(|cache| { - let lei = cache.layer_list.find_element_at(page_x, page_y)?; - let el = cache.layer_list.layout_tree.get_node_by_id(lei)?; - Some(el.dom_node_id) + let (new_leaf, new_lei) = self.pipeline_cache.as_ref().map_or((None, None), |cache| { + let _t = gosub_shared::timing_guard!("hover.hit_test"); + let Some(lei) = cache.layer_list.find_element_at(page_x, page_y) else { + return (None, None); + }; + let dom_node_id = cache + .layer_list + .layout_tree + .get_node_by_id(lei) + .map(|el| el.dom_node_id); + (dom_node_id, Some(lei)) }); - // Walk the ancestor chain to find the nearest . - let link_url = new_leaf.and_then(|leaf| { - let doc = self.document.as_ref()?; - let mut id = leaf; - loop { - if doc.tag_name(id) == Some("a") { - if let Some(href) = doc.attribute(id, "href") { - return Some(href.to_string()); + // Common case: same element — skip the ancestor walk entirely. + if new_leaf == self.hover_leaf { + return (false, false, self.hover_link_url.clone()); + } + + self.hover_leaf = new_leaf; + self.hover_layout_element = new_lei; + + // Build hover fingerprints lazily on first use after a document load. + if self.hover_fingerprints.is_none() { + self.hover_fingerprints = Some( + self.document + .as_ref() + .map(|doc| HoverFingerprints::build(doc)) + .unwrap_or_else(HoverFingerprints::empty), + ); + } + + log::debug!("[hover] leaf → {:?} lei={:?}", new_leaf, new_lei); + + // Walk the ancestor chain once for both link detection and fingerprint matching. + // Terminate early once both are found. + let (link_url, new_sensitive) = { + let fps = self.hover_fingerprints.as_ref().unwrap(); + let mut link: Option = None; + let mut sensitive = false; + + if let (Some(leaf), Some(doc)) = (new_leaf, self.document.as_ref()) { + let _t = gosub_shared::timing_guard!("hover.ancestor_walk"); + let mut id = leaf; + loop { + if !sensitive && fps.matches(doc, id) { + sensitive = true; + } + if link.is_none() && doc.tag_name(id) == Some("a") { + if let Some(href) = doc.attribute(id, "href") { + link = Some(href.to_string()); + } + } + if sensitive && link.is_some() { + break; + } + match doc.parent(id) { + Some(parent) => id = parent, + None => break, } } - id = doc.parent(id)?; } - }); + (link, sensitive) + }; + let url_changed = link_url != self.hover_link_url; self.hover_link_url = link_url.clone(); - if new_leaf == self.hover_leaf { - return (false, link_url); - } + // Only trigger a style recalc + repaint when a hover-sensitive node entered or left + // the hover chain. If neither the old nor new chain touches a :hover rule, skip it. + let visual_dirty = self.hover_chain_sensitive || new_sensitive; + self.hover_chain_sensitive = new_sensitive; - self.hover_leaf = new_leaf; + log::debug!( + "[hover] visual_dirty={visual_dirty} url_changed={url_changed} new_sensitive={new_sensitive} url={link_url:?}" + ); - if let Some(doc) = &self.document { - doc.set_hovered_nodes(new_leaf); + if visual_dirty { + if let Some(doc) = &self.document { + let _t = gosub_shared::timing_guard!("hover.set_hovered"); + doc.set_hovered_nodes(new_leaf); + } + // Hover-only changes are paint-only (color, background, box-shadow). + // Use the cheap hover-dirty path which skips render-tree + layout. + self.hover_dirty = true; } - self.render_dirty = true; - self.style_dirty = true; - (true, link_url) + (visual_dirty, url_changed, link_url) } /// Returns the render list @@ -1041,6 +1276,283 @@ fn pipeline_build_cache( } } +/// Hover-only repaint: skip stages 1–2 (render-tree + layout), reuse the cached +/// `LayerList`, then run stages 4–6 (tile, paint, rasterize) with a fresh style cache. +/// This makes `:hover` visual changes (background, color, box-shadow, …) cheap — +/// only the tiles that changed content get re-rasterized. +#[cfg(feature = "pipeline")] +fn pipeline_hover_repaint( + layer_list: Arc, + page_height: f64, + viewport: &gosub_render_pipeline::render::Viewport, + #[cfg(feature = "backend_vello")] _vello_resources: Option< + std::sync::Arc, + >, + prev_tile_cache: std::collections::HashMap>)>, +) -> PipelineCache { + use gosub_render_pipeline::common::browser_state::{BrowserState, WireframeState}; + use gosub_render_pipeline::common::geo::{Dimension as PipelineDimension, Rect as PipelineRect}; + use gosub_render_pipeline::painter::Painter; + use gosub_render_pipeline::tiler::{TileId, TileList, TileState}; + use gosub_shared::{timing_start, timing_stop}; + use std::time::Instant; + + log::info!("[pipeline] hover repaint (skipping stages 1–2)"); + let t_total = Instant::now(); + + // Invalidate the per-node style cache so the painter re-evaluates :hover rules. + layer_list.layout_tree.render_tree.doc.clear_style_cache(); + + // Stage 4: tiling — reuse existing LayerList, no layout work. + let t = Instant::now(); + let ts4 = timing_start!("pipeline.hover.tiling"); + let mut tile_list = TileList::from_arc(Arc::clone(&layer_list), PipelineDimension::new(256.0, 256.0)); + tile_list.generate(); + let total_tiles = tile_list.arena.len(); + timing_stop!(ts4); + log::info!( + "[pipeline] hover stage 4 tiling: {:>6.1}ms ({} tiles)", + t.elapsed().as_secs_f64() * 1000.0, + total_tiles + ); + + // Stage 5: paint — reads live styles from doc (now with cleared cache). + let t = Instant::now(); + let ts5 = timing_start!("pipeline.hover.painting"); + let full_page_rect = PipelineRect::new(0.0, 0.0, viewport.width as f64, page_height.max(viewport.height as f64)); + let layer_ids = tile_list.layer_list.layer_ids.read().clone(); + let paint_state = BrowserState { + visible_layer_list: vec![true; layer_ids.len()], + wireframed: WireframeState::None, + debug_hover: false, + current_hovered_element: None, + show_tilegrid: false, + viewport: full_page_rect, + tile_list: None, + dpi_scale_factor: 1.0, + }; + let painter = Painter::new(tile_list.layer_list.clone()); + let mut painted_tiles = 0usize; + for &layer_id in &layer_ids { + let tile_ids = tile_list.get_intersecting_tiles(layer_id, full_page_rect); + for tile_id in tile_ids { + if let Some(tile) = tile_list.get_tile_mut(tile_id) { + if tile.state == TileState::Dirty { + let mut cmds = 0usize; + for tiled_element in &mut tile.elements { + let c = painter.paint(tiled_element, &paint_state); + cmds += c.len(); + tiled_element.paint_commands = c; + } + painted_tiles += 1; + let _ = cmds; + } + } + } + } + timing_stop!(ts5); + log::info!( + "[pipeline] hover stage 5 painting: {:>6.1}ms ({} tiles painted)", + t.elapsed().as_secs_f64() * 1000.0, + painted_tiles + ); + + // Stage 6: rasterize (parallel for Cairo/Skia, using the tile-pixel cache). + #[cfg(any(feature = "backend_cairo", feature = "backend_skia"))] + macro_rules! rasterize_parallel { + ($rasterizer:expr, $label:literal) => {{ + use gosub_render_pipeline::common::media::MediaStore; + use gosub_render_pipeline::common::texture_store::TextureStore; + use gosub_render_pipeline::rasterizer::Rasterable; + use rayon::prelude::*; + + let t = Instant::now(); + let ts6 = timing_start!("pipeline.hover.rasterize"); + let media_store = MediaStore::new(); + let rasterizer = $rasterizer; + + let dirty_ids: Vec = layer_ids + .iter() + .flat_map(|&layer_id| tile_list.get_intersecting_tiles(layer_id, full_page_rect)) + .filter(|&id| tile_list.arena.get(&id).map_or(false, |t| t.state == TileState::Dirty)) + .collect(); + + type CacheEntry = (TileCacheKey, (u32, u32, Arc>)); + let results: Vec<(TileId, Option, Option)> = dirty_ids + .par_iter() + .map(|&tile_id| { + let Some(tile) = tile_list.arena.get(&tile_id) else { + return (tile_id, None, None); + }; + let key = tile_cache_key(tile); + if let Some(&(w, h, ref data)) = prev_tile_cache.get(&key) { + return ( + tile_id, + Some(BakedTile { + page_x: tile.rect.x, + page_y: tile.rect.y, + width: w, + height: h, + data: Arc::clone(data), + }), + None, + ); + } + let mut local_store = TextureStore::new(); + let baked = rasterizer + .rasterize(tile, &mut local_store, &media_store) + .and_then(|tid| local_store.get(tid)) + .map(|tex| BakedTile { + page_x: tile.rect.x, + page_y: tile.rect.y, + width: tex.width as u32, + height: tex.height as u32, + data: Arc::clone(&tex.data), + }); + let cache_entry = baked.as_ref().map(|b| (key, (b.width, b.height, Arc::clone(&b.data)))); + (tile_id, baked, cache_entry) + }) + .collect(); + + let mut rasterized = 0usize; + let mut cache_hits = 0usize; + let mut tiles: Vec = Vec::with_capacity(results.len()); + let mut new_tile_cache: std::collections::HashMap>)> = + std::collections::HashMap::with_capacity(results.len()); + for (tile_id, baked, cache_entry) in results { + if let Some(tile) = tile_list.arena.get_mut(&tile_id) { + match baked { + Some(b) => { + tile.state = TileState::Clean; + if let Some(e) = cache_entry { + new_tile_cache.insert(e.0, e.1); + rasterized += 1; + } else { + cache_hits += 1; + } + tiles.push(b); + } + None => { + tile.state = TileState::Empty; + } + } + } + } + timing_stop!(ts6); + log::info!( + concat!( + "[pipeline] hover stage 6 rasterize ", + $label, + " {:>6.1}ms ({} rasterized, {} hits)" + ), + t.elapsed().as_secs_f64() * 1000.0, + rasterized, + cache_hits + ); + (tiles, new_tile_cache) + }}; + } + + #[cfg(feature = "backend_cairo")] + let (baked_tiles, new_tile_cache) = { + use gosub_renderer_cairo::CairoRasterizer; + rasterize_parallel!(CairoRasterizer::new(), "(cairo):") + }; + + #[cfg(all(feature = "backend_skia", not(feature = "backend_cairo")))] + let (baked_tiles, new_tile_cache) = { + use gosub_renderer_skia::SkiaRasterizer; + rasterize_parallel!(SkiaRasterizer::new(1.0), "(skia): ") + }; + + #[cfg(all( + feature = "backend_vello", + not(feature = "backend_cairo"), + not(feature = "backend_skia") + ))] + let (baked_tiles, new_tile_cache) = { + // Vello: sequential rasterization, no tile-pixel cache yet. + use gosub_render_pipeline::common::media::MediaStore; + use gosub_render_pipeline::common::texture_store::TextureStore; + use gosub_render_pipeline::rasterizer::Rasterable; + use gosub_renderer_vello::VelloRasterizer; + let t = Instant::now(); + let ts6 = timing_start!("pipeline.hover.rasterize"); + let media_store = MediaStore::new(); + let mut texture_store = TextureStore::new(); + let mut tiles: Vec = Vec::new(); + if let Some(ref resources) = _vello_resources { + let rasterizer = VelloRasterizer::new(std::sync::Arc::clone(resources)); + for &layer_id in &layer_ids { + for tile_id in tile_list.get_intersecting_tiles(layer_id, full_page_rect) { + if let Some(tile) = tile_list.get_tile_mut(tile_id) { + if tile.state == TileState::Dirty { + if let Some(tid) = rasterizer.rasterize(tile, &mut texture_store, &media_store) { + tile.texture_id = Some(tid); + tile.state = TileState::Clean; + } else { + tile.state = TileState::Empty; + } + } + } + } + } + for tile in tile_list.arena.values() { + if let (Some(tid), true) = (tile.texture_id, tile.state == TileState::Clean) { + if let Some(tex) = texture_store.get(tid) { + tiles.push(BakedTile { + page_x: tile.rect.x, + page_y: tile.rect.y, + width: tex.width as u32, + height: tex.height as u32, + data: Arc::clone(&tex.data), + }); + } + } + } + } + timing_stop!(ts6); + log::info!( + "[pipeline] hover stage 6 rasterize (vello): {:>6.1}ms", + t.elapsed().as_secs_f64() * 1000.0 + ); + (tiles, std::collections::HashMap::new()) + }; + + #[cfg(not(any(feature = "backend_cairo", feature = "backend_skia", feature = "backend_vello")))] + let (baked_tiles, new_tile_cache): ( + Vec, + std::collections::HashMap>)>, + ) = (Vec::new(), std::collections::HashMap::new()); + + log::info!( + "[pipeline] hover repaint total: {:.1}ms ({} baked tiles)", + t_total.elapsed().as_secs_f64() * 1000.0, + baked_tiles.len() + ); + + let cached_tiles = Arc::new( + baked_tiles + .iter() + .map(|t| gosub_render_pipeline::render::backend::CachedTile { + page_x: t.page_x as f32, + page_y: t.page_y as f32, + width: t.width, + height: t.height, + data: Arc::clone(&t.data), + }) + .collect::>(), + ); + + PipelineCache { + tiles: baked_tiles, + page_height, + cached_tiles, + layer_list, + tile_pixel_cache: new_tile_cache, + } +} + /// Stage 7: composite visible tiles from the cache into `rl`. /// /// Selects tiles that intersect `(scroll_x, scroll_y, vp_w, vp_h)` and blits them at diff --git a/crates/gosub_engine/src/engine/engine.rs b/crates/gosub_engine/src/engine/engine.rs index 4008c8885..d27160910 100644 --- a/crates/gosub_engine/src/engine/engine.rs +++ b/crates/gosub_engine/src/engine/engine.rs @@ -148,6 +148,10 @@ impl GosubEngine { } self.io_handle = Some(io_handle); + // Start metrics HTTP server (GET http://127.0.0.1:9090/metrics) + #[cfg(feature = "metrics")] + crate::metrics::start(9090); + // Start main engine run loop let join_handle = self.run().map(|task| spawn_named("Engine runner", task)); diff --git a/crates/gosub_engine/src/engine/tab/worker.rs b/crates/gosub_engine/src/engine/tab/worker.rs index 880a31a15..7f9f741d2 100644 --- a/crates/gosub_engine/src/engine/tab/worker.rs +++ b/crates/gosub_engine/src/engine/tab/worker.rs @@ -116,6 +116,8 @@ pub struct TabWorker { desired_viewport: Viewport, /// Set when a resize arrives while rendering. Causes an immediate re-render after finishing the current rendering. dirty_after_inflight: bool, + /// Latest unprocessed mouse position; coalesced into one hit-test per frame tick. + pending_mouse_pos: Option<(f64, f64)>, /// Current scroll offset in CSS pixels (updated by MouseScroll). scroll_x: i32, scroll_y: i32, @@ -168,6 +170,7 @@ impl TabWorker { committed_viewport: Default::default(), desired_viewport: Default::default(), dirty_after_inflight: false, + pending_mouse_pos: None, scroll_x: 0, scroll_y: 0, runtime: TabRuntime::default(), @@ -384,17 +387,8 @@ impl TabWorker { ControlFlow::Continue } TabCommand::MouseMove { x: _x, y: _y } => { - #[cfg(feature = "pipeline")] - { - let (dirty, link_url) = self.context.update_hover(_x as f64, _y as f64); - if dirty { - self.runtime.dirty = true; - } - self.send_event(EngineEvent::HoverUrl { - tab_id: self.tab_id, - url: link_url, - }); - } + // Coalesce: store the latest position and let tick_draw do one hit-test per frame. + self.pending_mouse_pos = Some((_x as f64, _y as f64)); #[cfg(not(feature = "pipeline"))] { self.runtime.dirty = true; @@ -737,6 +731,21 @@ impl TabWorker { /// Do a draw tick. This will be called based on the FPS that is requested #[allow(unreachable_code)] // cfg-conditional tile-cache returns make the display-list path unreachable for some feature combos async fn tick_draw(&mut self) -> anyhow::Result<()> { + // Drain the coalesced mouse position — at most one hit-test per frame tick. + #[cfg(feature = "pipeline")] + if let Some((mx, my)) = self.pending_mouse_pos.take() { + let (visual_dirty, url_changed, link_url) = self.context.update_hover(mx, my); + if visual_dirty { + self.runtime.dirty = true; + } + if url_changed { + self.send_event(EngineEvent::HoverUrl { + tab_id: self.tab_id, + url: link_url, + }); + } + } + // Skip rendering when nothing has changed to avoid burning CPU at the tick rate. if !self.runtime.dirty { return Ok(()); diff --git a/crates/gosub_render_pipeline/examples/pipeline_bench.rs b/crates/gosub_render_pipeline/examples/pipeline_bench.rs index 72295b2fb..43de0d86d 100644 --- a/crates/gosub_render_pipeline/examples/pipeline_bench.rs +++ b/crates/gosub_render_pipeline/examples/pipeline_bench.rs @@ -20,7 +20,7 @@ use gosub_html5::document::document_impl::DocumentImpl; use gosub_html5::parser::Html5Parser; use gosub_interface::config::{HasCssSystem, HasDocument}; use gosub_interface::css3::CssSystem as _; -use gosub_interface::document::Document; +use gosub_interface::document::Document as _; use gosub_shared::byte_stream::{ByteStream, Encoding}; use gosub_render_pipeline::common::browser_state::{BrowserState, WireframeState}; @@ -119,7 +119,7 @@ struct StageTimes { painting: Duration, } -fn run_pipeline_once(html: &str) -> StageTimes { +fn run_pipeline_once(html: &str, layouter: &mut TaffyLayouter) -> StageTimes { let viewport_w = 1280.0_f64; let viewport_h = 800.0_f64; @@ -141,7 +141,6 @@ fn run_pipeline_once(html: &str) -> StageTimes { // Stage 2: layout let t = Instant::now(); - let mut layouter = TaffyLayouter::new(); let layout_tree = layouter.layout(render_tree, Some(Dimension::new(viewport_w, viewport_h)), 1.0); let layout_time = t.elapsed(); @@ -150,17 +149,11 @@ fn run_pipeline_once(html: &str) -> StageTimes { // Stage 3: layering let t = Instant::now(); let layer_list = LayerList::new(layout_tree); - let layer_list_arc = Arc::new(layer_list); let layering_time = t.elapsed(); // Stage 4: tiling let t = Instant::now(); - let mut tile_list = TileList::new( - // LayerList::new takes ownership, so we clone the Arc-wrapped version - // via the field; the TileList keeps its own Arc. - Arc::try_unwrap(Arc::clone(&layer_list_arc)).unwrap_or_else(|arc| (*arc).clone()), - Dimension::new(256.0, 256.0), - ); + let mut tile_list = TileList::new(layer_list, Dimension::new(256.0, 256.0)); tile_list.generate(); let tiling_time = t.elapsed(); @@ -229,8 +222,12 @@ fn main() { fixture.name ); - // Warm up — one throw-away run to avoid cold-cache skew. - let _ = run_pipeline_once(&fixture.html); + // Each fixture gets its own layouter so the media store (images, SVGs) is warm + // for all timed iterations — we measure layout computation, not network I/O. + let mut layouter = TaffyLayouter::new(); + + // Warm up — one throw-away run to prime the media store and OS caches. + let _ = run_pipeline_once(&fixture.html, &mut layouter); let mut rt_times = Vec::with_capacity(iterations); let mut layout_times = Vec::with_capacity(iterations); @@ -239,7 +236,7 @@ fn main() { let mut paint_times = Vec::with_capacity(iterations); for _ in 0..iterations { - let t = run_pipeline_once(&fixture.html); + let t = run_pipeline_once(&fixture.html, &mut layouter); rt_times.push(t.render_tree); layout_times.push(t.layout); layer_times.push(t.layering); diff --git a/crates/gosub_render_pipeline/src/common/document/pipeline_doc.rs b/crates/gosub_render_pipeline/src/common/document/pipeline_doc.rs index 3d444c9a2..9494b7d80 100644 --- a/crates/gosub_render_pipeline/src/common/document/pipeline_doc.rs +++ b/crates/gosub_render_pipeline/src/common/document/pipeline_doc.rs @@ -40,6 +40,11 @@ pub trait PipelineDocument: Send + Sync { /// Returns the own (explicitly-set) value for `prop` on node `id`, without recursing. fn get_own_style(&self, id: NodeId, prop: &StyleProperty) -> Option; + /// Discard the computed-style cache so the next `get_own_style` call re-evaluates + /// CSS selectors (including `:hover`) from scratch. No-op for backends that do + /// not cache styles. + fn clear_style_cache(&self) {} + /// Returns the computed value for `prop` on node `id`: /// 1. own value if set, /// 2. parent's computed value if the property is inherited, @@ -274,6 +279,10 @@ where self.cached_styles(id).get_own(prop).cloned() } + fn clear_style_cache(&self) { + self.style_cache.lock().clear(); + } + fn html_node_id(&self) -> Option { let root = self.doc.root(); self.find_child_by_tag(root, "html") diff --git a/crates/gosub_render_pipeline/src/common/media/media_store.rs b/crates/gosub_render_pipeline/src/common/media/media_store.rs index dc1fe870a..6c969bac5 100644 --- a/crates/gosub_render_pipeline/src/common/media/media_store.rs +++ b/crates/gosub_render_pipeline/src/common/media/media_store.rs @@ -84,6 +84,10 @@ impl MediaStore { /// Load the given media from src into the media store, and return the media ID. Will also store the media(id) in cache /// so the next call with the same src will return the same media ID without reloading. + /// + /// If the resource cannot be fetched or decoded, the default placeholder for the detected media + /// type is returned and the failure is cached so subsequent calls with the same URL skip the + /// network entirely. pub fn load_media(&self, src: &str) -> anyhow::Result { // Check if the media from src is already loaded into the cache. If so, return that let h = hash_from_string(src); @@ -96,13 +100,24 @@ impl MediaStore { let result = self.load_media_from_source(src); - if let Ok(media_id) = result { - let mut cache = self.cache.write(); - // Another thread may have inserted while we were loading — don't overwrite - cache.entry(h).or_insert(media_id); - } + let media_id = match result { + Ok(media_id) => media_id, + Err(e) => { + log::warn!("Failed to load media from '{}': {}", src, e); + // Cache the failure as the default image placeholder so the same URL is + // never re-fetched in this session (avoids repeated blocking I/O). + let fallback_id = DEFAULT_IMAGE_ID; + let mut cache = self.cache.write(); + cache.entry(h).or_insert(fallback_id); + return Ok(fallback_id); + } + }; + + let mut cache = self.cache.write(); + // Another thread may have inserted while we were loading — don't overwrite + cache.entry(h).or_insert(media_id); - result + Ok(media_id) } pub fn load_media_from_data(&self, media_type: MediaType, data: &[u8]) -> anyhow::Result { diff --git a/crates/gosub_render_pipeline/src/layouter/taffy.rs b/crates/gosub_render_pipeline/src/layouter/taffy.rs index bdbc851fb..0eaa57bb4 100644 --- a/crates/gosub_render_pipeline/src/layouter/taffy.rs +++ b/crates/gosub_render_pipeline/src/layouter/taffy.rs @@ -24,6 +24,10 @@ use taffy::NodeId as TaffyNodeId; const DEFAULT_FONT_SIZE: f64 = 16.0; const DEFAULT_FONT_FAMILY: &str = "sans-serif"; +// Cache key: (text, font_family, size_bits, line_height_bits, weight, max_width_bits). +// Floats are stored as their bit pattern so the tuple is Hash + Eq. +type MeasureKey = (String, String, u32, u32, i32, u32); + /// Layouter structure that uses taffy as layout engine pub struct TaffyLayouter { /// Generated taffy tree @@ -44,6 +48,10 @@ pub struct TaffyLayouter { /// call avoids keeping the guard alive across the full layout pass, which lets /// other threads (e.g. the rasterizer) access the font collection between calls. font_system: Arc>, + /// Memoized text measurements. Taffy calls the measure function 2-4× per node + /// (MinContent, MaxContent, actual width). Caching by (text, font, max_width) + /// eliminates the redundant Parley shaping calls. + measure_cache: HashMap>, } /// Context structures to pass to taffy measure functions so we can calculate the size of the text or images. @@ -114,6 +122,7 @@ impl TaffyLayouter { anon_container_map: HashMap::new(), media_store: MediaStore::new(), font_system, + measure_cache: HashMap::new(), } } @@ -156,20 +165,26 @@ impl CanLayout for TaffyLayouter { None => Size::MAX_CONTENT, }; - // Clone the Arc so the closure can lock the font system without holding a - // borrow of `self` while `self.tree` is also mutably borrowed. + // Clone the Arc and take the measure cache so the closure can capture them + // without holding a borrow of `self` while `self.tree` is mutably borrowed. let font_system = Arc::clone(&self.font_system); + let mut measure_cache: HashMap> = std::mem::take(&mut self.measure_cache); // Compute the layout with a measure function if let Err(e) = self .tree .compute_layout_with_measure(self.root_id, size, |v_kd, v_as, v_ni, v_nc, v_s| { + // If taffy already knows both dimensions, no measurement needed. + if let (Some(w), Some(h)) = (v_kd.width, v_kd.height) { + return Size { width: w, height: h }; + } + match v_nc { // Calculate text node Some(TaffyContext::Text(text_ctx)) => { let max_width = if text_ctx.no_wrap { // white-space: nowrap — measure at unlimited width so text never wraps - 1_000_000_000.0 + 1_000_000_000.0_f64 } else { match v_as.width { AvailableSpace::Definite(width) => width as f64, @@ -177,6 +192,19 @@ impl CanLayout for TaffyLayouter { AvailableSpace::MinContent => 0.0, } }; + + let cache_key: MeasureKey = ( + text_ctx.text.clone(), + text_ctx.font_info.family.clone(), + (text_ctx.font_info.size as f32).to_bits(), + (text_ctx.font_info.line_height as f32).to_bits(), + text_ctx.font_info.weight, + (max_width as f32).to_bits(), + ); + if let Some(&cached) = measure_cache.get(&cache_key) { + return cached; + } + // Acquire the font context for this measurement. The lock is // released immediately after the call so other callers // (e.g. the rasterizer) can interleave without contention. @@ -211,12 +239,14 @@ impl CanLayout for TaffyLayouter { width += (text_ctx.font_info.size * 0.3) as f32; } - Size { + let result = Size { width, // Ceil height so the layout height matches the integer-pixel surface // that pango creates (prevents descenders from overflowing the box). height: text_layout.height.ceil() as f32, - } + }; + measure_cache.insert(cache_key, result); + result } Err(_) => Size::ZERO, } @@ -231,8 +261,10 @@ impl CanLayout for TaffyLayouter { }) { log::error!("Failed to compute taffy layout: {:?}", e); + self.measure_cache = measure_cache; return layout_tree; } + self.measure_cache = measure_cache; // Since we are not interested in taffy layout after this stage in the pipeline, we convert // the taffy layout to a box model layout tree. This makes the rest of the pipeline @@ -298,6 +330,7 @@ impl TaffyLayouter { /// Generate the layout tree from the render tree fn generate_tree(&mut self, render_tree: RenderTree, root_id: RenderNodeId) -> LayoutTree { + self.measure_cache.clear(); self.tree = TaffyTree::new(); // Taffy's built-in rounding snaps layout values to integer CSS pixels, which causes // text containers to lose sub-pixel width (e.g. 52.344 → 52.0). This makes pango @@ -570,7 +603,6 @@ impl TaffyLayouter { if data.tag_name.eq_ignore_ascii_case("svg") { let inner_html = layout_tree.render_tree.doc.inner_html(dom_node.node_id); - match self .media_store .load_media_from_data(MediaType::Svg, inner_html.into_bytes().as_slice()) diff --git a/crates/gosub_render_pipeline/src/tiler.rs b/crates/gosub_render_pipeline/src/tiler.rs index 588e43e3c..7f3c7553f 100644 --- a/crates/gosub_render_pipeline/src/tiler.rs +++ b/crates/gosub_render_pipeline/src/tiler.rs @@ -254,6 +254,18 @@ impl TileList { } } + /// Like `new`, but accepts an already-`Arc`-wrapped `LayerList` — used by + /// the hover-repaint fast path to avoid cloning the layout tree. + pub fn from_arc(layer_list: Arc, dimension: Dimension) -> Self { + Self { + layer_list, + tiles: HashMap::new(), + arena: HashMap::new(), + next_node_id: Arc::new(RwLock::new(TileId::new(0))), + default_tile_dimension: dimension, + } + } + // @TODO: Optimize: remove all tiles that are empty pub fn generate(&mut self) { self.tiles.clear(); diff --git a/crates/gosub_renderer_cairo/src/bin/pipeline-cairo.rs b/crates/gosub_renderer_cairo/src/bin/pipeline-cairo.rs index e666b2172..fb23e5dca 100644 --- a/crates/gosub_renderer_cairo/src/bin/pipeline-cairo.rs +++ b/crates/gosub_renderer_cairo/src/bin/pipeline-cairo.rs @@ -88,6 +88,7 @@ Available key commands: w Toggle wireframe d Toggle debug hover t Toggle tile grid + s Print hover timing stats to stdout " ); @@ -149,13 +150,16 @@ fn build_ui( { let bs = browser_state.clone(); motion_controller.connect_motion(move |_, x, y| { + let _t_total = gosub_shared::timing_guard!("cairo_hover.total"); + let el_id = { let state = bs.read(); let Some(ref tile_list) = state.tile_list else { return; }; - let id = tile_list.read().layer_list.find_element_at(x, y); - id + let binding = tile_list.read(); + let _t = gosub_shared::timing_guard!("cairo_hover.hit_test"); + binding.layer_list.find_element_at(x, y) }; let che = bs.read().current_hovered_element; @@ -198,6 +202,7 @@ fn build_ui( let mut state = bs.write(); if state.current_hovered_element != el_id { + let _t_change = gosub_shared::timing_guard!("cairo_hover.changed"); if let Some(id) = el_id { if let Some(ref tile_list) = state.tile_list { let binding = tile_list.read(); @@ -320,6 +325,11 @@ fn build_ui( state.show_tilegrid = !state.show_tilegrid; area.queue_draw(); } + key if key == gtk4::gdk::Key::s => { + gosub_shared::timing::TIMING_TABLE + .lock() + .print_timings(false, gosub_shared::timing::Scale::Auto); + } _ => (), }