diff --git a/core/src/layout_diagnostics.rs b/core/src/layout_diagnostics.rs index e5ebebb..b922e31 100644 --- a/core/src/layout_diagnostics.rs +++ b/core/src/layout_diagnostics.rs @@ -10,8 +10,12 @@ //! added together. Use an uninstrumented build under `perf` for final CPU //! totals; counting every primitive and distinct widget deliberately perturbs //! the instrumented run. +//! +//! Call [`trace_widget`] before a frame to retain the ordered constraint, +//! reuse, size, placement, and text events for one suspicious widget. The +//! selection is a set and survives [`take`] until cleared. -use crate::WidgetId; +use crate::{Axis, Len, Size, UiRegion, WidgetId, util::Vec2}; use std::{ cell::RefCell, collections::{HashMap, HashSet}, @@ -119,6 +123,7 @@ pub struct Report { distinct_text_widgets: usize, hot_widgets: Vec, hot_text: Vec, + traces: Vec, } impl Default for Report { @@ -130,6 +135,7 @@ impl Default for Report { distinct_text_widgets: 0, hot_widgets: Vec::new(), hot_text: Vec::new(), + traces: Vec::new(), } } } @@ -160,6 +166,11 @@ impl Report { &self.hot_text } + /// Ordered layout events for widgets selected with [`trace_widget`]. + pub fn traces(&self) -> &[TraceEvent] { + &self.traces + } + /// Formats nonzero totals divided by `frames`. pub fn per_frame(&self, frames: usize) -> String { let divisor = frames.max(1) as f64; @@ -211,6 +222,12 @@ impl Report { ); } } + if !self.traces.is_empty() { + let _ = writeln!(out, " targeted layout trace:"); + for event in &self.traces { + let _ = writeln!(out, " {event:?}"); + } + } out } } @@ -223,6 +240,58 @@ pub struct Callsite { pub distinct_widths: usize, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ReuseOutcome { + Exact, + Moved, + Dirty, + WrongParent, + Unslotted, + OwnResize, + DescendantResize, +} + +/// One targeted layout event. Events are retained in execution order, making +/// repeated constraint paths visible without logging every widget globally. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TraceEvent { + DrawRequest { + id: WidgetId, + parent: Option, + region: UiRegion, + pixel_size: Vec2, + slotted: bool, + }, + Reuse { + id: WidgetId, + outcome: ReuseOutcome, + }, + SizeReported { + id: WidgetId, + size: Size, + }, + Placed { + id: WidgetId, + parent: WidgetId, + region: UiRegion, + }, + SizeRead { + id: WidgetId, + reader: WidgetId, + size: Size, + }, + HintRead { + id: WidgetId, + reader: WidgetId, + axis: Axis, + hint: Option, + }, + TextRendered { + id: WidgetId, + width: Option, + }, +} + #[derive(Default)] struct Calls { label: String, @@ -235,6 +304,7 @@ struct Current { report: Report, widgets: HashMap, text_widgets: HashMap, + traced: HashSet, } thread_local! { @@ -255,6 +325,79 @@ pub(crate) fn draw_widget(id: WidgetId, label: &str) { }); } +/// Adds a widget to the targeted trace set. Selection survives [`take`] +/// until explicitly removed or cleared. +pub fn trace_widget(id: impl Into) { + CURRENT.with_borrow_mut(|current| { + current.traced.insert(id.into()); + }); +} + +pub fn untrace_widget(id: impl Into) { + CURRENT.with_borrow_mut(|current| { + current.traced.remove(&id.into()); + }); +} + +pub fn clear_traced_widgets() { + CURRENT.with_borrow_mut(|current| current.traced.clear()); +} + +fn trace(id: WidgetId, event: TraceEvent) { + CURRENT.with_borrow_mut(|current| { + if current.traced.contains(&id) { + current.report.traces.push(event); + } + }); +} + +pub(crate) fn draw_request( + id: WidgetId, + parent: Option, + region: UiRegion, + pixel_size: Vec2, + slotted: bool, +) { + trace( + id, + TraceEvent::DrawRequest { + id, + parent, + region, + pixel_size, + slotted, + }, + ); +} + +pub(crate) fn reuse(id: WidgetId, outcome: ReuseOutcome) { + trace(id, TraceEvent::Reuse { id, outcome }); +} + +pub(crate) fn size_reported(id: WidgetId, size: Size) { + trace(id, TraceEvent::SizeReported { id, size }); +} + +pub(crate) fn placed(id: WidgetId, parent: WidgetId, region: UiRegion) { + trace(id, TraceEvent::Placed { id, parent, region }); +} + +pub(crate) fn size_read(id: WidgetId, reader: WidgetId, size: Size) { + trace(id, TraceEvent::SizeRead { id, reader, size }); +} + +pub(crate) fn hint_read(id: WidgetId, reader: WidgetId, axis: Axis, hint: Option) { + trace( + id, + TraceEvent::HintRead { + id, + reader, + axis, + hint, + }, + ); +} + pub(crate) fn render_text(id: WidgetId, label: &str, width: Option) { CURRENT.with_borrow_mut(|current| { let calls = current.text_widgets.entry(id).or_default(); @@ -263,6 +406,12 @@ pub(crate) fn render_text(id: WidgetId, label: &str, width: Option) { } calls.count += 1; calls.widths.insert(width.map(f32::to_bits)); + if current.traced.contains(&id) { + current + .report + .traces + .push(TraceEvent::TextRendered { id, width }); + } }); } diff --git a/core/src/orientation/axis.rs b/core/src/orientation/axis.rs index 997036a..1053b4e 100644 --- a/core/src/orientation/axis.rs +++ b/core/src/orientation/axis.rs @@ -1,6 +1,6 @@ use super::*; -#[derive(Copy, Clone, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum Axis { X, Y, diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index 9388acd..ae1a2d5 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -111,6 +111,8 @@ impl<'a> Painter<'a> { #[cfg(feature = "layout-diagnostics")] diag::bump(Counter::PlaceCalls); let region = region.within(&self.region); + #[cfg(feature = "layout-diagnostics")] + diag::placed(id.id(), self.id, region); self.widget_at(id, region, true) } @@ -150,6 +152,8 @@ impl<'a> Painter<'a> { .widgets() .get_dyn(id.id()) .and_then(|widget| widget.size_hint(axis)); + #[cfg(feature = "layout-diagnostics")] + diag::hint_read(id.id(), self.id, axis, hint); match hint { Some(hint) => { #[cfg(feature = "layout-diagnostics")] @@ -262,7 +266,10 @@ pub struct DrawResult<'p, 'a, W: ?Sized> { impl DrawResult<'_, '_, W> { pub fn size(self) -> Size { #[cfg(feature = "layout-diagnostics")] - diag::bump(Counter::SizeReads); + { + diag::bump(Counter::SizeReads); + diag::size_read(self.child.id(), self.painter.id, self.size); + } self.painter.depend_on_size(self.child); self.size } diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index 751f1e9..97c47b6 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -1,5 +1,5 @@ #[cfg(feature = "layout-diagnostics")] -use crate::layout_diagnostics::{self as diag, Counter, TimerKind}; +use crate::layout_diagnostics::{self as diag, Counter, ReuseOutcome, TimerKind}; use crate::{ ActiveData, Axis, DrawLayers, IdLike, MaskIdx, MoveIdx, Moves, OnResize, Painter, PixelRegion, Size, StrongWidget, UiRegion, UiRsc, WidgetId, Widgets, @@ -37,8 +37,11 @@ impl UiRenderState { } pub fn resize(&mut self, size: impl Into) { - self.output_size = size.into(); - self.resized = true; + let size = size.into(); + if size != self.output_size { + self.output_size = size; + self.resized = true; + } } pub fn output_size(&self) -> Vec2 { @@ -77,11 +80,17 @@ impl UiRenderState { { #[cfg(feature = "layout-diagnostics")] let _marking = diag::timer(TimerKind::ResizeMarking); - for (&id, active) in &self.active { - if active.reads_output { - #[cfg(feature = "layout-diagnostics")] - diag::bump(Counter::ResizeDependents); - rsc.widgets_mut().needs_redraw.insert(id); + let dependents: Vec<_> = self + .active + .iter() + .filter_map(|(&id, active)| active.reads_output.then_some(id)) + .collect(); + for id in dependents { + #[cfg(feature = "layout-diagnostics")] + diag::bump(Counter::ResizeDependents); + rsc.widgets_mut().needs_redraw.insert(id); + if let Some(top) = self.mark_readers(id, rsc) { + rsc.widgets_mut().needs_redraw.insert(top); } } } @@ -127,7 +136,10 @@ impl UiRenderState { rsc: &mut dyn UiRsc, ) -> Size { #[cfg(feature = "layout-diagnostics")] - diag::bump(Counter::DrawRequests); + { + diag::bump(Counter::DrawRequests); + diag::draw_request(id, parent, region, self.px_of(parent_move, region), slotted); + } let mut old_children = old_children.unwrap_or_default(); if self.active.contains_key(&id) { if let Some(size) = self.try_reuse(id, region, parent_move, rsc) { @@ -175,6 +187,8 @@ impl UiRenderState { let mut widget = painter.rsc.widgets().get_dyn_dynamic(id); let size = widget.draw(&mut painter); drop(widget); + #[cfg(feature = "layout-diagnostics")] + diag::size_reported(id, size); let Painter { state: _, @@ -271,7 +285,10 @@ impl UiRenderState { diag::bump(Counter::ReuseAttempts); if rsc.widgets().needs_redraw.contains(&id) { #[cfg(feature = "layout-diagnostics")] - diag::bump(Counter::ReuseDirty); + { + diag::bump(Counter::ReuseDirty); + diag::reuse(id, ReuseOutcome::Dirty); + } return None; } let active = self.active.get(&id)?; @@ -279,7 +296,10 @@ impl UiRenderState { // longer sits in, and its slot names the wrong parent. if active.parent_move != parent_move { #[cfg(feature = "layout-diagnostics")] - diag::bump(Counter::ReuseWrongParent); + { + diag::bump(Counter::ReuseWrongParent); + diag::reuse(id, ReuseOutcome::WrongParent); + } return None; } let (size, old_region, slot, old_px) = @@ -294,7 +314,10 @@ impl UiRenderState { } if !changed.iter().any(|&c| c) && old_region == region { #[cfg(feature = "layout-diagnostics")] - diag::bump(Counter::ReuseExact); + { + diag::bump(Counter::ReuseExact); + diag::reuse(id, ReuseOutcome::Exact); + } return Some(size); } // Only a placed widget can be given a different box without drawing @@ -302,7 +325,10 @@ impl UiRenderState { // entry says where all of it went. if slot == parent_move { #[cfg(feature = "layout-diagnostics")] - diag::bump(Counter::ReuseUnslotted); + { + diag::bump(Counter::ReuseUnslotted); + diag::reuse(id, ReuseOutcome::Unslotted); + } return None; } if changed.iter().any(|&c| c) { @@ -316,12 +342,18 @@ impl UiRenderState { // and has to lay out around what it comes to. if redraws { #[cfg(feature = "layout-diagnostics")] - diag::bump(Counter::ReuseOwnResize); + { + diag::bump(Counter::ReuseOwnResize); + diag::reuse(id, ReuseOutcome::OwnResize); + } return None; } if self.redraws_under(id, changed, rsc) { #[cfg(feature = "layout-diagnostics")] - diag::bump(Counter::ReuseDescendantResize); + { + diag::bump(Counter::ReuseDescendantResize); + diag::reuse(id, ReuseOutcome::DescendantResize); + } return None; } } @@ -330,7 +362,10 @@ impl UiRenderState { active.region = region; active.px = px; #[cfg(feature = "layout-diagnostics")] - diag::bump(Counter::ReuseMoved); + { + diag::bump(Counter::ReuseMoved); + diag::reuse(id, ReuseOutcome::Moved); + } Some(size) } @@ -446,14 +481,16 @@ impl UiRenderState { let _layout = diag::timer(TimerKind::IncrementalLayout); // A reader's answer is only valid after every dirty size it reads has // settled. Equal-depth widgets are independent, so their order does - // not matter. - while let Some(id) = rsc - .widgets() - .needs_redraw - .iter() - .copied() - .max_by_key(|&id| self.depth(id)) - { + // not matter. Resize dirtiness already marks whole reader chains, so + // choosing their shallowest roots coalesces descendants that share a + // reader and gives each changing box its final constraints first. + while let Some(id) = { + let dirty = rsc.widgets().needs_redraw.iter().copied(); + match self.resized { + true => dirty.min_by_key(|&id| self.depth(id)), + false => dirty.max_by_key(|&id| self.depth(id)), + } + } { #[cfg(feature = "layout-diagnostics")] diag::bump(Counter::QueuePops); self.redraw(id, rsc); diff --git a/tests/generated.rs b/tests/generated.rs index a212064..dc22fa8 100644 --- a/tests/generated.rs +++ b/tests/generated.rs @@ -18,6 +18,33 @@ use iris::random::{Edits, Lens, Rng, SpanEdit, Tree, grow}; const DEPTH: usize = 4; const SEEDS: [u64; 6] = [1, 2, 3, 5, 8, 13]; +const REGION_ULPS: u32 = 4; + +fn ordered_bits(value: f32) -> u32 { + const SIGN: u32 = 1 << 31; + let bits = value.to_bits(); + match bits & SIGN { + 0 => bits | SIGN, + _ => !bits, + } +} + +fn same_coordinate(got: f32, want: f32) -> bool { + got == want || ordered_bits(got).abs_diff(ordered_bits(want)) <= REGION_ULPS +} + +fn same_region(got: Option, want: Option) -> bool { + match (got, want) { + (Some(got), Some(want)) => { + same_coordinate(got.top_left.x, want.top_left.x) + && same_coordinate(got.top_left.y, want.top_left.y) + && same_coordinate(got.bot_right.x, want.bot_right.x) + && same_coordinate(got.bot_right.y, want.bot_right.y) + } + (None, None) => true, + _ => false, + } +} fn plant(h: &mut Harness, seed: u64, edits: &Edits) -> Tree { let (root, tree) = grow(&mut h.rsc, seed, DEPTH, edits); @@ -138,7 +165,10 @@ fn assert_same(seed: u64, what: &str, warm: (&Harness, &Tree), cold: (&Harness, for (i, (&w, &c)) in wt.ids.iter().zip(&ct.ids).enumerate() { let (got, want) = (wh.region(&w), ch.region(&c)); drawn += usize::from(got.is_some()); - if got == want { + // Equivalent composition orders can differ by a few f32 ULPs. Bound + // that representation drift directly, while whether a widget drew + // remains exact. + if same_region(got, want) { continue; } wrong += 1; diff --git a/tests/layout_diagnostics.rs b/tests/layout_diagnostics.rs index 2c130c7..54b208a 100644 --- a/tests/layout_diagnostics.rs +++ b/tests/layout_diagnostics.rs @@ -21,6 +21,53 @@ use std::time::Instant; const OUTPUT: (f32, f32) = (1920.0, 1200.0); +#[cfg(feature = "layout-diagnostics")] +#[test] +fn a_selected_widget_retains_its_layout_events() { + use iris::core::layout_diagnostics::{self as diagnostics, TraceEvent}; + + diagnostics::clear_traced_widgets(); + let _ = diagnostics::take(); + let mut harness = Harness::new((400, 200)); + let leaf = rect(Color::RED).add(&mut harness.rsc); + let other = rect(Color::BLUE).add(&mut harness.rsc); + let root = (leaf, other).span(Dir::RIGHT).add(&mut harness.rsc); + harness.set_root(root); + diagnostics::trace_widget(leaf.id()); + let _ = diagnostics::take(); + + let _ = harness.rsc.widgets_mut().get_dyn_mut(root.id()); + let _ = harness.rsc.widgets_mut().get_dyn_mut(leaf.id()); + harness.frame(); + + let report = diagnostics::take(); + assert!( + report + .traces() + .iter() + .any(|event| matches!(event, TraceEvent::Placed { id, .. } if *id == leaf.id())) + ); + assert!( + report + .traces() + .iter() + .any(|event| matches!(event, TraceEvent::DrawRequest { id, .. } if *id == leaf.id())) + ); + assert!( + report + .traces() + .iter() + .any(|event| matches!(event, TraceEvent::SizeRead { id, .. } if *id == leaf.id())) + ); + assert!( + report + .traces() + .iter() + .any(|event| matches!(event, TraceEvent::SizeReported { id, .. } if *id == leaf.id())) + ); + diagnostics::clear_traced_widgets(); +} + fn env(name: &str, fallback: T) -> T { std::env::var(name) .ok() diff --git a/tests/retained.rs b/tests/retained.rs index c7005b9..69f10dc 100644 --- a/tests/retained.rs +++ b/tests/retained.rs @@ -227,6 +227,24 @@ fn a_resize_redraws_what_read_the_output() { assert_eq!(draws.get(), settled + 1); } +#[test] +fn reporting_the_same_output_size_does_not_start_a_resize() { + let mut h = Harness::new((400, 200)); + let draws = Rc::new(Cell::new(0)); + let leaf = ReadsOutput { + draws: draws.clone(), + } + .add(&mut h.rsc); + h.set_root(leaf); + let settled = draws.get(); + + h.resize((400, 200)); + + assert!(!h.needs_redraw()); + h.frame(); + assert_eq!(draws.get(), settled); +} + #[test] fn narrowing_the_output_reflows_text_and_relays_out_around_it() { let mut h = Harness::new((600, 400));