diff --git a/docs/Profiling Component Paint.md b/docs/Profiling Component Paint.md new file mode 100644 index 000000000..019385c2d --- /dev/null +++ b/docs/Profiling Component Paint.md @@ -0,0 +1,228 @@ +# Measuring Component Paint Speed + +This guide explains how to use YUP's built-in paint profiling system to measure the rendering cost of individual components and identify bottlenecks in your UI. + +## Overview + +YUP's `PaintProfiler` is a process-wide singleton that records per-component paint timings broken into four categories: + +| Category | Meaning | +|---|---| +| **self** | Time inside the component's own `paint()` callback | +| **children** | Time spent painting all direct and indirect children | +| **framework** | Time for framework bookkeeping (clip setup, transform, etc.) | +| **total** | Full elapsed time for the complete paint pass | + +Samples are stored in a per-component ring buffer (default capacity: 300 frames). Statistical summaries — min, max, mean, p50, p95, p99 — are computed on demand from the stored samples. + +--- + +## Step 1 — Enable the Build Flag + +Paint profiling is compiled out by default. Add the preprocessor definition to your target in CMake: + +```cmake +yup_standalone_app ( + TARGET_NAME MyApp + DEFINITIONS + YUP_ENABLE_COMPONENT_PAINT_PROFILING=1 + MODULES + yup::yup_gui + # ... other modules +) +``` + +Without this flag every profiling call is a no-op and produces no overhead in release builds. + +--- + +## Step 2 — Start a Session + +The recommended API is `PaintProfiler::startSession()`, which returns a `ScopedSession`. The session enables profiling on the entire component subtree rooted at the component you pass in, and disables it automatically when the handle is destroyed. + +```cpp +#if YUP_ENABLE_COMPONENT_PAINT_PROFILING + std::unique_ptr profileSession; +#endif + +// In your component constructor or initialisation: +#if YUP_ENABLE_COMPONENT_PAINT_PROFILING + profileSession = yup::PaintProfiler::getInstance().startSession (*this); +#endif +``` + +Guard every profiling call with `#if YUP_ENABLE_COMPONENT_PAINT_PROFILING` so the code compiles and runs correctly in builds without the flag. + +### Session options + +Pass a `PaintProfileOptions` struct to control the session's behaviour: + +```cpp +yup::PaintProfileOptions options; +options.sampleCapacity = 600; // retain 600 frames of history +options.minimumSampleMicros = 50.0; // discard samples shorter than 50 µs +options.includeBounds = true; // record component bounds per sample +options.includeRepaintArea = true; // record dirty rect per sample +options.includeInvisibleComponents = false; // should invisible components be included +options.recordSkippedSelfPaint = true; // track child-only cost even with no paint() + +profileSession = yup::PaintProfiler::getInstance().startSession (*this, options); +``` + +--- + +## Step 3 — Take Snapshots + +A `Snapshot` is an immutable, point-in-time view of every registered component's statistics. Call `createSnapshot()` on the session at whatever rate you need — a 10 Hz timer is typical for a dashboard display. + +```cpp +void timerCallback() override +{ +#if YUP_ENABLE_COMPONENT_PAINT_PROFILING + if (profileSession == nullptr || profileSession->isPaused()) + return; + + auto snap = profileSession->createSnapshot (yup::PaintProfileTimeKind::total, 32); + // use snap ... +#endif +} +``` + +`createSnapshot` accepts: +- **sortBy** — which time kind determines the descending sort order (default `total`). +- **histogramBuckets** — bucket count for the global frame histogram (default 32). + +The returned `Snapshot` contains: + +```cpp +struct Snapshot +{ + uint64 frameIndex; // frame counter at snapshot time + std::vector components; // one entry per registered component + PaintProfileSummary globalFrameTotal; // per-frame total across all components + PaintProfileHistogram globalFrameHistogram; +}; +``` + +Each `ComponentEntry` gives you: + +```cpp +struct ComponentEntry +{ + String name; // component title at snapshot time + PaintProfileStats* stats; // live pointer — may be stale after destruction + PaintProfileSummary self; + PaintProfileSummary children; + PaintProfileSummary framework; + PaintProfileSummary total; +}; +``` + +A `PaintProfileSummary` exposes: `lastMicros`, `minMicros`, `maxMicros`, `meanMicros`, `p50Micros`, `p95Micros`, `p99Micros`, and `sampleCount`. + +--- + +## Step 4 — Interpret the Data + +### Performance thresholds + +| Range | Meaning | +|---|---| +| < 500 µs | Normal — no action needed | +| 500 µs – 2 ms | Warm — worth investigating if sustained | +| > 2 ms | Hot — likely causing dropped frames at 60 Hz | + +At 60 Hz, the full frame budget is ~16.7 ms. A single component that consistently takes > 2 ms for its own paint is a significant contributor. + +### Reading a summary + +```cpp +const auto& entry = snap.components[0]; + +// Is the component itself expensive, or is it due to children? +double selfCost = entry.self.p95Micros; +double childrenCost = entry.children.p95Micros; + +// p95 is the most useful signal: it captures spikes while ignoring outliers +double worstNormal = entry.total.p95Micros; +``` + +Use **p95** as the primary signal. `maxMicros` is useful for catching spikes, but a single GC or OS event can inflate it. `meanMicros` smooths over spikes that matter. + +### Log a snapshot to the console + +```cpp +#if YUP_ENABLE_COMPONENT_PAINT_PROFILING +auto snap = profileSession->createSnapshot (yup::PaintProfileTimeKind::total, 32); + +yup::Logger::outputDebugString ("Paint profile — frame " + yup::String (snap.frameIndex)); +yup::Logger::outputDebugString (yup::String::formatted ("%-30s %9s %9s %9s %9s", "Widget", "last", "mean", "p95", "max")); +for (const auto& entry : snap.components) +{ + yup::Logger::outputDebugString ( + yup::String::formatted ("%-30s %7.2f ms %7.2f ms %7.2f ms %7.2f ms", + entry.name.toRawUTF8(), + entry.total.lastMicros / 1000.0, + entry.total.meanMicros / 1000.0, + entry.total.p95Micros / 1000.0, + entry.total.maxMicros / 1000.0)); +} +#endif +``` + +--- + +## Step 5 — Inspect Raw Samples + +When you need more detail than aggregated statistics, pull the ring buffer directly: + +```cpp +#if YUP_ENABLE_COMPONENT_PAINT_PROFILING +if (auto* stats = myComponent.getPaintProfileStats()) +{ + const auto samples = stats->copySamples(); // chronological order, oldest first + + for (const auto& s : samples) + { + // s.selfMicros, s.childrenMicros, s.frameworkMicros, s.totalMicros + // s.frameIndex, s.paintIndex + // s.componentBounds, s.repaintArea + // s.renderContinuous, s.selfPaintSkipped + } + + auto summary = stats->summarize (yup::PaintProfileTimeKind::total); + auto histogram = stats->createHistogram (yup::PaintProfileTimeKind::self, 32); +} +#endif +``` + +`copySamples()` returns samples in chronological order regardless of the ring-buffer write position. + +--- + +## Step 6 — Reset and Pause + +Clear accumulated history after a layout change or before a timed benchmark: + +```cpp +profileSession->reset(); // clears all ring buffers for this session's components +``` + +Temporarily suppress recording without destroying the session: + +```cpp +profileSession->setPaused (true); +// ... do something that should not be measured ... +profileSession->setPaused (false); +``` + +--- + +## Tips for Accurate Measurements + +- **Warm up first.** Discard the first second of data after a `reset()` — the JIT-equivalent effects (GPU shader compilation, OS scheduling) inflate early samples. +- **Isolate one change at a time.** Use `setPaused(true)` on the session while switching components so the history stays clean. +- **Prefer p95 over max.** A single OS preemption can produce a multi-millisecond outlier that distorts `maxMicros`. +- **Separate self from children.** A high `total` with a low `self` means the component's own paint is fine but its children are costly — recurse down the tree. +- **Check `renderContinuous`.** Samples with `renderContinuous = true` in the ring buffer indicate the component is requesting continuous repaints (animation loops). Every such component adds baseline CPU pressure even when nothing is animating visually. +- **Use `setOpaque(true)`.** Opaque components allow the renderer to skip painting the background beneath them. It is one of the cheapest paint optimisations available. diff --git a/docs/images/yup_component_profiler.png b/docs/images/yup_component_profiler.png new file mode 100644 index 000000000..6710f31c6 Binary files /dev/null and b/docs/images/yup_component_profiler.png differ diff --git a/examples/graphics/source/examples/PaintProfilerDemo.h b/examples/graphics/source/examples/PaintProfilerDemo.h new file mode 100644 index 000000000..4e383ead2 --- /dev/null +++ b/examples/graphics/source/examples/PaintProfilerDemo.h @@ -0,0 +1,683 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +//============================================================================== + +class OpaqueBackgroundWidget : public yup::Component +{ +public: + OpaqueBackgroundWidget() + { + setTitle ("Opaque Background"); + setOpaque (true); + } + + void paint (yup::Graphics& g) override + { + g.setFillColor (yup::Color (0xff1a472a)); + g.fillAll(); + } +}; + +//============================================================================== + +class PathDrawingWidget : public yup::Component + , public yup::Timer +{ +public: + PathDrawingWidget() + { + setTitle ("Path Drawing"); + startTimerHz (60); + } + + ~PathDrawingWidget() override + { + stopTimer(); + } + + void paint (yup::Graphics& g) override + { + g.setFillColor (yup::Color (0xff1b3a6b)); + g.fillAll(); + + const auto bounds = getLocalBounds().to(); + const float cx = bounds.getCenterX(); + const float cy = bounds.getCenterY(); + const float r = std::min (bounds.getWidth(), bounds.getHeight()) * 0.4f; + + yup::Path path; + for (int i = 0; i <= 120; ++i) + { + const float angle = (float) i / 120.0f * yup::MathConstants::twoPi + phase; + const float radius = r * (0.5f + 0.5f * std::sin (angle * 3.0f + phase)); + const float px = cx + radius * std::cos (angle); + const float py = cy + radius * std::sin (angle); + if (i == 0) + path.startNewSubPath (px, py); + else + path.lineTo (px, py); + } + path.closeSubPath(); + + g.setFillColor (yup::Color (0xff4a90d9).withAlpha (0.6f)); + g.fillPath (path); + g.setStrokeColor (yup::Color (0xff80c0ff)); + g.setStrokeWidth (1.5f + strokePhase); + g.strokePath (path); + } + + void timerCallback() override + { + phase += 0.03f; + strokePhase = 0.5f + 0.5f * std::sin (phase * 0.7f); + repaint(); + } + +private: + float phase = 0.0f; + float strokePhase = 0.0f; +}; + +//============================================================================== + +class TextGradientWidget : public yup::Component + , public yup::Timer +{ +public: + TextGradientWidget() + { + setTitle ("Text Gradient"); + auto theme = yup::ApplicationTheme::getGlobalTheme(); + font = theme->getDefaultFont(); + startTimerHz (30); + } + + ~TextGradientWidget() override + { + stopTimer(); + } + + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds().to(); + g.setFillColor (yup::Color (0xff2d1b4e)); + g.fillAll(); + + const float t = phase; + g.setFillColor (yup::Color (0xff9b59b6).withAlpha (0.4f + 0.3f * std::sin (t))); + g.fillRect (bounds.reduced (8.0f)); + + g.setFillColor (yup::Colors::white); + g.fillFittedText ("TextGradient", font, bounds, yup::Justification::center); + } + + void timerCallback() override + { + phase += 0.05f; + repaint(); + } + +private: + float phase = 0.0f; + yup::Font font; +}; + +//============================================================================== + +class NestedWidgetGrid : public yup::Component +{ + class GridCell : public yup::Component + { + public: + explicit GridCell (yup::Color color, int index) + : cellColor (color) + { + setTitle ("Grid Cell " + yup::String (index)); + setOpaque (true); + } + + void paint (yup::Graphics& g) override + { + g.setFillColor (cellColor); + g.fillAll(); + } + + private: + yup::Color cellColor; + }; + +public: + NestedWidgetGrid() + { + setTitle ("Nested Widget Grid"); + for (int i = 0; i < 16; ++i) + { + const float hue = (float) i / 16.0f; + cells.push_back (std::make_unique (yup::Color::fromHSL (hue, 0.7f, 0.5f, 1.0f), i)); + addAndMakeVisible (cells.back().get()); + } + } + + void paint (yup::Graphics& g) override + { + g.setFillColor (yup::Color (0xff1c1c2e)); + g.fillAll(); + } + + void resized() override + { + const auto bounds = getLocalBounds().to(); + const int cols = 4, rows = 4; + const float cellW = bounds.getWidth() / (float) cols; + const float cellH = bounds.getHeight() / (float) rows; + + for (int i = 0; i < (int) cells.size(); ++i) + { + const int col = i % cols; + const int row = i / cols; + cells[i]->setBounds ({ col * cellW, row * cellH, cellW, cellH }); + } + } + +private: + std::vector> cells; +}; + +//============================================================================== + +class ProfilerDashboard : public yup::Component +{ +public: + ProfilerDashboard() + { + setTitle ("Profiler Dashboard"); + auto theme = yup::ApplicationTheme::getGlobalTheme(); + font = theme->getDefaultFont(); + } + + void setSnapshot (const yup::PaintProfiler::Snapshot& newSnapshot, int selectedIndex) + { + snapshot = newSnapshot; + selectedRow = selectedIndex; + repaint(); + } + + int getSelectedRow() const { return selectedRow; } + + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds().to(); + if (bounds.isEmpty()) + return; + + g.setFillColor (yup::Color (0xff1a1a2e)); + g.fillAll(); + + const float divX = bounds.getWidth() * 0.60f; + drawTable (g, bounds.withWidth (divX)); + drawRightPanel (g, bounds.withX (divX + 4.0f).withWidth (bounds.getWidth() - divX - 4.0f)); + } + + void mouseDown (const yup::MouseEvent& event) override + { + const float divX = getLocalBounds().to().getWidth() * 0.60f; + if (event.getPosition().getX() >= divX) + return; + + const float headerH = 24.0f; + const float rowH = 20.0f; + const int row = (int) ((event.getPosition().getY() - headerH) / rowH); + if (row >= 0 && row < (int) snapshot.components.size()) + selectedRow = row; + repaint(); + } + +private: + static constexpr float kMaxColW = 62.0f; + static constexpr float kAvgColW = 62.0f; + static constexpr float kHeaderH = 24.0f; + static constexpr float kRowH = 20.0f; + + void drawTable (yup::Graphics& g, yup::Rectangle area) + { + if (area.isEmpty()) + return; + + const float histColW = area.getWidth() - kMaxColW - kAvgColW; + + auto header = area.removeFromTop (kHeaderH); + g.setFillColor (yup::Color (0xff252545)); + g.fillRect (header); + g.setFillColor (yup::Color (0xffaaaacc)); + + const float nameW = histColW * 0.42f; + + auto hdrCopy = header.reduced (2, 0); + g.fillFittedText ("Widget", font, hdrCopy.removeFromLeft (nameW), yup::Justification::centerLeft); + g.fillFittedText ("max", font, hdrCopy.removeFromLeft (kMaxColW), yup::Justification::centerLeft); + g.fillFittedText ("avg", font, hdrCopy.removeFromLeft (kAvgColW), yup::Justification::centerLeft); + g.fillFittedText ("history (green=self / blue=children)", font, hdrCopy, yup::Justification::centerLeft); + + for (int i = 0; i < (int) snapshot.components.size(); ++i) + { + if (area.isEmpty()) + break; + + const auto& entry = snapshot.components[i]; + auto row = area.removeFromTop (kRowH); + + const bool isSelected = (i == selectedRow); + const bool isHot = entry.total.maxMicros > 2000.0; + const bool isWarm = ! isHot && entry.total.maxMicros > 500.0; + + g.setFillColor (isSelected ? yup::Color (0xff353565) + : isHot ? yup::Color (0xff3a1010) + : isWarm ? yup::Color (0xff3a2a10) + : yup::Color (0xff1e1e38)); + g.fillRect (row); + + g.setFillColor (isHot ? yup::Color (0xffff6060) + : isWarm ? yup::Color (0xffffcc44) + : yup::Colors::white); + + auto r = row.reduced (2, 0); + g.fillFittedText (entry.name, font, r.removeFromLeft (nameW), yup::Justification::centerLeft); + g.fillFittedText (formatMicros (entry.total.maxMicros), font, r.removeFromLeft (kMaxColW), yup::Justification::centerLeft); + g.fillFittedText (formatMicros (entry.total.meanMicros), font, r.removeFromLeft (kAvgColW), yup::Justification::centerLeft); + + drawSparkline (g, r, entry.stats); + } + } + + void drawSparkline (yup::Graphics& g, yup::Rectangle area, const yup::PaintProfileStats* stats) + { + if (area.isEmpty() || stats == nullptr || stats->getSampleCount() == 0) + return; + + const auto allSamples = stats->copySamples(); + const int n = std::min ((int) allSamples.size(), 80); + const int start = (int) allSamples.size() - n; + + const float barW = area.getWidth() / (float) n; + const float maxUs = 2000.0f; + + for (int i = 0; i < n; ++i) + { + const auto& sample = allSamples[(std::size_t) (start + i)]; + const double totalUs = sample.totalMicros; + const double selfUs = sample.selfMicros; + + const float ratio = std::min (1.0f, (float) (totalUs / maxUs)); + const float totalH = std::max (2.0f, area.getHeight() * ratio); + + const float selfFrac = totalUs > 0.0 ? (float) (selfUs / totalUs) : 1.0f; + const float selfH = std::max (1.0f, totalH * selfFrac); + const float childH = totalH - selfH; + + const float x = area.getX() + i * barW; + const float bW = std::max (1.0f, barW - 0.5f); + + // Self portion (bottom) — green normally, amber/red when hot + g.setFillColor (totalUs > 2000.0 ? yup::Color (0xffcc2222) + : totalUs > 500.0 ? yup::Color (0xffbb8800) + : yup::Color (0xff33aa44)); + g.fillRect ({ x, area.getBottom() - selfH, bW, selfH }); + + // Children portion (above self) — blue normally, amber/red when hot + if (childH > 0.5f) + { + g.setFillColor (totalUs > 2000.0 ? yup::Color (0xffff6666) + : totalUs > 500.0 ? yup::Color (0xffffcc44) + : yup::Color (0xff4488cc)); + g.fillRect ({ x, area.getBottom() - totalH, bW, childH }); + } + } + } + + void drawRightPanel (yup::Graphics& g, yup::Rectangle area) + { + if (area.isEmpty()) + return; + + const float histH = (area.getHeight() - 6.0f) * 0.48f; + + auto globalArea = area.removeFromTop (histH); + drawHistogramPanel (g, globalArea, "Global frame total", snapshot.globalFrameHistogram, -1.0); + + area.removeFromTop (6.0f); + + if (selectedRow >= 0 && selectedRow < (int) snapshot.components.size()) + { + const auto& entry = snapshot.components[selectedRow]; + yup::PaintProfileHistogram selHisto; + if (entry.stats != nullptr) + selHisto = entry.stats->createHistogram (yup::PaintProfileTimeKind::total, 32); + + auto selArea = area.removeFromTop (histH); + drawHistogramPanel (g, selArea, entry.name + " total", selHisto, entry.total.p95Micros); + + area.removeFromTop (4.0f); + g.setFillColor (yup::Color (0xff888899)); + g.fillFittedText ( + yup::String::formatted ("self %.0f children %.0f fw %.0f total %.0f us [p95 %.0f]", + entry.self.lastMicros, + entry.children.lastMicros, + entry.framework.lastMicros, + entry.total.lastMicros, + entry.total.p95Micros), + font, + area.reduced (4, 0), + yup::Justification::topLeft); + } + else + { + auto selArea = area.removeFromTop (histH); + drawHistogramPanel (g, selArea, "Click a row to inspect", {}, -1.0); + } + } + + void drawHistogramPanel (yup::Graphics& g, + yup::Rectangle area, + const yup::String& label, + const yup::PaintProfileHistogram& histo, + double p95Micros) + { + if (area.isEmpty()) + return; + + g.setFillColor (yup::Color (0xff161628)); + g.fillRect (area); + + const float labelH = 15.0f; + auto labelArea = area.removeFromTop (labelH); + g.setFillColor (yup::Color (0xff8888bb)); + g.fillFittedText (label, font, labelArea.reduced (3, 0), yup::Justification::centerLeft); + + const float axisH = 13.0f; + auto axisArea = area.removeFromBottom (axisH); + + if (histo.buckets.empty() || histo.rangeMaxMicros <= 0.0) + return; + + int maxCount = 0; + for (int c : histo.buckets) + maxCount = std::max (maxCount, c); + if (maxCount == 0) + return; + + const int numBuckets = (int) histo.buckets.size(); + const float barW = area.getWidth() / (float) numBuckets; + const double usPerBucket = histo.rangeMaxMicros / numBuckets; + + for (int i = 0; i < numBuckets; ++i) + { + const float ratio = (float) histo.buckets[i] / (float) maxCount; + if (ratio <= 0.0f) + continue; + + const double bucketMidUs = (i + 0.5) * usPerBucket; + const yup::Color barColor = bucketMidUs > 2000.0 ? yup::Color (0xffcc3333) + : bucketMidUs > 500.0 ? yup::Color (0xffcc8800) + : yup::Color (0xff33aa44); + + const float barH = area.getHeight() * ratio; + g.setFillColor (barColor); + g.fillRect ({ area.getX() + i * barW, + area.getBottom() - barH, + std::max (1.0f, barW - 0.5f), + barH }); + } + + auto drawThreshold = [&] (double thresholdUs, yup::Color color) + { + if (thresholdUs >= histo.rangeMaxMicros) + return; + const float x = area.getX() + area.getWidth() * (float) (thresholdUs / histo.rangeMaxMicros); + g.setFillColor (color.withAlpha (0.55f)); + g.fillRect ({ x - 0.5f, area.getY(), 1.0f, area.getHeight() }); + }; + + drawThreshold (500.0, yup::Color (0xffffff00)); + drawThreshold (2000.0, yup::Color (0xffff4444)); + + if (p95Micros > 0.0 && p95Micros < histo.rangeMaxMicros) + { + const float x = area.getX() + area.getWidth() * (float) (p95Micros / histo.rangeMaxMicros); + g.setFillColor (yup::Color (0xffffffff).withAlpha (0.7f)); + g.fillRect ({ x - 0.5f, area.getY(), 1.0f, area.getHeight() }); + } + + g.setFillColor (yup::Color (0xff666688)); + g.fillFittedText ("0", font, axisArea.removeFromLeft (28.0f), yup::Justification::centerLeft); + + const float pos500 = area.getWidth() * (float) (500.0 / histo.rangeMaxMicros); + if (pos500 > 30.0f && pos500 < area.getWidth() - 30.0f) + { + g.setFillColor (yup::Color (0xffaaaa44)); + g.fillFittedText (L"500µs", font, { area.getX() + pos500 - 18.0f, axisArea.getY(), 36.0f, axisH }, yup::Justification::center); + } + + const float pos2ms = area.getWidth() * (float) (2000.0 / histo.rangeMaxMicros); + if (pos2ms > 30.0f && pos2ms < area.getWidth() - 30.0f) + { + g.setFillColor (yup::Color (0xffcc5555)); + g.fillFittedText ("2ms", font, { area.getX() + pos2ms - 16.0f, axisArea.getY(), 32.0f, axisH }, yup::Justification::center); + } + + g.setFillColor (yup::Color (0xff666688)); + g.fillFittedText (formatMicros (histo.rangeMaxMicros), font, axisArea.removeFromRight (52.0f), yup::Justification::centerRight); + } + + static yup::String formatMicros (double micros) + { + if (micros >= 1000.0) + return yup::String::formatted ("%.2f ms", micros / 1000.0); + return yup::String::formatted ("%.0f us", micros); + } + + yup::PaintProfiler::Snapshot snapshot; + + int selectedRow = -1; + yup::Font font; +}; + +//============================================================================== + +class ProfilerWindow : public yup::DocumentWindow +{ +public: + explicit ProfilerWindow (std::function onClose) + : yup::DocumentWindow ( + yup::ComponentNative::Options() + .withResizableWindow (true) + .withRenderContinuous (false), + yup::Color (0xff1a1a2e)) + , closeCallback (std::move (onClose)) + { + setTitle ("Paint Profiler"); + dashboard = std::make_unique(); + addAndMakeVisible (dashboard.get()); + } + + void resized() override + { + dashboard->setBounds (getLocalBounds()); + } + + void userTriedToCloseWindow() override + { + if (closeCallback != nullptr) + yup::MessageManager::callAsync (closeCallback); + } + + void updateSnapshot (const yup::PaintProfiler::Snapshot& snap) + { + dashboard->setSnapshot (snap, dashboard->getSelectedRow()); + } + +private: + std::unique_ptr dashboard; + std::function closeCallback; +}; + +//============================================================================== + +class PaintProfilerDemo : public yup::Component + , public yup::Timer +{ +public: + PaintProfilerDemo() + { + setTitle ("Paint Profiler Demo"); + setWantsKeyboardFocus (true); + + opaqueWidget = std::make_unique(); + addAndMakeVisible (opaqueWidget.get()); + + pathWidget = std::make_unique(); + addAndMakeVisible (pathWidget.get()); + + textWidget = std::make_unique(); + addAndMakeVisible (textWidget.get()); + + nestedGrid = std::make_unique(); + addAndMakeVisible (nestedGrid.get()); + + paintProfileSession = yup::PaintProfiler::getInstance().startSession (*this); + + startTimerHz (10); + } + + ~PaintProfilerDemo() override + { + stopTimer(); + profilerWindow.reset(); + paintProfileSession.reset(); + } + + void paint (yup::Graphics& g) override + { + g.setFillColor (findColor (yup::DocumentWindow::Style::backgroundColorId) + .value_or (yup::Color (0xff282840))); + g.fillAll(); + } + + void resized() override + { + const auto bounds = getLocalBounds().to(); + const float widgetW = bounds.getWidth() / 2.0f; + const float widgetH = bounds.getHeight() / 2.0f; + + opaqueWidget->setBounds ({ 0.0f, 0.0f, widgetW, widgetH }); + pathWidget->setBounds ({ widgetW, 0.0f, widgetW, widgetH }); + textWidget->setBounds ({ 0.0f, widgetH, widgetW, widgetH }); + nestedGrid->setBounds ({ widgetW, widgetH, widgetW, widgetH }); + } + + void timerCallback() override + { + if (paintProfileSession == nullptr || paintProfileSession->isPaused()) + return; + + if (profilerWindow == nullptr || ! profilerWindow->isVisible()) + return; + + auto snap = paintProfileSession->createSnapshot (yup::PaintProfileTimeKind::total, 32); + profilerWindow->updateSnapshot (snap); + } + + void keyDown (const yup::KeyPress& keys, const yup::Point& position) override + { + if (keys.getKey() == yup::KeyPress::textPKey) + { + toggleProfilerWindow(); + } + else if (keys.getKey() == yup::KeyPress::textRKey) + { + if (paintProfileSession != nullptr) + paintProfileSession->reset(); + } + else if (keys.getKey() == yup::KeyPress::textLKey) + { + logSnapshot(); + } + } + +private: + void toggleProfilerWindow() + { + if (profilerWindow == nullptr) + { + profilerWindow = std::make_unique ([this] + { + profilerWindow.reset(); + }); + profilerWindow->centreWithSize ({ 920, 520 }); + profilerWindow->setVisible (true); + profilerWindow->toFront (true); + } + else + { + profilerWindow.reset(); + } + } + + void logSnapshot() + { + if (paintProfileSession == nullptr) + return; + + auto snap = paintProfileSession->createSnapshot (yup::PaintProfileTimeKind::total, 32); + + yup::Logger::outputDebugString ("Paint profile snapshot frame=" + yup::String (snap.frameIndex)); + yup::Logger::outputDebugString (yup::String::formatted ("%-30s %9s %9s %9s %9s", + "Widget", + "last", + "mean", + "p95", + "max")); + + for (const auto& entry : snap.components) + { + yup::Logger::outputDebugString (yup::String::formatted ("%-30s %8.2f ms %8.2f ms %8.2f ms %8.2f ms", + entry.name.toRawUTF8(), + entry.total.lastMicros / 1000.0, + entry.total.meanMicros / 1000.0, + entry.total.p95Micros / 1000.0, + entry.total.maxMicros / 1000.0)); + } + + yup::String globalBuckets = "Global frame histogram total/us buckets:"; + for (int c : snap.globalFrameHistogram.buckets) + globalBuckets += " " + yup::String (c); + yup::Logger::outputDebugString (globalBuckets); + } + + std::unique_ptr opaqueWidget; + std::unique_ptr pathWidget; + std::unique_ptr textWidget; + std::unique_ptr nestedGrid; + std::unique_ptr profilerWindow; + std::unique_ptr paintProfileSession; +}; diff --git a/examples/graphics/source/main.cpp b/examples/graphics/source/main.cpp index a943ca89a..339185015 100644 --- a/examples/graphics/source/main.cpp +++ b/examples/graphics/source/main.cpp @@ -55,6 +55,7 @@ #include "examples/TextEditor.h" #include "examples/VariableFonts.h" #include "examples/Widgets.h" +#include "examples/PaintProfilerDemo.h" #if YUP_MODULE_AVAILABLE_yup_python #include "examples/Python.h" #endif @@ -161,6 +162,7 @@ class CustomWindow jassert (artboard.loadArtboard()); }); registerDemo ("SVG", counter++); + registerDemo ("Paint Profiler", counter++); #if YUP_MODULE_AVAILABLE_yup_python registerDemo ("Python", counter++); #endif diff --git a/modules/yup_gui/component/yup_Component.cpp b/modules/yup_gui/component/yup_Component.cpp index 490ed3c73..038a2896a 100644 --- a/modules/yup_gui/component/yup_Component.cpp +++ b/modules/yup_gui/component/yup_Component.cpp @@ -37,6 +37,8 @@ Component::Component (StringRef componentID) Component::~Component() { + componentListeners.call (&ComponentListener::componentBeingDeleted, *this); + if (options.onDesktop) removeFromDesktop(); @@ -158,7 +160,7 @@ void Component::setPosition (const Point& newPosition) if (options.onDesktop && native != nullptr) native->setPosition (newPosition.to()); - moved(); + sendMoved(); } float Component::getX() const @@ -203,7 +205,7 @@ void Component::setTopLeft (const Point& newTopLeft) if (options.onDesktop && native != nullptr) native->setPosition (newTopLeft.to()); - moved(); + sendMoved(); } Point Component::getBottomLeft() const @@ -218,7 +220,7 @@ void Component::setBottomLeft (const Point& newBottomLeft) if (options.onDesktop && native != nullptr) native->setPosition (newBottomLeft.translated (0.0f, -getHeight()).to()); - moved(); + sendMoved(); } Point Component::getTopRight() const @@ -233,7 +235,7 @@ void Component::setTopRight (const Point& newTopRight) if (options.onDesktop && native != nullptr) native->setPosition (newTopRight.translated (-getWidth(), 0.0f).to()); - moved(); + sendMoved(); } Point Component::getBottomRight() const @@ -248,7 +250,7 @@ void Component::setBottomRight (const Point& newBottomRight) if (options.onDesktop && native != nullptr) native->setPosition (newBottomRight.translated (-getWidth(), -getHeight()).to()); - moved(); + sendMoved(); } Point Component::getCenter() const @@ -263,7 +265,7 @@ void Component::setCenter (const Point& newCenter) if (options.onDesktop && native != nullptr) native->setPosition (newCenter.translated (-getWidth() / 2.0f, -getHeight() / 2.0f).to()); - moved(); + sendMoved(); } float Component::getCenterX() const @@ -281,7 +283,7 @@ void Component::setCenterX (float newCenterX) native->setPosition (newCenter.translated (-getWidth() / 2.0f, 0.0f).to()); } - moved(); + sendMoved(); } float Component::getCenterY() const @@ -299,11 +301,21 @@ void Component::setCenterY (float newCenterY) native->setPosition (newCenter.translated (0.0f, -getHeight() / 2.0f).to()); } - moved(); + sendMoved(); } void Component::moved() {} +void Component::sendMoved() +{ + moved(); + + componentListeners.call ([this] (ComponentListener& listener) + { + listener.componentMoved (*this); + }); +} + //============================================================================== void Component::setSize (float width, float height) @@ -320,7 +332,7 @@ void Component::setSize (const Size& newSize) if (options.onDesktop && native != nullptr) native->setSize (newSize.to()); - resized(); + sendResized(); repaint (areaToRepaint); } @@ -361,12 +373,12 @@ void Component::setBounds (const Rectangle& newBounds) auto bailOutChecker = BailOutChecker (this); - resized(); + sendResized(); if (bailOutChecker.shouldBailOut()) return; - moved(); + sendMoved(); } Rectangle Component::getBounds() const @@ -407,6 +419,16 @@ float Component::proportionOfHeight (float proportion) const void Component::resized() {} +void Component::sendResized() +{ + resized(); + + componentListeners.call ([this] (ComponentListener& listener) + { + listener.componentResized (*this); + }); +} + //============================================================================== void Component::setTransform (const AffineTransform& newTransform) @@ -511,6 +533,16 @@ bool Component::isRenderingUnclipped() const return options.unclippedRendering; } +void Component::setPaintProfilingDisabled (bool shouldBeDisabled) +{ + options.paintProfilingDisabled = shouldBeDisabled; +} + +bool Component::isPaintProfilingDisabled() const +{ + return options.paintProfilingDisabled; +} + void Component::repaint() { repaint (getLocalBounds()); @@ -902,6 +934,21 @@ void Component::setWantsKeyboardFocus (bool wantsFocus) options.wantsKeyboardFocus = wantsFocus; } +bool Component::getWantsKeyboardFocus() const +{ + return options.wantsKeyboardFocus; +} + +void Component::setClickingGrabFocus (bool shouldGrabFocus) +{ + options.clickingDoesNotGrabFocus = ! shouldGrabFocus; +} + +bool Component::getClickingGrabFocus() const +{ + return ! options.clickingDoesNotGrabFocus; +} + void Component::takeKeyboardFocus() { if (! options.wantsKeyboardFocus || ! isEnabled()) @@ -937,6 +984,20 @@ void Component::focusLost() {} //============================================================================== +void Component::handleKeyboardFocusFromClick() +{ + for (auto* component = this; component != nullptr; component = component->parentComponent) + { + if (component->options.wantsKeyboardFocus && ! component->options.clickingDoesNotGrabFocus) + { + component->takeKeyboardFocus(); + return; + } + } +} + +//============================================================================== + NamedValueSet& Component::getProperties() { return properties; @@ -1014,6 +1075,18 @@ void Component::removeMouseListener (MouseListener* listener) //============================================================================== +void Component::addComponentListener (ComponentListener* listener) +{ + componentListeners.add (listener); +} + +void Component::removeComponentListener (ComponentListener* listener) +{ + componentListeners.remove (listener); +} + +//============================================================================== + void Component::setStyle (ComponentStyle::Ptr newStyle) { if (style == newStyle) @@ -1123,8 +1196,6 @@ bool Component::hasOpaqueChildCoveringArea (const Rectangle& area) return false; } -//============================================================================== - void Component::internalRefreshDisplay (double lastFrameTimeSeconds) { refreshDisplay (lastFrameTimeSeconds); @@ -1174,6 +1245,20 @@ void Component::internalPaint (Graphics& g, const Rectangle& repaintArea, options.isRepainting = true; { + const bool shouldMeasurePaint = ! options.paintProfilingDisabled && ! componentListeners.isEmpty(); + + ComponentPaintMetrics metrics; + int64 totalStartTicks = 0; + int64 selfStartTicks = 0; + + if (shouldMeasurePaint) + { + totalStartTicks = Time::getHighResolutionTicks(); + metrics.repaintArea = repaintArea; + metrics.componentBounds = bounds.to(); + metrics.renderContinuous = renderContinuous; + } + const auto globalState = g.saveState(); g.setOpacity (opacity); @@ -1191,18 +1276,57 @@ void Component::internalPaint (Graphics& g, const Rectangle& repaintArea, { const auto paintState = g.saveState(); - paint (g); + if (shouldMeasurePaint) + { + selfStartTicks = Time::getHighResolutionTicks(); + + paint (g); + + metrics.selfTicks += Time::getHighResolutionTicks() - selfStartTicks; + } + else + { + paint (g); + } + } + else + { + if (shouldMeasurePaint) + metrics.selfPaintSkipped = true; } - for (auto child : children) - child->internalPaint (g, boundsToRedraw, renderContinuous); + if (shouldMeasurePaint) + { + const int64 childrenStartTicks = Time::getHighResolutionTicks(); + + for (auto child : children) + child->internalPaint (g, boundsToRedraw, renderContinuous); + + metrics.childrenTicks += Time::getHighResolutionTicks() - childrenStartTicks; + + selfStartTicks = Time::getHighResolutionTicks(); + + paintOverChildren (g); + + const int64 selfEndTicks = Time::getHighResolutionTicks(); + + metrics.selfTicks += selfEndTicks - selfStartTicks; + metrics.totalTicks = selfEndTicks - totalStartTicks; - paintOverChildren (g); + componentListeners.call (&ComponentListener::componentPaintCompleted, *this, metrics); + } + else + { + for (auto child : children) + child->internalPaint (g, boundsToRedraw, renderContinuous); + + paintOverChildren (g); + } } options.isRepainting = false; -#if YUP_ENABLE_COMPONENT_REPAINT_DEBUGGING +#if YUP_ENABLE_COMPONENT_PAINT_DEBUGGING g.setFillColor (debugColor); g.setOpacity (0.2f); g.fillAll(); @@ -1264,6 +1388,11 @@ void Component::internalMouseDown (const MouseEvent& event) auto bailOutChecker = BailOutChecker (this); + handleKeyboardFocusFromClick(); + + if (bailOutChecker.shouldBailOut()) + return; + mouseDown (event); if (bailOutChecker.shouldBailOut()) @@ -1399,7 +1528,7 @@ void Component::internalResized (int width, int height) { boundsInParent = boundsInParent.withSize (Size (width, height).to()); - resized(); + sendResized(); } //============================================================================== @@ -1408,7 +1537,7 @@ void Component::internalMoved (int xpos, int ypos) { boundsInParent = boundsInParent.withPosition (Point (xpos, ypos).to()); - moved(); + sendMoved(); } //============================================================================== diff --git a/modules/yup_gui/component/yup_Component.h b/modules/yup_gui/component/yup_Component.h index 938f2d04f..e421741a8 100644 --- a/modules/yup_gui/component/yup_Component.h +++ b/modules/yup_gui/component/yup_Component.h @@ -246,7 +246,18 @@ class YUP_API Component : public MouseListener */ void setBottomRight (const Point& newBottomRight); + /** + Get the center position of the component relative to its parent. + + @return The center position of the component relative to its parent. + */ Point getCenter() const; + + /** + Set the center position of the component relative to its parent. + + @param newCenter The new center position of the component relative to its parent. + */ void setCenter (const Point& newCenter); /** @@ -611,6 +622,19 @@ class YUP_API Component : public MouseListener */ bool isRenderingUnclipped() const; + //============================================================================== + /** Enables or disables paint measurement suppression for this component. + + When suppression is enabled, component paint listeners will not receive + paint measurement callbacks for this component, even if they request paint + measurements. + */ + void setPaintProfilingDisabled (bool shouldBeDisabled); + + /** Returns true if paint measurement is suppressed for this component. */ + bool isPaintProfilingDisabled() const; + + //============================================================================== /** Repaint the component. */ @@ -746,6 +770,32 @@ class YUP_API Component : public MouseListener */ void setWantsKeyboardFocus (bool wantsFocus); + /** + Check whether this component wants keyboard focus. + + @return True if this component wants keyboard focus, false otherwise. + */ + bool getWantsKeyboardFocus() const; + + /** + Set whether clicking this component can make it grab keyboard focus. + + When a component is clicked, focus handling walks from the clicked + component up through its parents until it finds a component that both + wants keyboard focus and has this flag enabled. This is enabled by + default. + + @param shouldGrabFocus True if mouse clicks can grab keyboard focus. + */ + void setClickingGrabFocus (bool shouldGrabFocus); + + /** + Check whether clicking this component can make it grab keyboard focus. + + @return True if mouse clicks can grab keyboard focus. + */ + bool getClickingGrabFocus() const; + /** Take the focus. */ @@ -1061,6 +1111,12 @@ class YUP_API Component : public MouseListener */ void removeMouseListener (MouseListener* listener); + /** Add a component listener to this component. */ + void addComponentListener (ComponentListener* listener); + + /** Remove a component listener from this component. */ + void removeComponentListener (ComponentListener* listener); + //============================================================================== /** Called when a key is pressed. @@ -1233,14 +1289,21 @@ class YUP_API Component : public MouseListener void internalAttachedToNative(); void internalDetachedFromNative(); + void handleKeyboardFocusFromClick(); + void updateMouseCursor(); + void sendMoved(); + void sendResized(); + bool hasOpaqueChildCoveringArea (const Rectangle& area); friend class ComponentNative; + friend class ComponentTestHelper; friend class SDL2ComponentNative; friend class WeakReference; + using ComponentListenerList = ListenerList>>; using MouseListenerList = ListenerList>>; String componentID, componentTitle; @@ -1251,6 +1314,7 @@ class YUP_API Component : public MouseListener ComponentNative::Ptr native; WeakReference::Master masterReference; MouseListenerList mouseListeners; + ComponentListenerList componentListeners; ComponentStyle::Ptr style; NamedValueSet properties; MouseCursor mouseCursor; @@ -1266,9 +1330,11 @@ class YUP_API Component : public MouseListener bool isTransparent : 1; bool unclippedRendering : 1; bool wantsKeyboardFocus : 1; + bool clickingDoesNotGrabFocus : 1; bool isRepainting : 1; bool blockSelfMouseEvents : 1; bool blockChildrenMouseEvents : 1; + bool paintProfilingDisabled : 1; }; union @@ -1277,7 +1343,7 @@ class YUP_API Component : public MouseListener Options options; }; -#if YUP_ENABLE_COMPONENT_REPAINT_DEBUGGING +#if YUP_ENABLE_COMPONENT_PAINT_DEBUGGING Color debugColor = Color::opaqueRandom(); int counter = 2; #endif diff --git a/modules/yup_gui/component/yup_ComponentListener.h b/modules/yup_gui/component/yup_ComponentListener.h new file mode 100644 index 000000000..dd7575b5c --- /dev/null +++ b/modules/yup_gui/component/yup_ComponentListener.h @@ -0,0 +1,70 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== + +class Component; + +//============================================================================== + +/** A base class for receiving component lifecycle and paint measurement events. */ +class YUP_API ComponentListener +{ +public: + /** Destructor. */ + virtual ~ComponentListener() = default; + + /** Called when a component's position changes. */ + virtual void componentMoved (Component& component) + { + ignoreUnused (component); + } + + /** Called when a component's size changes. */ + virtual void componentResized (Component& component) + { + ignoreUnused (component); + } + + /** Called when a component is about to be deleted. */ + virtual void componentBeingDeleted (Component& component) + { + ignoreUnused (component); + } + + /** Called after a component paint pass completes with measured paint data. + + Timing fields in measurement contain raw high-resolution tick counts. Listeners + that need wall-clock units are responsible for converting them. + */ + virtual void componentPaintCompleted (Component& component, const ComponentPaintMetrics& metrics) + { + ignoreUnused (component, metrics); + } + +private: + YUP_DECLARE_WEAK_REFERENCEABLE (ComponentListener) +}; + +} // namespace yup diff --git a/modules/yup_gui/component/yup_ComponentPaintMetrics.h b/modules/yup_gui/component/yup_ComponentPaintMetrics.h new file mode 100644 index 000000000..f8502a0da --- /dev/null +++ b/modules/yup_gui/component/yup_ComponentPaintMetrics.h @@ -0,0 +1,61 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== + +/** A single timing snapshot captured during one paint pass of a component. + + ComponentPaintMetrics captures the time spent in different phases of a component's paint + operation, as well as contextual information about the paint event (bounds, dirty region, + etc.). These snapshots are emitted to ComponentListeners after each paint pass, and are + aggregated into PaintProfileStats for longer-term storage and analysis. +*/ +struct ComponentPaintMetrics +{ + /** Ticks spent inside the component's own paint callback. */ + int64 selfTicks = 0; + + /** Ticks spent painting all children of this component. */ + int64 childrenTicks = 0; + + /** Ticks consumed by framework bookkeeping (transform setup, clip, etc.). */ + int64 frameworkTicks = 0; + + /** Total ticks from the start to the end of the full paint pass. */ + int64 totalTicks = 0; + + /** Axis-aligned bounding rectangle of the component in its parent's coordinate space. */ + Rectangle componentBounds; + + /** The dirty region that triggered this repaint, in the component's local coordinate space. */ + Rectangle repaintArea; + + /** True when the component requested a continuous repaint (e.g. animation loop). */ + bool renderContinuous = false; + + /** True when the component's own paint callback was skipped for this sample. */ + bool selfPaintSkipped = false; +}; + +} // namespace yup diff --git a/modules/yup_gui/native/yup_Windowing_sdl2.cpp b/modules/yup_gui/native/yup_Windowing_sdl2.cpp index 75a7a1255..ea04a68d5 100644 --- a/modules/yup_gui/native/yup_Windowing_sdl2.cpp +++ b/modules/yup_gui/native/yup_Windowing_sdl2.cpp @@ -736,17 +736,37 @@ void SDL2ComponentNative::renderContext() context->begin (frameDescriptor); } - // Repaint components hierarchy - if (renderer != nullptr) { - const auto dpiScale = getScaleDpi(); - - for (auto& repaintArea : currentRepaintAreas) + const auto repaintComponents = [&] + { + // Repaint components hierarchy + if (renderer != nullptr) + { + const auto dpiScale = getScaleDpi(); + + for (auto& repaintArea : currentRepaintAreas) + { + YUP_PROFILE_NAMED_INTERNAL_TRACE (InternalPaint); + + Graphics g (*context, *renderer, dpiScale); + component.internalPaint (g, repaintArea, renderContinuous); + } + } + }; + + if (PaintProfiler::hasRegisteredComponents() && PaintProfiler::getInstance().isEnabled()) { - YUP_PROFILE_NAMED_INTERNAL_TRACE (InternalPaint); + PaintProfiler::getInstance().beginFrame(); + const auto endFrameGuard = ErasedScopeGuard ([&] + { + PaintProfiler::getInstance().endFrame(); + }); - Graphics g (*context, *renderer, dpiScale); - component.internalPaint (g, repaintArea, renderContinuous); + repaintComponents(); + } + else + { + repaintComponents(); } } @@ -1422,6 +1442,8 @@ void SDL2ComponentNative::handleEvent (SDL_Event* event) case SDL_KEYDOWN: { + YUP_WINDOWING_LOG ("SDL_KEYDOWN " << event->key.keysym.sym << " " << event->key.keysym.scancode); + auto cursorPosition = getCursorPosition(); auto modifiers = toKeyModifiers (event->key.keysym.mod); @@ -1433,6 +1455,8 @@ void SDL2ComponentNative::handleEvent (SDL_Event* event) case SDL_KEYUP: { + YUP_WINDOWING_LOG ("SDL_KEYUP " << event->key.keysym.sym << " " << event->key.keysym.scancode); + auto cursorPosition = getCursorPosition(); auto modifiers = toKeyModifiers (event->key.keysym.mod); @@ -1444,7 +1468,7 @@ void SDL2ComponentNative::handleEvent (SDL_Event* event) case SDL_TEXTINPUT: { - YUP_WINDOWING_LOG ("SDL_TEXTINPUT"); + YUP_WINDOWING_LOG ("SDL_TEXTINPUT " << String::fromUTF8 (event->text.text)); // auto cursorPosition = getCursorPosition(); // auto modifiers = toKeyModifiers (getKeyModifiers()); diff --git a/modules/yup_gui/profiling/yup_PaintProfileSample.h b/modules/yup_gui/profiling/yup_PaintProfileSample.h new file mode 100644 index 000000000..14da91c38 --- /dev/null +++ b/modules/yup_gui/profiling/yup_PaintProfileSample.h @@ -0,0 +1,157 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== + +/** Identifies which time contribution to extract from a PaintProfileSample. */ +enum class PaintProfileTimeKind +{ + /** Time spent executing the component's own paint callback. */ + self, + + /** Time spent painting all direct and indirect children. */ + children, + + /** Time spent in framework bookkeeping around the paint call. */ + framework, + + /** Total elapsed time for the full paint pass of this component. */ + total +}; + +//============================================================================== + +/** Controls the behaviour and storage policy of a PaintProfileStats instance. */ +struct PaintProfileOptions +{ + /** Maximum number of samples retained in the ring buffer. */ + int sampleCapacity = 300; + + /** Samples whose totalMicros is below this threshold are discarded. + Set to 0.0 to record every sample. */ + double minimumSampleMicros = 0.0; + + /** When true, each sample stores the component bounds at paint time. */ + bool includeBounds = true; + + /** When true, each sample stores the dirty region that triggered the repaint. */ + bool includeRepaintArea = true; + + /** When true, samples are recorded even for components whose isVisible() returns false. */ + bool includeInvisibleComponents = false; + + /** When true, a sample is recorded even when the component's own paint was skipped + (e.g. because it has no paint implementation), so that child-only costs are tracked. */ + bool recordSkippedSelfPaint = true; +}; + +//============================================================================== + +/** A single timing snapshot captured during one paint pass of a component. */ +struct PaintProfileSample +{ + /** Monotonically increasing index of the display frame in which this sample was taken. */ + uint64 frameIndex = 0; + + /** Monotonically increasing index of the paint call within the current frame. */ + uint64 paintIndex = 0; + + /** Microseconds spent inside the component's own paint callback. */ + double selfMicros = 0; + + /** Microseconds spent painting all children of this component. */ + double childrenMicros = 0; + + /** Microseconds consumed by framework bookkeeping (transform setup, clip, etc.). */ + double frameworkMicros = 0; + + /** Total microseconds from the start to the end of the full paint pass. */ + double totalMicros = 0; + + /** Axis-aligned bounding rectangle of the component in its parent's coordinate space. */ + Rectangle componentBounds; + + /** The dirty region that triggered this repaint, in the component's local coordinate space. */ + Rectangle repaintArea; + + /** True when the component requested a continuous repaint (e.g. animation loop). */ + bool renderContinuous = false; + + /** True when the component's own paint callback was skipped for this sample. */ + bool selfPaintSkipped = false; +}; + +//============================================================================== + +/** Statistical summary computed over a collection of PaintProfileSample values + for a single PaintProfileTimeKind field. */ +struct PaintProfileSummary +{ + /** Number of samples included in this summary. */ + int sampleCount = 0; + + /** Value from the most recently recorded sample. */ + double lastMicros = 0.0; + + /** Minimum observed value across all samples. */ + double minMicros = 0.0; + + /** Maximum observed value across all samples. */ + double maxMicros = 0.0; + + /** Arithmetic mean of all sample values. */ + double meanMicros = 0.0; + + /** 50th-percentile (median) value. */ + double p50Micros = 0.0; + + /** 95th-percentile value. */ + double p95Micros = 0.0; + + /** 99th-percentile value. */ + double p99Micros = 0.0; +}; + +//============================================================================== + +/** A linear histogram of sample values for a single PaintProfileTimeKind field. + + The range [rangeMinMicros, rangeMaxMicros] is divided into buckets.size() equal-width + buckets. Each bucket stores the count of samples whose value falls within that interval. + Values above rangeMaxMicros are clamped into the last bucket. +*/ +struct PaintProfileHistogram +{ + /** Lower bound of the histogram range in microseconds (always 0.0). */ + double rangeMinMicros = 0.0; + + /** Upper bound of the histogram range in microseconds. */ + double rangeMaxMicros = 0.0; + + /** Per-bucket sample counts. buckets[i] covers the sub-range + [rangeMinMicros + i*width, rangeMinMicros + (i+1)*width). */ + std::vector buckets; +}; + +} // namespace yup diff --git a/modules/yup_gui/profiling/yup_PaintProfileStats.cpp b/modules/yup_gui/profiling/yup_PaintProfileStats.cpp new file mode 100644 index 000000000..6a7094fca --- /dev/null +++ b/modules/yup_gui/profiling/yup_PaintProfileStats.cpp @@ -0,0 +1,226 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== + +namespace +{ + +double extractTime (const PaintProfileSample& sample, PaintProfileTimeKind kind) +{ + switch (kind) + { + case PaintProfileTimeKind::self: + return sample.selfMicros; + case PaintProfileTimeKind::children: + return sample.childrenMicros; + case PaintProfileTimeKind::framework: + return sample.frameworkMicros; + case PaintProfileTimeKind::total: + return sample.totalMicros; + } + + return sample.totalMicros; +} + +double percentileFromSorted (const std::vector& sorted, double p) +{ + if (sorted.empty()) + return 0.0; + + const int n = static_cast (sorted.size()); + const int index = std::max (0, static_cast (std::ceil (p / 100.0 * n)) - 1); + return sorted[static_cast (index)]; +} + +} // namespace + +//============================================================================== + +PaintProfileStats::PaintProfileStats (PaintProfileOptions options) + : options (options) +{ + samples.resize (static_cast (options.sampleCapacity)); + nextSample = 0; + sampleCount = 0; +} + +//============================================================================== + +void PaintProfileStats::recordSample (const PaintProfileSample& sample) +{ + if (options.minimumSampleMicros > 0.0 && sample.totalMicros < options.minimumSampleMicros) + return; + + samples[static_cast (nextSample)] = sample; + nextSample = (nextSample + 1) % options.sampleCapacity; + sampleCount = std::min (sampleCount + 1, options.sampleCapacity); +} + +void PaintProfileStats::reset() +{ + nextSample = 0; + sampleCount = 0; +} + +//============================================================================== + +int PaintProfileStats::getSampleCount() const +{ + return sampleCount; +} + +int PaintProfileStats::getCapacity() const +{ + return options.sampleCapacity; +} + +PaintProfileOptions PaintProfileStats::getOptions() const +{ + return options; +} + +//============================================================================== + +std::vector PaintProfileStats::copySamples() const +{ + std::vector result; + result.reserve (static_cast (sampleCount)); + + if (sampleCount < options.sampleCapacity) + { + for (int i = 0; i < sampleCount; ++i) + result.push_back (samples[static_cast (i)]); + } + else + { + for (int i = 0; i < options.sampleCapacity; ++i) + { + const int index = (nextSample + i) % options.sampleCapacity; + result.push_back (samples[static_cast (index)]); + } + } + + return result; +} + +PaintProfileSample PaintProfileStats::getLastSample() const +{ + if (sampleCount == 0) + return {}; + + const int index = (nextSample - 1 + options.sampleCapacity) % options.sampleCapacity; + return samples[static_cast (index)]; +} + +//============================================================================== + +PaintProfileSummary PaintProfileStats::summarize (PaintProfileTimeKind kind) const +{ + const auto all = copySamples(); + + PaintProfileSummary summary; + summary.sampleCount = static_cast (all.size()); + + if (all.empty()) + return summary; + + std::vector values; + values.reserve (all.size()); + + double sum = 0.0; + double minVal = std::numeric_limits::max(); + double maxVal = std::numeric_limits::lowest(); + + for (const auto& s : all) + { + const double v = extractTime (s, kind); + values.push_back (v); + sum += v; + minVal = std::min (minVal, v); + maxVal = std::max (maxVal, v); + } + + summary.lastMicros = values.back(); + summary.minMicros = minVal; + summary.maxMicros = maxVal; + summary.meanMicros = sum / static_cast (values.size()); + + std::vector sorted = values; + std::sort (sorted.begin(), sorted.end()); + + summary.p50Micros = percentileFromSorted (sorted, 50.0); + summary.p95Micros = percentileFromSorted (sorted, 95.0); + summary.p99Micros = percentileFromSorted (sorted, 99.0); + + return summary; +} + +PaintProfileHistogram PaintProfileStats::createHistogram (PaintProfileTimeKind kind, int numBuckets) const +{ + jassert (numBuckets >= 1); + numBuckets = std::max (1, numBuckets); + + const auto all = copySamples(); + + PaintProfileHistogram histogram; + histogram.rangeMinMicros = 0.0; + histogram.buckets.assign (static_cast (numBuckets), 0); + + if (all.empty()) + { + histogram.rangeMaxMicros = 100.0; + return histogram; + } + + std::vector values; + values.reserve (all.size()); + + double maxObserved = 0.0; + for (const auto& s : all) + { + const double v = extractTime (s, kind); + values.push_back (v); + maxObserved = std::max (maxObserved, v); + } + + std::vector sorted = values; + std::sort (sorted.begin(), sorted.end()); + const double p99 = percentileFromSorted (sorted, 99.0); + + histogram.rangeMaxMicros = std::max ({ p99 * 1.25, maxObserved, 100.0 }); + + const double bucketWidth = histogram.rangeMaxMicros / static_cast (numBuckets); + + for (const double v : values) + { + int bucket = static_cast (v / bucketWidth); + bucket = std::min (bucket, numBuckets - 1); + ++histogram.buckets[static_cast (bucket)]; + } + + return histogram; +} + +} // namespace yup diff --git a/modules/yup_gui/profiling/yup_PaintProfileStats.h b/modules/yup_gui/profiling/yup_PaintProfileStats.h new file mode 100644 index 000000000..d6e52d092 --- /dev/null +++ b/modules/yup_gui/profiling/yup_PaintProfileStats.h @@ -0,0 +1,128 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== + +/** Collects and analyses PaintProfileSample records for a single component. + + Samples are stored in a fixed-capacity ring buffer whose size is determined by + PaintProfileOptions::sampleCapacity. Once full, the oldest sample is silently + overwritten by each new arrival. + + All query methods are const and operate on a snapshot of the ring buffer, so + they are safe to call from any thread as long as no concurrent writes are in + progress. If concurrent access is required, the caller is responsible for + external synchronisation. +*/ +class PaintProfileStats +{ +public: + //============================================================================== + + /** Constructs a PaintProfileStats with the given options. + + @param options Controls capacity, filtering thresholds, and recording behaviour. + Defaults to a PaintProfileOptions with sampleCapacity = 300. + */ + explicit PaintProfileStats (PaintProfileOptions options = {}); + + //============================================================================== + + /** Records a new paint sample. + + If PaintProfileOptions::minimumSampleMicros is greater than zero and + sample.totalMicros is below that threshold, the sample is silently discarded. + Otherwise the sample overwrites the oldest entry in the ring buffer. + + @param sample The sample to store. + */ + void recordSample (const PaintProfileSample& sample); + + /** Clears all stored samples and resets internal counters to zero. + + The configured PaintProfileOptions are preserved; only the sample data is cleared. + */ + void reset(); + + //============================================================================== + + /** Returns the number of valid samples currently stored (at most getCapacity()). */ + int getSampleCount() const; + + /** Returns the maximum number of samples that can be stored concurrently. */ + int getCapacity() const; + + /** Returns the PaintProfileOptions that were supplied at construction time. */ + PaintProfileOptions getOptions() const; + + //============================================================================== + + /** Returns a copy of all valid samples in chronological order (oldest first). + + If fewer samples than the capacity have been recorded, only the recorded + samples are returned. Once the buffer is full, iteration wraps correctly + around the ring so that chronological order is preserved. + + @returns A vector containing getSampleCount() samples. + */ + std::vector copySamples() const; + + /** Returns the most recently recorded sample. + + @returns The last sample, or a default-constructed PaintProfileSample if + no samples have been recorded yet. + */ + PaintProfileSample getLastSample() const; + + //============================================================================== + + /** Computes a statistical summary for the specified time field across all stored samples. + + @param kind Selects which time field (self, children, framework, or total) + is used when computing the statistics. + @returns A PaintProfileSummary populated with min, max, mean, and percentile values. + */ + PaintProfileSummary summarize (PaintProfileTimeKind kind) const; + + /** Builds a linear frequency histogram for the specified time field. + + The histogram range is derived automatically from the data: the upper bound is + max(p99 * 1.25, maxObserved, 100.0) microseconds, and the range is divided into + numBuckets equal-width buckets starting from 0. Values above the upper bound are + clamped into the last bucket. + + @param kind Selects which time field is histogrammed. + @param numBuckets Number of equal-width buckets to create. Must be >= 1. + @returns A PaintProfileHistogram with buckets.size() == numBuckets. + */ + PaintProfileHistogram createHistogram (PaintProfileTimeKind kind, int numBuckets) const; + +private: + std::vector samples; + PaintProfileOptions options; + int nextSample = 0; + int sampleCount = 0; +}; + +} // namespace yup diff --git a/modules/yup_gui/profiling/yup_PaintProfiler.cpp b/modules/yup_gui/profiling/yup_PaintProfiler.cpp new file mode 100644 index 000000000..25aeccdc4 --- /dev/null +++ b/modules/yup_gui/profiling/yup_PaintProfiler.cpp @@ -0,0 +1,493 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +namespace +{ + +double ticksToMicros (double ticks) +{ + return Time::highResolutionTicksToSeconds (static_cast (ticks)) * 1.0e6; +} + +String getComponentPaintProfileName (const Component& component) +{ + if (component.getTitle().isNotEmpty()) + return component.getTitle(); + + if (component.getComponentID().isNotEmpty()) + return component.getComponentID(); + + return "Component"; +} + +} // namespace + +//============================================================================== + +std::atomic PaintProfiler::registeredComponentCount { 0 }; + +//============================================================================== + +PaintProfiler::PaintProfiler() + : globalFrameStats (std::make_unique (PaintProfileOptions {})) +{ +} + +PaintProfiler::~PaintProfiler() = default; + +//============================================================================== + +PaintProfiler& PaintProfiler::getInstance() +{ + static PaintProfiler instance; + return instance; +} + +bool PaintProfiler::hasRegisteredComponents() noexcept +{ + return registeredComponentCount.load (std::memory_order_relaxed) > 0; +} + +void PaintProfiler::removeExpiredRegistryEntries() const +{ + const auto previousSize = registry.size(); + + std::erase_if (registry, [] (const auto& entry) + { + return entry.component.get() == nullptr; + }); + + const auto removedCount = previousSize - registry.size(); + + if (removedCount > 0) + registeredComponentCount.fetch_sub (static_cast (removedCount), std::memory_order_release); +} + +//============================================================================== + +void PaintProfiler::setEnabled (bool shouldBeEnabled) +{ + enabled.store (shouldBeEnabled, std::memory_order_release); + + const ScopedLock sl (registryLock); + + removeExpiredRegistryEntries(); + + for (auto& entry : registry) + { + auto* component = entry.component.get(); + + if (component == nullptr) + continue; + + if (shouldBeEnabled) + component->addComponentListener (this); + else + component->removeComponentListener (this); + } +} + +bool PaintProfiler::isEnabled() const +{ + return enabled.load (std::memory_order_acquire); +} + +bool PaintProfiler::isActive() const noexcept +{ + return isEnabled() && hasRegisteredComponents(); +} + +//============================================================================== + +void PaintProfiler::beginFrame() +{ + ++currentFrameIndex; + globalPaintIndex.store (0, std::memory_order_relaxed); + frameStartMicros = ticksToMicros (Time::getHighResolutionTicks()); +} + +void PaintProfiler::endFrame() +{ + const double endMicros = ticksToMicros (Time::getHighResolutionTicks()); + + PaintProfileSample sample; + sample.frameIndex = currentFrameIndex; + sample.totalMicros = endMicros - frameStartMicros; + globalFrameStats->recordSample (sample); +} + +//============================================================================== + +void PaintProfiler::enableComponent (Component& component, PaintProfileOptions options) +{ + bool registeredNewComponent = false; + + const ScopedLock sl (registryLock); + + removeExpiredRegistryEntries(); + + auto existing = std::find_if (registry.begin(), registry.end(), [&] (const auto& entry) + { + return entry.component.get() == &component; + }); + + if (existing != registry.end()) + { + if (existing->stats == nullptr || existing->stats->getCapacity() != options.sampleCapacity) + existing->stats = std::make_unique (options); + + if (isEnabled()) + component.addComponentListener (this); + + return; + } + + registry.push_back ({ WeakReference (&component), std::make_unique (options) }); + registeredNewComponent = true; + + if (isEnabled()) + component.addComponentListener (this); + + if (registeredNewComponent) + registeredComponentCount.fetch_add (1, std::memory_order_release); +} + +void PaintProfiler::disableComponent (Component& component) +{ + bool removedComponent = false; + + const ScopedLock sl (registryLock); + + removeExpiredRegistryEntries(); + + const auto previousSize = registry.size(); + + std::erase_if (registry, [&] (const auto& entry) + { + return entry.component.get() == &component; + }); + + removedComponent = registry.size() != previousSize; + + if (removedComponent) + component.removeComponentListener (this); + + if (removedComponent) + registeredComponentCount.fetch_sub (1, std::memory_order_release); +} + +bool PaintProfiler::isComponentEnabled (const Component& component) const +{ + const ScopedLock sl (registryLock); + + removeExpiredRegistryEntries(); + + return std::any_of (registry.begin(), registry.end(), [&] (const auto& entry) + { + return entry.component.get() == &component; + }); +} + +void PaintProfiler::resetComponent (Component& component) +{ + if (auto* stats = getStatsForComponent (component)) + stats->reset(); +} + +PaintProfileStats* PaintProfiler::getStatsForComponent (const Component& component) const +{ + const ScopedLock sl (registryLock); + + removeExpiredRegistryEntries(); + + auto found = std::find_if (registry.begin(), registry.end(), [&] (const auto& entry) + { + return entry.component.get() == &component; + }); + + return found != registry.end() ? found->stats.get() : nullptr; +} + +//============================================================================== + +void PaintProfiler::enableSubtree (Component& root, PaintProfileOptions options) +{ + enableComponent (root, options); + + for (int i = 0; i < root.getNumChildComponents(); ++i) + { + if (auto* child = root.getChildComponent (i)) + enableSubtree (*child, options); + } +} + +void PaintProfiler::disableSubtree (Component& root) +{ + disableComponent (root); + + for (int i = 0; i < root.getNumChildComponents(); ++i) + { + if (auto* child = root.getChildComponent (i)) + disableSubtree (*child); + } +} + +void PaintProfiler::resetSubtree (Component& root) +{ + { + const ScopedLock sl (registryLock); + + removeExpiredRegistryEntries(); + + for (auto& entry : registry) + { + if (entry.component.get() == &root) + { + entry.stats->reset(); + break; + } + } + } + + for (int i = 0; i < root.getNumChildComponents(); ++i) + { + if (auto* child = root.getChildComponent (i)) + resetSubtree (*child); + } +} + +void PaintProfiler::resetAll() +{ + const ScopedLock sl (registryLock); + + removeExpiredRegistryEntries(); + + for (auto& entry : registry) + entry.stats->reset(); + + globalFrameStats->reset(); +} + +//============================================================================== + +std::unique_ptr PaintProfiler::startSession (Component& root, PaintProfileOptions options) +{ + return std::unique_ptr (new ScopedSession (*this, root, options)); +} + +//============================================================================== + +PaintProfiler::Snapshot PaintProfiler::createSnapshot (PaintProfileTimeKind sortBy, int histogramBuckets) const +{ + std::vector> registryCopy; + + { + const ScopedLock sl (registryLock); + + removeExpiredRegistryEntries(); + + registryCopy.reserve (registry.size()); + for (auto& entry : registry) + { + if (auto* component = entry.component.get()) + registryCopy.emplace_back (component, entry.stats.get()); + } + } + + Snapshot snapshot; + snapshot.frameIndex = currentFrameIndex; + snapshot.components.reserve (registryCopy.size()); + + for (auto& [component, stats] : registryCopy) + { + ComponentEntry entry; + entry.component = component; + entry.name = getComponentPaintProfileName (*component); + entry.stats = stats; + entry.self = stats->summarize (PaintProfileTimeKind::self); + entry.children = stats->summarize (PaintProfileTimeKind::children); + entry.framework = stats->summarize (PaintProfileTimeKind::framework); + entry.total = stats->summarize (PaintProfileTimeKind::total); + + snapshot.components.push_back (std::move (entry)); + } + + std::sort (snapshot.components.begin(), snapshot.components.end(), [sortBy] (const ComponentEntry& a, const ComponentEntry& b) + { + auto getP95 = [sortBy] (const ComponentEntry& entry) -> double + { + switch (sortBy) + { + case PaintProfileTimeKind::self: + return entry.self.p95Micros; + + case PaintProfileTimeKind::children: + return entry.children.p95Micros; + + case PaintProfileTimeKind::framework: + return entry.framework.p95Micros; + + case PaintProfileTimeKind::total: + return entry.total.p95Micros; + + default: + return entry.total.p95Micros; + } + }; + + return getP95 (a) > getP95 (b); + }); + + snapshot.globalFrameTotal = globalFrameStats->summarize (PaintProfileTimeKind::total); + snapshot.globalFrameHistogram = globalFrameStats->createHistogram (PaintProfileTimeKind::total, histogramBuckets); + + return snapshot; +} + +PaintProfileHistogram PaintProfiler::createHistogramForComponent (const Component& component, + PaintProfileTimeKind kind, + int histogramBuckets) const +{ + PaintProfileStats* found = nullptr; + + { + const ScopedLock sl (registryLock); + + removeExpiredRegistryEntries(); + + for (auto& entry : registry) + { + if (entry.component.get() == &component) + { + found = entry.stats.get(); + break; + } + } + } + + if (found == nullptr) + return {}; + + return found->createHistogram (kind, histogramBuckets); +} + +void PaintProfiler::componentBeingDeleted (Component& component) +{ + bool removedComponent = false; + + const ScopedLock sl (registryLock); + + const auto previousSize = registry.size(); + + std::erase_if (registry, [&] (const auto& entry) + { + return entry.component.get() == &component; + }); + + removedComponent = registry.size() != previousSize; + + if (removedComponent) + registeredComponentCount.fetch_sub (1, std::memory_order_release); +} + +void PaintProfiler::componentPaintCompleted (Component& component, const ComponentPaintMetrics& metrics) +{ + if (! isEnabled()) + return; + + if (auto* stats = getStatsForComponent (component)) + { + PaintProfileSample sample; + sample.frameIndex = currentFrameIndex; + sample.paintIndex = globalPaintIndex.fetch_add (1, std::memory_order_relaxed); + sample.selfMicros = ticksToMicros (static_cast (metrics.selfTicks)); + sample.childrenMicros = ticksToMicros (static_cast (metrics.childrenTicks)); + sample.totalMicros = ticksToMicros (static_cast (metrics.totalTicks)); + sample.frameworkMicros = jmax (0.0, sample.totalMicros - sample.selfMicros - sample.childrenMicros); + sample.componentBounds = metrics.componentBounds; + sample.repaintArea = metrics.repaintArea; + sample.renderContinuous = metrics.renderContinuous; + sample.selfPaintSkipped = metrics.selfPaintSkipped; + stats->recordSample (sample); + } +} + +//============================================================================== + +PaintProfiler::ScopedSession::ScopedSession (PaintProfiler& profilerRef, + Component& rootComponent, + PaintProfileOptions sessionOptions) + : profiler (profilerRef) + , root (&rootComponent) + , options (sessionOptions) +{ + profiler.enableSubtree (rootComponent, sessionOptions); + + std::function collectComponents = [&] (Component& component) + { + enabledComponents.emplace_back (&component); + + for (int i = 0; i < component.getNumChildComponents(); ++i) + { + if (auto* child = component.getChildComponent (i)) + collectComponents (*child); + } + }; + + collectComponents (rootComponent); +} + +PaintProfiler::ScopedSession::~ScopedSession() +{ + for (auto& weakComponent : enabledComponents) + { + if (auto* component = weakComponent.get()) + profiler.disableComponent (*component); + } +} + +//============================================================================== + +void PaintProfiler::ScopedSession::setPaused (bool shouldBePaused) +{ + paused = shouldBePaused; +} + +bool PaintProfiler::ScopedSession::isPaused() const +{ + return paused; +} + +void PaintProfiler::ScopedSession::reset() +{ + if (auto* rootComponent = root.get()) + profiler.resetSubtree (*rootComponent); +} + +PaintProfiler::Snapshot PaintProfiler::ScopedSession::createSnapshot (PaintProfileTimeKind sortBy, int histogramBuckets) const +{ + return profiler.createSnapshot (sortBy, histogramBuckets); +} + +} // namespace yup diff --git a/modules/yup_gui/profiling/yup_PaintProfiler.h b/modules/yup_gui/profiling/yup_PaintProfiler.h new file mode 100644 index 000000000..748cdb8b6 --- /dev/null +++ b/modules/yup_gui/profiling/yup_PaintProfiler.h @@ -0,0 +1,282 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== + +/** Process-wide singleton that coordinates paint profiling across all components. + + PaintProfiler maintains a registry of components that have profiling enabled, + provides frame-level bookkeeping (beginFrame / endFrame), and exposes snapshot + and histogram queries over the collected data. + + The recommended entry point for callers is startSession(), which returns a + RAII ScopedSession handle that enables profiling on a component subtree and + automatically disables it when the handle is destroyed. +*/ +class PaintProfiler : private ComponentListener +{ +public: + //============================================================================== + + /** A record describing one profiled component within a snapshot. */ + struct ComponentEntry + { + /** Pointer to the profiled component (may be stale after the snapshot is taken). */ + WeakReference component; + + /** Display name of the component at snapshot time. */ + String name; + + /** Pointer to the stats object owned by PaintProfiler (may be stale). */ + PaintProfileStats* stats = nullptr; + + /** Statistical summary for the component's own paint time. */ + PaintProfileSummary self; + + /** Statistical summary for the time spent painting this component's children. */ + PaintProfileSummary children; + + /** Statistical summary for framework bookkeeping time. */ + PaintProfileSummary framework; + + /** Statistical summary for the total paint time. */ + PaintProfileSummary total; + }; + + //============================================================================== + + /** An immutable snapshot of all currently profiled components. */ + struct Snapshot + { + /** Monotonically increasing index of the frame at which this snapshot was taken. */ + uint64 frameIndex = 0; + + /** One entry per registered component, sorted by the requested PaintProfileTimeKind. */ + std::vector components; + + /** Summary of global per-frame timing derived from globalFrameStats. */ + PaintProfileSummary globalFrameTotal; + + /** Histogram of global per-frame timing. */ + PaintProfileHistogram globalFrameHistogram; + }; + + //============================================================================== + + /** + RAII handle for a profiling session over a component subtree. + + Created by PaintProfiler::startSession(). The destructor disables profiling + for the same components that were enabled at construction time. + */ + class ScopedSession + { + public: + /** Destructor disables profiling for all components enabled by this session. */ + ~ScopedSession(); + + /** Pauses or resumes sample recording for this session. + + When paused, the profiler global enabled state is not changed; instead + the session records the paused state so that reset() and createSnapshot() + reflect a quiescent view. + + @param shouldBePaused Pass true to pause, false to resume. + */ + void setPaused (bool shouldBePaused); + + /** Returns true if this session is currently paused. */ + bool isPaused() const; + + /** Clears all samples collected by this session's components. */ + void reset(); + + /** Creates an immutable snapshot of the current profiling state. + + @param sortBy Which time kind to use when sorting components in the snapshot. + @param histogramBuckets Number of buckets to use for the global frame histogram. + @returns An immutable Snapshot of the current profiling state. + */ + Snapshot createSnapshot (PaintProfileTimeKind sortBy = PaintProfileTimeKind::total, + int histogramBuckets = 32) const; + + private: + friend class PaintProfiler; + + ScopedSession (PaintProfiler& profiler, Component& root, PaintProfileOptions options); + + PaintProfiler& profiler; + WeakReference root; + PaintProfileOptions options; + std::vector> enabledComponents; + bool paused = false; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ScopedSession) + }; + + //============================================================================== + + /** Returns the process-wide singleton instance. */ + static PaintProfiler& getInstance(); + + /** Returns true if any component is currently registered for paint profiling. + + This is a cheap process-wide check intended for hot paths that need to avoid + constructing the PaintProfiler singleton unless there is profiling work to do. + */ + static bool hasRegisteredComponents() noexcept; + + //============================================================================== + + /** Enables or disables global profiling. + + When disabled, no new samples are recorded but all per-component state and + the registry are preserved. + + @param shouldBeEnabled Pass true to enable, false to disable. + */ + void setEnabled (bool shouldBeEnabled); + + /** Returns true when global profiling is enabled. */ + bool isEnabled() const; + + /** Returns true when at least one registered component can collect samples. */ + bool isActive() const noexcept; + + //============================================================================== + + /** Enables profiling on the subtree rooted at root and returns an RAII handle. + + The handle's destructor will disable profiling for each component that was + enabled by this call, regardless of the current global enabled state. + + @param root The root component of the subtree to profile. + @param options Options used for each profiler-owned PaintProfileStats. + @returns A unique_ptr to a ScopedSession that disables profiling on destruction. + */ + std::unique_ptr startSession (Component& root, PaintProfileOptions options = {}); + + /** Enables profiling for a single component. */ + void enableComponent (Component& component, PaintProfileOptions options = {}); + + /** Disables profiling for a single component. */ + void disableComponent (Component& component); + + /** Returns true if profiling is enabled for a single component. */ + bool isComponentEnabled (const Component& component) const; + + /** Clears all paint profile samples recorded for a single component. */ + void resetComponent (Component& component); + + /** Returns profiler-owned stats for a single component, or nullptr if disabled. */ + PaintProfileStats* getStatsForComponent (const Component& component) const; + + /** Recursively enables profiling for root and all its current children. + + @param root The root component of the subtree. + @param options Options used for each profiler-owned PaintProfileStats. + */ + void enableSubtree (Component& root, PaintProfileOptions options = {}); + + /** Recursively disables profiling for root and all its current children. + + @param root The root component of the subtree. + */ + void disableSubtree (Component& root); + + /** Recursively resets all samples for root and all its current children. + + @param root The root component of the subtree. + */ + void resetSubtree (Component& root); + + /** Resets all samples for every registered component. */ + void resetAll(); + + //============================================================================== + + /** Creates an immutable snapshot of all registered components. + + @param sortBy Which time kind determines the sort order (descending by p95). + @param histogramBuckets Number of buckets for the global frame histogram. + @returns An immutable Snapshot populated with all currently registered components. + */ + Snapshot createSnapshot (PaintProfileTimeKind sortBy = PaintProfileTimeKind::total, + int histogramBuckets = 32) const; + + /** Creates a histogram for a specific component. + + @param component The component whose samples should be histogrammed. + @param kind Which time field to histogram. + @param histogramBuckets Number of equal-width buckets. + @returns A PaintProfileHistogram, or a default-constructed one if the component + is not registered. + */ + PaintProfileHistogram createHistogramForComponent (const Component& component, + PaintProfileTimeKind kind, + int histogramBuckets = 32) const; + + //============================================================================== + + /** Called by the native renderer before processing repaint areas. + + Increments the frame counter and resets the per-frame paint index. + */ + void beginFrame(); + + /** Called by the native renderer after all repaint areas have been processed. */ + void endFrame(); + + /** Returns the current frame index for use in sample construction. */ + uint64 getCurrentFrameIndex() const noexcept { return currentFrameIndex; } + +private: + PaintProfiler(); + ~PaintProfiler(); + + void componentBeingDeleted (Component& component) override; + void componentPaintCompleted (Component& component, const ComponentPaintMetrics& metrics) override; + + void removeExpiredRegistryEntries() const; + + struct RegistryEntry + { + WeakReference component; + std::unique_ptr stats; + }; + + mutable CriticalSection registryLock; + mutable std::vector registry; + + std::unique_ptr globalFrameStats; + uint64 currentFrameIndex = 0; + double frameStartMicros = 0.0; + std::atomic globalPaintIndex { 0 }; + std::atomic enabled { true }; + static std::atomic registeredComponentCount; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (PaintProfiler) +}; + +} // namespace yup diff --git a/modules/yup_gui/yup_gui.cpp b/modules/yup_gui/yup_gui.cpp index 060aa6c53..455387792 100644 --- a/modules/yup_gui/yup_gui.cpp +++ b/modules/yup_gui/yup_gui.cpp @@ -125,6 +125,11 @@ //============================================================================== +#include "profiling/yup_PaintProfileStats.cpp" +#include "profiling/yup_PaintProfiler.cpp" + +//============================================================================== + #include "application/yup_Application.cpp" #include "desktop/yup_Desktop.cpp" #include "keyboard/yup_TextInputTarget.cpp" diff --git a/modules/yup_gui/yup_gui.h b/modules/yup_gui/yup_gui.h index 9a2de2d1b..f132fd527 100644 --- a/modules/yup_gui/yup_gui.h +++ b/modules/yup_gui/yup_gui.h @@ -52,12 +52,12 @@ #include //============================================================================== -/** Config: YUP_ENABLE_COMPONENT_REPAINT_DEBUGGING +/** Config: YUP_ENABLE_COMPONENT_PAINT_DEBUGGING Enable repaint debugging for components. */ -#ifndef YUP_ENABLE_COMPONENT_REPAINT_DEBUGGING -#define YUP_ENABLE_COMPONENT_REPAINT_DEBUGGING 0 +#ifndef YUP_ENABLE_COMPONENT_PAINT_DEBUGGING +#define YUP_ENABLE_COMPONENT_PAINT_DEBUGGING 0 #endif //============================================================================== @@ -71,11 +71,17 @@ //============================================================================== +#include #include #include //============================================================================== +#include "profiling/yup_PaintProfileSample.h" +#include "profiling/yup_PaintProfileStats.h" + +//============================================================================== + #include "application/yup_Application.h" #include "keyboard/yup_KeyModifiers.h" #include "keyboard/yup_KeyPress.h" @@ -89,6 +95,8 @@ #include "desktop/yup_Desktop.h" #include "component/yup_ComponentNative.h" #include "component/yup_ComponentStyle.h" +#include "component/yup_ComponentPaintMetrics.h" +#include "component/yup_ComponentListener.h" #include "component/yup_Component.h" #include "menus/yup_PopupMenu.h" #include "buttons/yup_Button.h" @@ -111,6 +119,10 @@ //============================================================================== +#include "profiling/yup_PaintProfiler.h" + +//============================================================================== + #include "native/yup_WindowingHelpers.h" //============================================================================== diff --git a/modules/yup_python/bindings/yup_YupGui_bindings.cpp b/modules/yup_python/bindings/yup_YupGui_bindings.cpp index e1ac2df3f..87d03bd0c 100644 --- a/modules/yup_python/bindings/yup_YupGui_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupGui_bindings.cpp @@ -353,6 +353,9 @@ void registerYupGuiBindings (py::module_& m) // Keyboard focus .def ("setWantsKeyboardFocus", &Component::setWantsKeyboardFocus) + .def ("getWantsKeyboardFocus", &Component::getWantsKeyboardFocus) + .def ("setClickingGrabFocus", &Component::setClickingGrabFocus) + .def ("getClickingGrabFocus", &Component::getClickingGrabFocus) .def ("takeKeyboardFocus", &Component::takeKeyboardFocus) .def ("leaveKeyboardFocus", &Component::leaveKeyboardFocus) .def ("hasKeyboardFocus", &Component::hasKeyboardFocus) diff --git a/tests/yup_gui.cpp b/tests/yup_gui.cpp index 5467ea7a0..78c0b8b96 100644 --- a/tests/yup_gui.cpp +++ b/tests/yup_gui.cpp @@ -26,6 +26,7 @@ #include "yup_gui/yup_FileChooser.cpp" #include "yup_gui/yup_Label.cpp" #include "yup_gui/yup_ListBox.cpp" +#include "yup_gui/yup_PaintProfiler.cpp" #include "yup_gui/yup_PopupMenu.cpp" #include "yup_gui/yup_ProgressBar.cpp" #include "yup_gui/yup_ScrollBar.cpp" diff --git a/tests/yup_gui/yup_Component.cpp b/tests/yup_gui/yup_Component.cpp index 374ebb6ea..9b7d6ccfd 100644 --- a/tests/yup_gui/yup_Component.cpp +++ b/tests/yup_gui/yup_Component.cpp @@ -167,6 +167,35 @@ class ComponentMock : public Component void transformChanged() override { transformChangedCalled = true; } }; +class RecordingComponentListener : public ComponentListener +{ +public: + void componentMoved (Component& component) override + { + ++movedCount; + lastMovedComponent = &component; + } + + void componentResized (Component& component) override + { + ++resizedCount; + lastResizedComponent = &component; + } + + void componentBeingDeleted (Component& component) override + { + ++deletedCount; + lastDeletedComponent = &component; + } + + int movedCount = 0; + int resizedCount = 0; + int deletedCount = 0; + Component* lastMovedComponent = nullptr; + Component* lastResizedComponent = nullptr; + Component* lastDeletedComponent = nullptr; +}; + } // namespace // ============================================================================= @@ -813,7 +842,6 @@ TEST_F (ComponentTest, HitTesting) // ============================================================================= -/* TEST_F (ComponentTest, KeyboardFocus) { // Test default focus behavior @@ -825,7 +853,17 @@ TEST_F (ComponentTest, KeyboardFocus) child->setWantsKeyboardFocus (false); EXPECT_FALSE (child->getWantsKeyboardFocus()); } -*/ + +TEST_F (ComponentTest, ClickingGrabFocus) +{ + EXPECT_TRUE (child->getClickingGrabFocus()); + + child->setClickingGrabFocus (false); + EXPECT_FALSE (child->getClickingGrabFocus()); + + child->setClickingGrabFocus (true); + EXPECT_TRUE (child->getClickingGrabFocus()); +} // ============================================================================= @@ -1155,6 +1193,103 @@ TEST_F (ComponentMockTest, MouseListenerMethods) mockComponent->removeMouseListener (listener.get()); } +TEST_F (ComponentMockTest, ComponentListenerReceivesMovedCallback) +{ + RecordingComponentListener listener; + mockComponent->addComponentListener (&listener); + + mockComponent->setPosition ({ 10.0f, 20.0f }); + + EXPECT_EQ (1, listener.movedCount); + EXPECT_EQ (static_cast (mockComponent.get()), listener.lastMovedComponent); + EXPECT_EQ (0, listener.resizedCount); +} + +TEST_F (ComponentMockTest, ComponentListenerReceivesResizedCallback) +{ + RecordingComponentListener listener; + mockComponent->addComponentListener (&listener); + + mockComponent->setSize ({ 100.0f, 80.0f }); + + EXPECT_EQ (1, listener.resizedCount); + EXPECT_EQ (static_cast (mockComponent.get()), listener.lastResizedComponent); + EXPECT_EQ (0, listener.movedCount); +} + +TEST_F (ComponentMockTest, ComponentListenerReceivesMovedAndResizedFromSetBounds) +{ + RecordingComponentListener listener; + mockComponent->addComponentListener (&listener); + + mockComponent->setBounds (5.0f, 10.0f, 150.0f, 120.0f); + + EXPECT_EQ (1, listener.movedCount); + EXPECT_EQ (1, listener.resizedCount); + EXPECT_EQ (static_cast (mockComponent.get()), listener.lastMovedComponent); + EXPECT_EQ (static_cast (mockComponent.get()), listener.lastResizedComponent); +} + +TEST_F (ComponentMockTest, RemovingComponentListenerStopsCallbacks) +{ + RecordingComponentListener listener; + mockComponent->addComponentListener (&listener); + mockComponent->removeComponentListener (&listener); + + mockComponent->setPosition ({ 10.0f, 20.0f }); + mockComponent->setSize ({ 100.0f, 80.0f }); + + EXPECT_EQ (0, listener.movedCount); + EXPECT_EQ (0, listener.resizedCount); +} + +TEST_F (ComponentMockTest, ComponentListenerIsOnlyAddedOnce) +{ + RecordingComponentListener listener; + mockComponent->addComponentListener (&listener); + mockComponent->addComponentListener (&listener); + + mockComponent->setPosition ({ 10.0f, 20.0f }); + + EXPECT_EQ (1, listener.movedCount); +} + +TEST_F (ComponentMockTest, DestroyedComponentListenerIsSkipped) +{ + auto listener = std::make_unique(); + mockComponent->addComponentListener (listener.get()); + + listener.reset(); + + const Point newPosition (10.0f, 20.0f); + EXPECT_NO_FATAL_FAILURE (mockComponent->setPosition (newPosition)); +} + +TEST_F (ComponentMockTest, ComponentListenerReceivesBeingDeletedCallback) +{ + RecordingComponentListener listener; + auto component = std::make_unique ("delete-notified"); + component->addComponentListener (&listener); + + auto* componentAddress = static_cast (component.get()); + component.reset(); + + EXPECT_EQ (1, listener.deletedCount); + EXPECT_EQ (componentAddress, listener.lastDeletedComponent); +} + +TEST_F (ComponentMockTest, RemovedComponentListenerDoesNotReceiveBeingDeletedCallback) +{ + RecordingComponentListener listener; + auto component = std::make_unique ("delete-notified"); + component->addComponentListener (&listener); + component->removeComponentListener (&listener); + + component.reset(); + + EXPECT_EQ (0, listener.deletedCount); +} + TEST_F (ComponentMockTest, StyleMethods) { // Test default style diff --git a/tests/yup_gui/yup_PaintProfileStats.cpp b/tests/yup_gui/yup_PaintProfileStats.cpp new file mode 100644 index 000000000..301b9f16b --- /dev/null +++ b/tests/yup_gui/yup_PaintProfileStats.cpp @@ -0,0 +1,447 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +namespace +{ + +PaintProfileSample makeSample (double totalMicros, + double selfMicros = 0.0, + double childrenMicros = 0.0) +{ + PaintProfileSample s; + s.totalMicros = totalMicros; + s.selfMicros = selfMicros; + s.childrenMicros = childrenMicros; + s.frameworkMicros = totalMicros - selfMicros - childrenMicros; + return s; +} + +} // namespace + +TEST (PaintProfileStatsTests, InitialStateIsEmpty) +{ + PaintProfileStats stats; + EXPECT_EQ (0, stats.getSampleCount()); + EXPECT_EQ (300, stats.getCapacity()); + EXPECT_EQ (0, stats.getLastSample().totalMicros); +} + +TEST (PaintProfileStatsTests, CapacityFromOptions) +{ + PaintProfileOptions opts; + opts.sampleCapacity = 50; + PaintProfileStats stats (opts); + EXPECT_EQ (50, stats.getCapacity()); +} + +TEST (PaintProfileStatsTests, OptionsPreserved) +{ + PaintProfileOptions opts; + opts.sampleCapacity = 100; + opts.minimumSampleMicros = 5.0; + opts.includeBounds = false; + PaintProfileStats stats (opts); + auto stored = stats.getOptions(); + EXPECT_EQ (100, stored.sampleCapacity); + EXPECT_DOUBLE_EQ (5.0, stored.minimumSampleMicros); + EXPECT_FALSE (stored.includeBounds); +} + +TEST (PaintProfileStatsTests, RecordingBelowCapacity) +{ + PaintProfileStats stats; + stats.recordSample (makeSample (10.0)); + stats.recordSample (makeSample (20.0)); + stats.recordSample (makeSample (30.0)); + + EXPECT_EQ (3, stats.getSampleCount()); + + auto samples = stats.copySamples(); + ASSERT_EQ (3u, samples.size()); + EXPECT_DOUBLE_EQ (10.0, samples[0].totalMicros); + EXPECT_DOUBLE_EQ (20.0, samples[1].totalMicros); + EXPECT_DOUBLE_EQ (30.0, samples[2].totalMicros); +} + +TEST (PaintProfileStatsTests, LastSampleReturnsNewest) +{ + PaintProfileStats stats; + stats.recordSample (makeSample (10.0)); + stats.recordSample (makeSample (20.0)); + stats.recordSample (makeSample (30.0)); + + EXPECT_DOUBLE_EQ (30.0, stats.getLastSample().totalMicros); +} + +TEST (PaintProfileStatsTests, RingBufferWrapsAtCapacity) +{ + PaintProfileOptions opts; + opts.sampleCapacity = 5; + PaintProfileStats stats (opts); + + for (int i = 1; i <= 10; ++i) + stats.recordSample (makeSample (static_cast (i) * 10.0)); + + EXPECT_EQ (5, stats.getSampleCount()); + + auto samples = stats.copySamples(); + ASSERT_EQ (5u, samples.size()); + EXPECT_DOUBLE_EQ (60.0, samples[0].totalMicros); + EXPECT_DOUBLE_EQ (70.0, samples[1].totalMicros); + EXPECT_DOUBLE_EQ (80.0, samples[2].totalMicros); + EXPECT_DOUBLE_EQ (90.0, samples[3].totalMicros); + EXPECT_DOUBLE_EQ (100.0, samples[4].totalMicros); +} + +TEST (PaintProfileStatsTests, LastSampleAfterWrapping) +{ + PaintProfileOptions opts; + opts.sampleCapacity = 3; + PaintProfileStats stats (opts); + + stats.recordSample (makeSample (10.0)); + stats.recordSample (makeSample (20.0)); + stats.recordSample (makeSample (30.0)); + stats.recordSample (makeSample (40.0)); + stats.recordSample (makeSample (50.0)); + + EXPECT_DOUBLE_EQ (50.0, stats.getLastSample().totalMicros); +} + +TEST (PaintProfileStatsTests, ResetClearsSamples) +{ + PaintProfileStats stats; + stats.recordSample (makeSample (10.0)); + stats.recordSample (makeSample (20.0)); + stats.recordSample (makeSample (30.0)); + + stats.reset(); + + EXPECT_EQ (0, stats.getSampleCount()); + EXPECT_EQ (300, stats.getCapacity()); + EXPECT_EQ (0, stats.getLastSample().totalMicros); +} + +TEST (PaintProfileStatsTests, ResetDoesNotChangeCapacity) +{ + PaintProfileOptions opts; + opts.sampleCapacity = 50; + PaintProfileStats stats (opts); + stats.recordSample (makeSample (10.0)); + + stats.reset(); + + EXPECT_EQ (50, stats.getCapacity()); +} + +TEST (PaintProfileStatsTests, MinimumSampleThresholdFilters) +{ + PaintProfileOptions opts; + opts.minimumSampleMicros = 100.0; + PaintProfileStats stats (opts); + + stats.recordSample (makeSample (50.0)); + EXPECT_EQ (0, stats.getSampleCount()); + + stats.recordSample (makeSample (100.0)); + EXPECT_EQ (1, stats.getSampleCount()); + + stats.recordSample (makeSample (200.0)); + EXPECT_EQ (2, stats.getSampleCount()); + + stats.recordSample (makeSample (99.9)); + EXPECT_EQ (2, stats.getSampleCount()); +} + +TEST (PaintProfileStatsTests, SummarizeEmptyStatsReturnsZeros) +{ + PaintProfileStats stats; + + auto summary = stats.summarize (PaintProfileTimeKind::total); + + EXPECT_EQ (0, summary.sampleCount); + EXPECT_DOUBLE_EQ (0.0, summary.lastMicros); + EXPECT_DOUBLE_EQ (0.0, summary.minMicros); + EXPECT_DOUBLE_EQ (0.0, summary.maxMicros); + EXPECT_DOUBLE_EQ (0.0, summary.meanMicros); + EXPECT_DOUBLE_EQ (0.0, summary.p50Micros); + EXPECT_DOUBLE_EQ (0.0, summary.p95Micros); + EXPECT_DOUBLE_EQ (0.0, summary.p99Micros); +} + +TEST (PaintProfileStatsTests, SummarizeTotalMicros) +{ + PaintProfileStats stats; + + stats.recordSample (makeSample (10.0)); + stats.recordSample (makeSample (20.0)); + stats.recordSample (makeSample (30.0)); + stats.recordSample (makeSample (40.0)); + stats.recordSample (makeSample (50.0)); + + auto summary = stats.summarize (PaintProfileTimeKind::total); + + EXPECT_EQ (5, summary.sampleCount); + EXPECT_DOUBLE_EQ (50.0, summary.lastMicros); + EXPECT_DOUBLE_EQ (10.0, summary.minMicros); + EXPECT_DOUBLE_EQ (50.0, summary.maxMicros); + EXPECT_DOUBLE_EQ (30.0, summary.meanMicros); + EXPECT_DOUBLE_EQ (30.0, summary.p50Micros); + EXPECT_DOUBLE_EQ (50.0, summary.p95Micros); + EXPECT_DOUBLE_EQ (50.0, summary.p99Micros); +} + +TEST (PaintProfileStatsTests, SummarizeSelfMicros) +{ + PaintProfileStats stats; + + stats.recordSample (makeSample (100.0, 10.0, 50.0)); + stats.recordSample (makeSample (100.0, 20.0, 50.0)); + stats.recordSample (makeSample (100.0, 30.0, 50.0)); + stats.recordSample (makeSample (100.0, 40.0, 50.0)); + stats.recordSample (makeSample (100.0, 50.0, 50.0)); + + auto summary = stats.summarize (PaintProfileTimeKind::self); + + EXPECT_EQ (5, summary.sampleCount); + EXPECT_DOUBLE_EQ (50.0, summary.lastMicros); + EXPECT_DOUBLE_EQ (10.0, summary.minMicros); + EXPECT_DOUBLE_EQ (50.0, summary.maxMicros); + EXPECT_DOUBLE_EQ (30.0, summary.meanMicros); +} + +TEST (PaintProfileStatsTests, SummarizeChildrenMicros) +{ + PaintProfileStats stats; + + stats.recordSample (makeSample (100.0, 40.0, 10.0)); + stats.recordSample (makeSample (100.0, 40.0, 20.0)); + stats.recordSample (makeSample (100.0, 40.0, 30.0)); + stats.recordSample (makeSample (100.0, 40.0, 40.0)); + stats.recordSample (makeSample (100.0, 40.0, 50.0)); + + auto summary = stats.summarize (PaintProfileTimeKind::children); + + EXPECT_EQ (5, summary.sampleCount); + EXPECT_DOUBLE_EQ (50.0, summary.lastMicros); + EXPECT_DOUBLE_EQ (10.0, summary.minMicros); + EXPECT_DOUBLE_EQ (50.0, summary.maxMicros); + EXPECT_DOUBLE_EQ (30.0, summary.meanMicros); +} + +TEST (PaintProfileStatsTests, SummarizeFrameworkMicros) +{ + PaintProfileStats stats; + + stats.recordSample (makeSample (100.0, 50.0, 50.0)); + stats.recordSample (makeSample (100.0, 50.0, 40.0)); + stats.recordSample (makeSample (100.0, 50.0, 30.0)); + stats.recordSample (makeSample (100.0, 50.0, 20.0)); + stats.recordSample (makeSample (100.0, 50.0, 10.0)); + + auto summary = stats.summarize (PaintProfileTimeKind::framework); + + EXPECT_EQ (5, summary.sampleCount); + EXPECT_DOUBLE_EQ (40.0, summary.lastMicros); + EXPECT_DOUBLE_EQ (0.0, summary.minMicros); + EXPECT_DOUBLE_EQ (40.0, summary.maxMicros); +} + +TEST (PaintProfileStatsTests, CreateHistogramEmpty) +{ + PaintProfileStats stats; + + auto histogram = stats.createHistogram (PaintProfileTimeKind::total, 10); + + EXPECT_DOUBLE_EQ (0.0, histogram.rangeMinMicros); + EXPECT_DOUBLE_EQ (100.0, histogram.rangeMaxMicros); + ASSERT_EQ (10u, histogram.buckets.size()); + for (int count : histogram.buckets) + EXPECT_EQ (0, count); +} + +TEST (PaintProfileStatsTests, CreateHistogramCoversSamples) +{ + PaintProfileStats stats; + + stats.recordSample (makeSample (10.0)); + stats.recordSample (makeSample (20.0)); + stats.recordSample (makeSample (30.0)); + stats.recordSample (makeSample (40.0)); + stats.recordSample (makeSample (50.0)); + stats.recordSample (makeSample (60.0)); + stats.recordSample (makeSample (70.0)); + stats.recordSample (makeSample (80.0)); + stats.recordSample (makeSample (90.0)); + stats.recordSample (makeSample (100.0)); + + auto histogram = stats.createHistogram (PaintProfileTimeKind::total, 10); + + EXPECT_DOUBLE_EQ (0.0, histogram.rangeMinMicros); + EXPECT_GT (histogram.rangeMaxMicros, 0.0); + ASSERT_EQ (10u, histogram.buckets.size()); + + int totalCount = 0; + for (int count : histogram.buckets) + totalCount += count; + EXPECT_EQ (10, totalCount); +} + +TEST (PaintProfileStatsTests, CreateHistogramWithSelfMicros) +{ + PaintProfileStats stats; + + stats.recordSample (makeSample (100.0, 10.0, 40.0)); + stats.recordSample (makeSample (100.0, 20.0, 40.0)); + stats.recordSample (makeSample (100.0, 30.0, 40.0)); + stats.recordSample (makeSample (100.0, 40.0, 40.0)); + stats.recordSample (makeSample (100.0, 50.0, 40.0)); + + auto histogram = stats.createHistogram (PaintProfileTimeKind::self, 5); + + ASSERT_EQ (5u, histogram.buckets.size()); + int totalCount = 0; + for (int count : histogram.buckets) + totalCount += count; + EXPECT_EQ (5, totalCount); +} + +TEST (PaintProfileStatsTests, CopySamplesOrderAfterWrapping) +{ + PaintProfileOptions opts; + opts.sampleCapacity = 3; + PaintProfileStats stats (opts); + + stats.recordSample (makeSample (10.0)); + stats.recordSample (makeSample (20.0)); + stats.recordSample (makeSample (30.0)); + stats.recordSample (makeSample (40.0)); + stats.recordSample (makeSample (50.0)); + stats.recordSample (makeSample (60.0)); + + auto samples = stats.copySamples(); + + ASSERT_EQ (3u, samples.size()); + EXPECT_DOUBLE_EQ (40.0, samples[0].totalMicros); + EXPECT_DOUBLE_EQ (50.0, samples[1].totalMicros); + EXPECT_DOUBLE_EQ (60.0, samples[2].totalMicros); +} + +TEST (PaintProfileStatsTests, SingleSampleStatistics) +{ + PaintProfileStats stats; + + stats.recordSample (makeSample (42.0)); + + auto summary = stats.summarize (PaintProfileTimeKind::total); + + EXPECT_EQ (1, summary.sampleCount); + EXPECT_DOUBLE_EQ (42.0, summary.lastMicros); + EXPECT_DOUBLE_EQ (42.0, summary.minMicros); + EXPECT_DOUBLE_EQ (42.0, summary.maxMicros); + EXPECT_DOUBLE_EQ (42.0, summary.meanMicros); + EXPECT_DOUBLE_EQ (42.0, summary.p50Micros); + EXPECT_DOUBLE_EQ (42.0, summary.p95Micros); + EXPECT_DOUBLE_EQ (42.0, summary.p99Micros); +} + +TEST (PaintProfileStatsTests, ResetAfterWrapping) +{ + PaintProfileOptions opts; + opts.sampleCapacity = 2; + PaintProfileStats stats (opts); + + stats.recordSample (makeSample (10.0)); + stats.recordSample (makeSample (20.0)); + stats.recordSample (makeSample (30.0)); + + stats.reset(); + + EXPECT_EQ (0, stats.getSampleCount()); + auto samples = stats.copySamples(); + EXPECT_TRUE (samples.empty()); +} + +TEST (PaintProfileStatsTests, MinimumThresholdZeroRecordsAll) +{ + PaintProfileOptions opts; + opts.minimumSampleMicros = 0.0; + PaintProfileStats stats (opts); + + stats.recordSample (makeSample (0.0)); + stats.recordSample (makeSample (0.001)); + stats.recordSample (makeSample (1000.0)); + + EXPECT_EQ (3, stats.getSampleCount()); +} + +TEST (PaintProfileStatsTests, HistogramBucketCountMatches) +{ + PaintProfileStats stats; + + for (int i = 1; i <= 50; ++i) + stats.recordSample (makeSample (static_cast (i))); + + auto hist5 = stats.createHistogram (PaintProfileTimeKind::total, 5); + auto hist20 = stats.createHistogram (PaintProfileTimeKind::total, 20); + + EXPECT_EQ (5u, hist5.buckets.size()); + EXPECT_EQ (20u, hist20.buckets.size()); +} + +TEST (PaintProfileStatsTests, SummarizePercentileOrdering) +{ + PaintProfileStats stats; + + stats.recordSample (makeSample (10.0)); + stats.recordSample (makeSample (20.0)); + stats.recordSample (makeSample (30.0)); + stats.recordSample (makeSample (40.0)); + stats.recordSample (makeSample (50.0)); + + auto summary = stats.summarize (PaintProfileTimeKind::total); + + EXPECT_LE (summary.minMicros, summary.p50Micros); + EXPECT_LE (summary.p50Micros, summary.p95Micros); + EXPECT_LE (summary.p95Micros, summary.p99Micros); + EXPECT_LE (summary.p99Micros, summary.maxMicros); +} + +TEST (PaintProfileStatsTests, CopySamplesDoesNotModifyState) +{ + PaintProfileStats stats; + + stats.recordSample (makeSample (10.0)); + stats.recordSample (makeSample (20.0)); + + auto first = stats.copySamples(); + auto second = stats.copySamples(); + + ASSERT_EQ (first.size(), second.size()); + for (size_t i = 0; i < first.size(); ++i) + EXPECT_DOUBLE_EQ (first[i].totalMicros, second[i].totalMicros); +} diff --git a/tests/yup_gui/yup_PaintProfiler.cpp b/tests/yup_gui/yup_PaintProfiler.cpp new file mode 100644 index 000000000..5d544083c --- /dev/null +++ b/tests/yup_gui/yup_PaintProfiler.cpp @@ -0,0 +1,834 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +namespace +{ + +PaintProfileSample makeSample (double totalMicros, + double selfMicros = 0.0, + double childrenMicros = 0.0) +{ + PaintProfileSample s; + s.totalMicros = totalMicros; + s.selfMicros = selfMicros; + s.childrenMicros = childrenMicros; + s.frameworkMicros = totalMicros - selfMicros - childrenMicros; + return s; +} + +} // namespace + +// ============================================================================= +// Component profiling API tests +// ============================================================================= + +TEST (ComponentProfilingTests, ProfilingDisabledByDefault) +{ + Component comp; + + EXPECT_FALSE (PaintProfiler::getInstance().isComponentEnabled (comp)); + EXPECT_EQ (nullptr, PaintProfiler::getInstance().getStatsForComponent (comp)); +} + +TEST (ComponentProfilingTests, EnableProfilingCreatesStats) +{ + Component comp; + PaintProfiler::getInstance().enableComponent (comp); + + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (comp)); + EXPECT_NE (nullptr, PaintProfiler::getInstance().getStatsForComponent (comp)); +} + +TEST (ComponentProfilingTests, DisableProfilingClearsStats) +{ + Component comp; + PaintProfiler::getInstance().enableComponent (comp); + PaintProfiler::getInstance().disableComponent (comp); + + EXPECT_FALSE (PaintProfiler::getInstance().isComponentEnabled (comp)); + EXPECT_EQ (nullptr, PaintProfiler::getInstance().getStatsForComponent (comp)); +} + +TEST (ComponentProfilingTests, ReEnableProfilingAfterDisable) +{ + Component comp; + PaintProfiler::getInstance().enableComponent (comp); + PaintProfiler::getInstance().disableComponent (comp); + PaintProfiler::getInstance().enableComponent (comp); + + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (comp)); + EXPECT_NE (nullptr, PaintProfiler::getInstance().getStatsForComponent (comp)); +} + +TEST (ComponentProfilingTests, ResetProfilingClearsSamples) +{ + Component comp; + PaintProfiler::getInstance().enableComponent (comp); + + auto* stats = PaintProfiler::getInstance().getStatsForComponent (comp); + stats->recordSample (makeSample (10.0)); + stats->recordSample (makeSample (20.0)); + ASSERT_EQ (2, stats->getSampleCount()); + + PaintProfiler::getInstance().resetComponent (comp); + + EXPECT_EQ (0, PaintProfiler::getInstance().getStatsForComponent (comp)->getSampleCount()); + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (comp)); +} + +TEST (ComponentProfilingTests, ResetProfilingOnDisabledComponentIsNoOp) +{ + Component comp; + + EXPECT_NO_FATAL_FAILURE (PaintProfiler::getInstance().resetComponent (comp)); + EXPECT_EQ (nullptr, PaintProfiler::getInstance().getStatsForComponent (comp)); +} + +TEST (ComponentProfilingTests, OptionsPreservedOnEnable) +{ + PaintProfileOptions opts; + opts.sampleCapacity = 50; + opts.minimumSampleMicros = 5.0; + opts.includeBounds = false; + + Component comp; + PaintProfiler::getInstance().enableComponent (comp, opts); + + auto* stats = PaintProfiler::getInstance().getStatsForComponent (comp); + ASSERT_NE (nullptr, stats); + + auto stored = stats->getOptions(); + EXPECT_EQ (50, stored.sampleCapacity); + EXPECT_DOUBLE_EQ (5.0, stored.minimumSampleMicros); + EXPECT_FALSE (stored.includeBounds); +} + +TEST (ComponentProfilingTests, GetPaintProfileNameFromTitle) +{ + auto& profiler = PaintProfiler::getInstance(); + Component comp; + comp.setTitle ("MyComponent"); + profiler.enableComponent (comp); + + auto snapshot = profiler.createSnapshot(); + + ASSERT_EQ (1u, snapshot.components.size()); + EXPECT_EQ ("MyComponent", snapshot.components[0].name); +} + +TEST (ComponentProfilingTests, GetPaintProfileNameFromID) +{ + auto& profiler = PaintProfiler::getInstance(); + Component comp ("myID"); + profiler.enableComponent (comp); + + auto snapshot = profiler.createSnapshot(); + + ASSERT_EQ (1u, snapshot.components.size()); + EXPECT_EQ ("myID", snapshot.components[0].name); +} + +TEST (ComponentProfilingTests, GetPaintProfileNameFallback) +{ + auto& profiler = PaintProfiler::getInstance(); + Component comp; + profiler.enableComponent (comp); + + auto snapshot = profiler.createSnapshot(); + + ASSERT_EQ (1u, snapshot.components.size()); + EXPECT_EQ ("Component", snapshot.components[0].name); +} + +TEST (ComponentProfilingTests, GetPaintProfileNamePrefersTitle) +{ + auto& profiler = PaintProfiler::getInstance(); + Component comp ("myID"); + comp.setTitle ("MyTitle"); + profiler.enableComponent (comp); + + auto snapshot = profiler.createSnapshot(); + + ASSERT_EQ (1u, snapshot.components.size()); + EXPECT_EQ ("MyTitle", snapshot.components[0].name); +} + +TEST (ComponentProfilingTests, PaintProfilingCanBeDisabledPerComponent) +{ + Component comp; + + EXPECT_FALSE (comp.isPaintProfilingDisabled()); + + comp.setPaintProfilingDisabled (true); + EXPECT_TRUE (comp.isPaintProfilingDisabled()); + + comp.setPaintProfilingDisabled (false); + EXPECT_FALSE (comp.isPaintProfilingDisabled()); +} + +// ============================================================================= +// PaintProfiler singleton tests +// ============================================================================= + +class PaintProfilerFixture : public ::testing::Test +{ +protected: + void SetUp() override + { + profiler = &PaintProfiler::getInstance(); + profiler->setEnabled (true); + profiler->resetAll(); + } + + PaintProfiler* profiler = nullptr; +}; + +TEST_F (PaintProfilerFixture, SingletonReturnsSameInstance) +{ + EXPECT_EQ (&PaintProfiler::getInstance(), &PaintProfiler::getInstance()); +} + +TEST_F (PaintProfilerFixture, IsEnabledAfterSetupReset) +{ + EXPECT_TRUE (profiler->isEnabled()); +} + +TEST_F (PaintProfilerFixture, SetEnabledFalseDisables) +{ + profiler->setEnabled (false); + + EXPECT_FALSE (profiler->isEnabled()); +} + +TEST_F (PaintProfilerFixture, SetEnabledTrueRenables) +{ + profiler->setEnabled (false); + profiler->setEnabled (true); + + EXPECT_TRUE (profiler->isEnabled()); +} + +TEST_F (PaintProfilerFixture, EnableSubtreeEnablesRoot) +{ + Component root; + + profiler->enableSubtree (root); + + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (root)); + EXPECT_NE (nullptr, PaintProfiler::getInstance().getStatsForComponent (root)); +} + +TEST_F (PaintProfilerFixture, EnableSubtreeEnablesChildren) +{ + Component root; + Component child; + root.addChildComponent (child); + + profiler->enableSubtree (root); + + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (root)); + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (child)); +} + +TEST_F (PaintProfilerFixture, EnableSubtreeEnablesDeepHierarchy) +{ + Component root; + Component mid; + Component leaf; + root.addChildComponent (mid); + mid.addChildComponent (leaf); + + profiler->enableSubtree (root); + + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (root)); + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (mid)); + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (leaf)); +} + +TEST_F (PaintProfilerFixture, DeletedEnabledComponentIsRemovedFromRegistry) +{ + auto comp = std::make_unique(); + profiler->enableComponent (*comp); + + EXPECT_TRUE (PaintProfiler::hasRegisteredComponents()); + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (*comp)); + + comp.reset(); + + EXPECT_FALSE (PaintProfiler::hasRegisteredComponents()); + EXPECT_TRUE (profiler->createSnapshot().components.empty()); +} + +TEST_F (PaintProfilerFixture, DeletedComponentWhileProfilerDisabledIsPrunedFromRegistry) +{ + profiler->setEnabled (false); + + auto comp = std::make_unique(); + profiler->enableComponent (*comp); + + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (*comp)); + + comp.reset(); + profiler->setEnabled (true); + + Component addressReuseProbe; + EXPECT_FALSE (PaintProfiler::getInstance().isComponentEnabled (addressReuseProbe)); + EXPECT_TRUE (profiler->createSnapshot().components.empty()); + EXPECT_FALSE (PaintProfiler::hasRegisteredComponents()); +} + +TEST_F (PaintProfilerFixture, DisableSubtreeDisablesRootAndChildren) +{ + Component root; + Component child; + root.addChildComponent (child); + + profiler->enableSubtree (root); + profiler->disableSubtree (root); + + EXPECT_FALSE (PaintProfiler::getInstance().isComponentEnabled (root)); + EXPECT_FALSE (PaintProfiler::getInstance().isComponentEnabled (child)); +} + +TEST_F (PaintProfilerFixture, ResetSubtreeResetsRootStats) +{ + Component root; + profiler->enableSubtree (root); + + auto* stats = PaintProfiler::getInstance().getStatsForComponent (root); + stats->recordSample (makeSample (10.0)); + stats->recordSample (makeSample (20.0)); + ASSERT_EQ (2, stats->getSampleCount()); + + profiler->resetSubtree (root); + + EXPECT_EQ (0, PaintProfiler::getInstance().getStatsForComponent (root)->getSampleCount()); +} + +TEST_F (PaintProfilerFixture, ResetSubtreeResetsChildStats) +{ + Component root; + Component child; + root.addChildComponent (child); + profiler->enableSubtree (root); + + PaintProfiler::getInstance().getStatsForComponent (child)->recordSample (makeSample (15.0)); + ASSERT_EQ (1, PaintProfiler::getInstance().getStatsForComponent (child)->getSampleCount()); + + profiler->resetSubtree (root); + + EXPECT_EQ (0, PaintProfiler::getInstance().getStatsForComponent (child)->getSampleCount()); +} + +TEST_F (PaintProfilerFixture, ResetAllClearsAllRegisteredStats) +{ + Component comp1; + Component comp2; + profiler->enableSubtree (comp1); + profiler->enableSubtree (comp2); + + PaintProfiler::getInstance().getStatsForComponent (comp1)->recordSample (makeSample (10.0)); + PaintProfiler::getInstance().getStatsForComponent (comp2)->recordSample (makeSample (20.0)); + + profiler->resetAll(); + + EXPECT_EQ (0, PaintProfiler::getInstance().getStatsForComponent (comp1)->getSampleCount()); + EXPECT_EQ (0, PaintProfiler::getInstance().getStatsForComponent (comp2)->getSampleCount()); +} + +TEST_F (PaintProfilerFixture, SnapshotEmptyWithNoRegisteredComponents) +{ + auto snapshot = profiler->createSnapshot(); + + EXPECT_TRUE (snapshot.components.empty()); +} + +TEST_F (PaintProfilerFixture, SnapshotContainsRegisteredComponents) +{ + Component comp1 ("comp1"); + Component comp2 ("comp2"); + profiler->enableSubtree (comp1); + profiler->enableSubtree (comp2); + + auto snapshot = profiler->createSnapshot(); + + EXPECT_EQ (2u, snapshot.components.size()); +} + +TEST_F (PaintProfilerFixture, SnapshotHasComponentNames) +{ + Component comp ("named"); + profiler->enableSubtree (comp); + + auto snapshot = profiler->createSnapshot(); + + ASSERT_EQ (1u, snapshot.components.size()); + EXPECT_EQ ("named", snapshot.components[0].name); +} + +TEST_F (PaintProfilerFixture, SnapshotSortedByP95TotalDescending) +{ + Component slow; + Component fast; + profiler->enableSubtree (slow); + profiler->enableSubtree (fast); + + for (int i = 0; i < 5; ++i) + PaintProfiler::getInstance().getStatsForComponent (slow)->recordSample (makeSample (100.0)); + + for (int i = 0; i < 5; ++i) + PaintProfiler::getInstance().getStatsForComponent (fast)->recordSample (makeSample (10.0)); + + auto snapshot = profiler->createSnapshot (PaintProfileTimeKind::total); + + ASSERT_EQ (2u, snapshot.components.size()); + EXPECT_GE (snapshot.components[0].total.p95Micros, snapshot.components[1].total.p95Micros); +} + +TEST_F (PaintProfilerFixture, SnapshotSortedByP95SelfDescending) +{ + Component highSelf; + Component lowSelf; + profiler->enableSubtree (highSelf); + profiler->enableSubtree (lowSelf); + + for (int i = 0; i < 5; ++i) + PaintProfiler::getInstance().getStatsForComponent (highSelf)->recordSample (makeSample (100.0, 80.0, 10.0)); + + for (int i = 0; i < 5; ++i) + PaintProfiler::getInstance().getStatsForComponent (lowSelf)->recordSample (makeSample (100.0, 10.0, 80.0)); + + auto snapshot = profiler->createSnapshot (PaintProfileTimeKind::self); + + ASSERT_EQ (2u, snapshot.components.size()); + EXPECT_GE (snapshot.components[0].self.p95Micros, snapshot.components[1].self.p95Micros); +} + +TEST_F (PaintProfilerFixture, SnapshotSortedByP95ChildrenDescending) +{ + Component highChildren; + Component lowChildren; + profiler->enableSubtree (highChildren); + profiler->enableSubtree (lowChildren); + + for (int i = 0; i < 5; ++i) + PaintProfiler::getInstance().getStatsForComponent (highChildren)->recordSample (makeSample (100.0, 10.0, 80.0)); + + for (int i = 0; i < 5; ++i) + PaintProfiler::getInstance().getStatsForComponent (lowChildren)->recordSample (makeSample (100.0, 80.0, 10.0)); + + auto snapshot = profiler->createSnapshot (PaintProfileTimeKind::children); + + ASSERT_EQ (2u, snapshot.components.size()); + EXPECT_GE (snapshot.components[0].children.p95Micros, snapshot.components[1].children.p95Micros); +} + +TEST_F (PaintProfilerFixture, SnapshotContainsComponentPointers) +{ + Component comp; + profiler->enableSubtree (comp); + + auto snapshot = profiler->createSnapshot(); + + ASSERT_EQ (1u, snapshot.components.size()); + EXPECT_EQ (&comp, snapshot.components[0].component); +} + +TEST_F (PaintProfilerFixture, SnapshotHasFrameIndex) +{ + profiler->beginFrame(); + uint64 expectedFrameIndex = profiler->getCurrentFrameIndex(); + + Component comp; + profiler->enableSubtree (comp); + + auto snapshot = profiler->createSnapshot(); + + EXPECT_EQ (expectedFrameIndex, snapshot.frameIndex); +} + +TEST_F (PaintProfilerFixture, HistogramForUnregisteredComponentIsDefault) +{ + Component comp; + + auto histogram = profiler->createHistogramForComponent (comp, PaintProfileTimeKind::total, 10); + + EXPECT_DOUBLE_EQ (0.0, histogram.rangeMinMicros); + EXPECT_DOUBLE_EQ (0.0, histogram.rangeMaxMicros); + EXPECT_TRUE (histogram.buckets.empty()); +} + +TEST_F (PaintProfilerFixture, HistogramForRegisteredComponentContainsAllSamples) +{ + Component comp; + profiler->enableSubtree (comp); + + auto* stats = PaintProfiler::getInstance().getStatsForComponent (comp); + for (int i = 1; i <= 5; ++i) + stats->recordSample (makeSample (static_cast (i) * 10.0)); + + auto histogram = profiler->createHistogramForComponent (comp, PaintProfileTimeKind::total, 5); + + ASSERT_EQ (5u, histogram.buckets.size()); + + int total = 0; + for (int count : histogram.buckets) + total += count; + + EXPECT_EQ (5, total); +} + +TEST_F (PaintProfilerFixture, StartSessionEnablesRootAndChildren) +{ + Component root; + Component child; + root.addChildComponent (child); + + auto session = profiler->startSession (root); + + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (root)); + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (child)); +} + +TEST_F (PaintProfilerFixture, ScopedSessionDestructorDisablesProfiling) +{ + Component root; + + { + auto session = profiler->startSession (root); + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (root)); + } + + EXPECT_FALSE (PaintProfiler::getInstance().isComponentEnabled (root)); +} + +TEST_F (PaintProfilerFixture, ScopedSessionDestructorDisablesChildren) +{ + Component root; + Component child; + root.addChildComponent (child); + + { + auto session = profiler->startSession (root); + EXPECT_TRUE (PaintProfiler::getInstance().isComponentEnabled (child)); + } + + EXPECT_FALSE (PaintProfiler::getInstance().isComponentEnabled (child)); +} + +TEST_F (PaintProfilerFixture, ScopedSessionInitiallyNotPaused) +{ + Component root; + auto session = profiler->startSession (root); + + EXPECT_FALSE (session->isPaused()); +} + +TEST_F (PaintProfilerFixture, ScopedSessionPauseAndResume) +{ + Component root; + auto session = profiler->startSession (root); + + session->setPaused (true); + EXPECT_TRUE (session->isPaused()); + + session->setPaused (false); + EXPECT_FALSE (session->isPaused()); +} + +TEST_F (PaintProfilerFixture, ScopedSessionResetClearsStats) +{ + Component root; + auto session = profiler->startSession (root); + + auto* stats = PaintProfiler::getInstance().getStatsForComponent (root); + stats->recordSample (makeSample (10.0)); + stats->recordSample (makeSample (20.0)); + ASSERT_EQ (2, stats->getSampleCount()); + + session->reset(); + + EXPECT_EQ (0, PaintProfiler::getInstance().getStatsForComponent (root)->getSampleCount()); +} + +TEST_F (PaintProfilerFixture, ScopedSessionCreateSnapshotReflectsRegisteredComponents) +{ + Component root ("root"); + auto session = profiler->startSession (root); + + auto snapshot = session->createSnapshot(); + + ASSERT_EQ (1u, snapshot.components.size()); + EXPECT_EQ ("root", snapshot.components[0].name); +} + +TEST_F (PaintProfilerFixture, BeginFrameIncrementsFrameIndex) +{ + uint64 before = profiler->getCurrentFrameIndex(); + profiler->beginFrame(); + uint64 after = profiler->getCurrentFrameIndex(); + + EXPECT_EQ (before + 1, after); +} + +TEST_F (PaintProfilerFixture, EndFrameRecordsGlobalFrameSample) +{ + profiler->beginFrame(); + profiler->endFrame(); + + auto snapshot = profiler->createSnapshot(); + + EXPECT_EQ (1, snapshot.globalFrameTotal.sampleCount); + EXPECT_GE (snapshot.globalFrameTotal.lastMicros, 0.0); +} + +TEST_F (PaintProfilerFixture, MultipleFramesAccumulateGlobalStats) +{ + constexpr int frameCount = 5; + + for (int i = 0; i < frameCount; ++i) + { + profiler->beginFrame(); + profiler->endFrame(); + } + + auto snapshot = profiler->createSnapshot(); + + EXPECT_EQ (frameCount, snapshot.globalFrameTotal.sampleCount); +} + +// ============================================================================= +// Paint profiling integration tests — exercises Component::internalPaint +// ============================================================================= + +namespace yup +{ + +class ComponentTestHelper +{ +public: + static void triggerPaint (Component& comp, + Graphics& g, + const Rectangle& repaintArea, + bool renderContinuous = false) + { + comp.internalPaint (g, repaintArea, renderContinuous); + } +}; + +} // namespace yup + +namespace +{ + +class PaintableComponent : public yup::Component +{ +public: + PaintableComponent() = default; + + void paint (yup::Graphics&) override {} +}; + +} // namespace + +class ComponentPaintProfilingFixture : public ::testing::Test +{ +protected: + void SetUp() override + { + GraphicsContext::Options opts; + opts.allowHeadlessRendering = true; + context = GraphicsContext::createContext (GraphicsContext::Api::Headless, opts); + renderer = context->makeRenderer (200, 200); + + profiler = &PaintProfiler::getInstance(); + profiler->setEnabled (true); + profiler->resetAll(); + } + + void TearDown() override + { + profiler->setEnabled (false); + profiler->resetAll(); + } + + std::unique_ptr context; + std::unique_ptr renderer; + PaintProfiler* profiler = nullptr; +}; + +TEST_F (ComponentPaintProfilingFixture, RecordsSampleAfterPaint) +{ + PaintableComponent comp; + comp.setBounds (0, 0, 100, 100); + comp.setVisible (true); + PaintProfiler::getInstance().enableComponent (comp); + + Graphics g (*context, *renderer, 1.0f); + ComponentTestHelper::triggerPaint (comp, g, comp.getBounds()); + + EXPECT_EQ (1, PaintProfiler::getInstance().getStatsForComponent (comp)->getSampleCount()); +} + +TEST_F (ComponentPaintProfilingFixture, NoSampleWhenProfilerDisabled) +{ + profiler->setEnabled (false); + + PaintableComponent comp; + comp.setBounds (0, 0, 100, 100); + comp.setVisible (true); + PaintProfiler::getInstance().enableComponent (comp); + + Graphics g (*context, *renderer, 1.0f); + ComponentTestHelper::triggerPaint (comp, g, comp.getBounds()); + + EXPECT_EQ (0, PaintProfiler::getInstance().getStatsForComponent (comp)->getSampleCount()); +} + +TEST_F (ComponentPaintProfilingFixture, NoSampleWhenComponentPaintProfilingDisabled) +{ + PaintableComponent comp; + comp.setBounds (0, 0, 100, 100); + comp.setVisible (true); + comp.setPaintProfilingDisabled (true); + PaintProfiler::getInstance().enableComponent (comp); + + Graphics g (*context, *renderer, 1.0f); + ComponentTestHelper::triggerPaint (comp, g, comp.getBounds()); + + EXPECT_EQ (0, PaintProfiler::getInstance().getStatsForComponent (comp)->getSampleCount()); +} + +TEST_F (ComponentPaintProfilingFixture, NoStatsWhenComponentProfilingNotEnabled) +{ + PaintableComponent comp; + comp.setBounds (0, 0, 100, 100); + comp.setVisible (true); + + Graphics g (*context, *renderer, 1.0f); + ComponentTestHelper::triggerPaint (comp, g, comp.getBounds()); + + EXPECT_EQ (nullptr, PaintProfiler::getInstance().getStatsForComponent (comp)); +} + +TEST_F (ComponentPaintProfilingFixture, SampleHasNonNegativeTotalTime) +{ + PaintableComponent comp; + comp.setBounds (0, 0, 100, 100); + comp.setVisible (true); + PaintProfiler::getInstance().enableComponent (comp); + + Graphics g (*context, *renderer, 1.0f); + ComponentTestHelper::triggerPaint (comp, g, comp.getBounds()); + + auto* stats = PaintProfiler::getInstance().getStatsForComponent (comp); + ASSERT_EQ (1, stats->getSampleCount()); + EXPECT_GE (stats->getLastSample().totalMicros, 0.0); +} + +TEST_F (ComponentPaintProfilingFixture, SelfPaintSkippedWhenOpaqueChildCoversArea) +{ + PaintableComponent parent; + parent.setBounds (0, 0, 100, 100); + parent.setVisible (true); + parent.setOpaque (true); + PaintProfiler::getInstance().enableComponent (parent); + + PaintableComponent child; + child.setBounds (0, 0, 100, 100); + child.setVisible (true); + child.setOpaque (true); + parent.addChildComponent (child); + + Graphics g (*context, *renderer, 1.0f); + ComponentTestHelper::triggerPaint (parent, g, parent.getBounds()); + + auto* stats = PaintProfiler::getInstance().getStatsForComponent (parent); + ASSERT_EQ (1, stats->getSampleCount()); + EXPECT_TRUE (stats->getLastSample().selfPaintSkipped); +} + +TEST_F (ComponentPaintProfilingFixture, ChildTimeContributesToParentChildrenMicros) +{ + PaintableComponent parent; + parent.setBounds (0, 0, 100, 100); + parent.setVisible (true); + PaintProfiler::getInstance().enableComponent (parent); + + PaintableComponent child; + child.setBounds (0, 0, 50, 50); + child.setVisible (true); + PaintProfiler::getInstance().enableComponent (child); + parent.addChildComponent (child); + + Graphics g (*context, *renderer, 1.0f); + ComponentTestHelper::triggerPaint (parent, g, parent.getBounds()); + + auto* parentStats = PaintProfiler::getInstance().getStatsForComponent (parent); + ASSERT_EQ (1, parentStats->getSampleCount()); + EXPECT_GE (parentStats->getLastSample().childrenMicros, 0.0); + EXPECT_GE (parentStats->getLastSample().totalMicros, parentStats->getLastSample().childrenMicros); +} + +TEST_F (ComponentPaintProfilingFixture, UnprofiledChildTimeContributesToProfiledParentChildrenMicros) +{ + PaintableComponent parent; + parent.setBounds (0, 0, 100, 100); + parent.setVisible (true); + PaintProfiler::getInstance().enableComponent (parent); + + PaintableComponent child; + child.setBounds (0, 0, 50, 50); + child.setVisible (true); + parent.addChildComponent (child); + + Graphics g (*context, *renderer, 1.0f); + ComponentTestHelper::triggerPaint (parent, g, parent.getBounds()); + + auto* parentStats = PaintProfiler::getInstance().getStatsForComponent (parent); + ASSERT_EQ (1, parentStats->getSampleCount()); + EXPECT_EQ (nullptr, PaintProfiler::getInstance().getStatsForComponent (child)); + EXPECT_GE (parentStats->getLastSample().childrenMicros, 0.0); + EXPECT_GE (parentStats->getLastSample().totalMicros, parentStats->getLastSample().childrenMicros); +} + +TEST_F (ComponentPaintProfilingFixture, MultiplePaintsAccumulateSamples) +{ + PaintableComponent comp; + comp.setBounds (0, 0, 100, 100); + comp.setVisible (true); + PaintProfiler::getInstance().enableComponent (comp); + + constexpr int paintCount = 5; + for (int i = 0; i < paintCount; ++i) + { + Graphics g (*context, *renderer, 1.0f); + ComponentTestHelper::triggerPaint (comp, g, comp.getBounds()); + } + + EXPECT_EQ (paintCount, PaintProfiler::getInstance().getStatsForComponent (comp)->getSampleCount()); +}