From e2e58595f4310dd17ab22e1b3da784a74962965f Mon Sep 17 00:00:00 2001 From: Profiler Team Date: Mon, 15 Dec 2025 22:25:21 -0800 Subject: [PATCH] [XProf: trace viewer] Pre-calculate visible level offsets in Timeline. This change introduces a `visible_level_offsets_` vector to store the y-offset for each level that is visually rendered. This allows for different rendering heights for different group types, such as flame charts and counters. The `CalculateOffsets` function now populates both group and visible level offsets. The event drawing logic is updated to use these pre-calculated offsets for positioning events. This will be used in the navigate to event feature. There is no visual change after this CL PiperOrigin-RevId: 845084690 --- .../components/trace_viewer_v2/application.cc | 12 +- .../trace_viewer_v2/timeline/data_provider.cc | 4 +- .../trace_viewer_v2/timeline/timeline.cc | 201 ++++++-- .../trace_viewer_v2/timeline/timeline.h | 43 +- .../trace_viewer_v2/timeline/timeline_test.cc | 450 ++++++++++++++++-- 5 files changed, 621 insertions(+), 89 deletions(-) diff --git a/frontend/app/components/trace_viewer_v2/application.cc b/frontend/app/components/trace_viewer_v2/application.cc index 31d41050..6ad1faf2 100644 --- a/frontend/app/components/trace_viewer_v2/application.cc +++ b/frontend/app/components/trace_viewer_v2/application.cc @@ -7,6 +7,7 @@ #include #include +#include #include "absl/time/clock.h" #include "absl/time/time.h" @@ -27,6 +28,15 @@ const char* const kWindowTarget = EMSCRIPTEN_EVENT_TARGET_WINDOW; const char* const kCanvasTarget = "#canvas"; constexpr float kScrollbarSize = 10.0f; +void ApplyDefaultTraceViewerStyles() { + ImGuiStyle& style = ImGui::GetStyle(); + style.ScrollbarSize = kScrollbarSize; + style.WindowRounding = 0.0f; + style.WindowPadding = ImVec2(0.0f, 0.0f); + style.CellPadding = ImVec2(0.0f, 0.0f); + style.ItemSpacing = ImVec2(0.0f, 0.0f); +} + void ApplyLightTheme() { ImGui::StyleColorsLight(); ImGuiStyle& style = ImGui::GetStyle(); @@ -57,7 +67,7 @@ void Application::Initialize() { fonts::LoadFonts(initial_canvas_state.device_pixel_ratio()); // TODO: b/450584482 - Add a dark theme for the timeline. ApplyLightTheme(); - ImGui::GetStyle().ScrollbarSize = kScrollbarSize; + ApplyDefaultTraceViewerStyles(); // Initialize the platform platform_ = std::make_unique(); diff --git a/frontend/app/components/trace_viewer_v2/timeline/data_provider.cc b/frontend/app/components/trace_viewer_v2/timeline/data_provider.cc index df6ecc67..cde51735 100644 --- a/frontend/app/components/trace_viewer_v2/timeline/data_provider.cc +++ b/frontend/app/components/trace_viewer_v2/timeline/data_provider.cc @@ -343,7 +343,7 @@ void DataProvider::ProcessTraceEvents(const ParsedTraceEvents& parsed_events, Timeline& timeline) { if (parsed_events.flame_events.empty() && parsed_events.counter_events.empty()) { - timeline.set_timeline_data({}); + timeline.SetTimelineData({}); timeline.set_data_time_range(TimeRange::Zero()); timeline.SetVisibleRange(TimeRange::Zero()); return; @@ -414,7 +414,7 @@ void DataProvider::ProcessTraceEvents(const ParsedTraceEvents& parsed_events, } } - timeline.set_timeline_data(CreateTimelineData(trace_info, time_bounds)); + timeline.SetTimelineData(CreateTimelineData(trace_info, time_bounds)); // Don't need to check for max_time because the TimeRange constructor will // handle any potential issues with max_time. diff --git a/frontend/app/components/trace_viewer_v2/timeline/timeline.cc b/frontend/app/components/trace_viewer_v2/timeline/timeline.cc index 87f849a5..68b93db1 100644 --- a/frontend/app/components/trace_viewer_v2/timeline/timeline.cc +++ b/frontend/app/components/trace_viewer_v2/timeline/timeline.cc @@ -7,6 +7,7 @@ #include #include #include +#include #include #include "absl/algorithm/container.h" @@ -52,6 +53,14 @@ void Timeline::SetVisibleRange(const TimeRange& range, bool animate) { } } +void Timeline::SetTimelineData(FlameChartTimelineData data) { + // Calculate offsets first to avoid partial state in group_offsets_ member. + Offsets new_offsets = CalculateOffsets(data); + timeline_data_ = std::move(data); + group_offsets_ = std::move(new_offsets.group_offsets); + visible_level_offsets_ = std::move(new_offsets.visible_level_offsets); +} + void Timeline::Draw() { event_clicked_this_frame_ = false; @@ -60,11 +69,6 @@ void Timeline::Draw() { ImGui::SetNextWindowSize(viewport->Size); ImGui::SetNextWindowViewport(viewport->ID); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(0.0f, 0.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f)); - ImGui::Begin("Timeline viewer", nullptr, kImGuiWindowFlags); const Pixel timeline_width = @@ -86,8 +90,43 @@ void Timeline::Draw() { label_width_); ImGui::TableSetupColumn("Timeline", ImGuiTableColumnFlags_WidthStretch); - for (int group_index = 0; group_index < timeline_data_.groups.size(); - ++group_index) { + // If the group offsets are empty, we don't need to draw any tracks/events + // Return early to avoid unnecessary operations and crashes. + if (group_offsets_.empty()) { + ImGui::EndTable(); + ImGui::EndChild(); // Tracks + ImGui::End(); // Timeline viewer + return; + } + + // Manual culling for smooth scrolling with variable height groups. + // ImGuiListClipper can cause jumping with variable height items because it + // estimates the height of unseen items. By pre-calculating the exact height + // of each group, we can implement a custom culling loop that provides + // perfectly smooth scrolling. + + // 1. Determine which groups are visible based on scroll position using binary + // search on the pre-calculated group offsets. + // Note: scroll_y is relative to the top of the "Tracks" child window. Since + // the ruler is drawn outside this child window, scroll_y starts at 0 for the + // first group and does not include the ruler height. + const float scroll_y = ImGui::GetScrollY(); + const float visible_height = ImGui::GetContentRegionAvail().y; + + auto [start_index, end_index] = + GetVisibleGroupRange(scroll_y, visible_height); + + // 2. Add top padding for all invisible items above the viewport. This is + // simply the offset of the first visible group. + const float top_padding_height = group_offsets_[start_index]; + if (top_padding_height > 0.0f) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::Dummy(ImVec2(0.0f, top_padding_height)); + } + + // 3. Draw only the visible items. + for (int group_index = start_index; group_index <= end_index; ++group_index) { const Group& group = timeline_data_.groups[group_index]; ImGui::TableNextRow(); ImGui::TableNextColumn(); @@ -105,6 +144,15 @@ void Timeline::Draw() { DrawGroup(group_index, px_per_time_unit_val); } + // 4. Add bottom padding for all invisible items below the viewport. + const float bottom_padding_height = + group_offsets_.back() - group_offsets_[end_index + 1]; + if (bottom_padding_height > 0.0f) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::Dummy(ImVec2(0.0f, bottom_padding_height)); + } + ImGui::EndTable(); HandleEventDeselection(); @@ -118,7 +166,7 @@ void Timeline::Draw() { HandleWheel(); HandleMouse(); - ImGui::EndChild(); + ImGui::EndChild(); // Tracks // Draw the selected time range in a separate overlay child window. // This ensures it is drawn on top of the "Tracks" child window (because it's @@ -132,12 +180,8 @@ void Timeline::Draw() { ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoBackground); DrawSelectedTimeRanges(timeline_width, px_per_time_unit_val); - ImGui::EndChild(); - ImGui::PopStyleVar(); // ItemSpacing - ImGui::PopStyleVar(); // CellPadding - ImGui::PopStyleVar(); // WindowPadding - ImGui::PopStyleVar(); // WindowRounding + ImGui::EndChild(); // SelectionOverlay ImGui::End(); // Timeline viewer } @@ -145,7 +189,7 @@ EventRect Timeline::CalculateEventRect(Microseconds start, Microseconds end, Pixel screen_x_offset, Pixel screen_y_offset, double px_per_time_unit, - int level_in_group, + Pixel relative_y_pos, Pixel timeline_width) const { const Pixel left = TimeToScreenX(start, screen_x_offset, px_per_time_unit); Pixel right = TimeToScreenX(end, screen_x_offset, px_per_time_unit); @@ -159,8 +203,7 @@ EventRect Timeline::CalculateEventRect(Microseconds start, Microseconds end, // event's start time. right -= kEventPaddingRight; - const Pixel top = - screen_y_offset + level_in_group * (kEventHeight + kEventPaddingBottom); + const Pixel top = screen_y_offset + relative_y_pos; const Pixel bottom = top + kEventHeight; const Pixel timeline_right_boundary = screen_x_offset + timeline_width; @@ -352,6 +395,75 @@ double Timeline::px_per_time_unit(Pixel timeline_width) const { } } +Timeline::Offsets Timeline::CalculateOffsets( + const FlameChartTimelineData& data) const { + Offsets offsets; + // There is one more element in offsets than in groups to store the total + // height. + offsets.group_offsets.reserve(data.groups.size() + 1); + offsets.visible_level_offsets.resize(data.events_by_level.size() + 1); + + float current_offset = 0.0f; + offsets.group_offsets.push_back(current_offset); + + for (int i = 0; i < data.groups.size(); ++i) { + const Group& group = data.groups[i]; + const int start_level = group.start_level; + int end_level = (i + 1 < data.groups.size()) + ? data.groups[i + 1].start_level + : data.events_by_level.size(); + + if (group.type == Group::Type::kCounter) { + if (start_level < offsets.visible_level_offsets.size()) { + offsets.visible_level_offsets[start_level] = current_offset; + current_offset += kCounterTrackHeight; + } + } else if (group.type == Group::Type::kFlame) { + // kFlame group. + // Ensure a minimum height of one level to prevent ImGui::BeginChild from + // auto-resizing, even if a group contains no levels. This is important + // for parent groups (e.g., a process) that might not contain any event + // levels directly. + if (end_level <= start_level) { + current_offset += (kEventHeight + kEventPaddingBottom); + } else { + for (int level = start_level; level < end_level; ++level) { + // Add a sanity check to avoid out-of-bounds access. This should not + // happen, but we add this check to prevent crashing the app. + if (level < offsets.visible_level_offsets.size()) { + offsets.visible_level_offsets[level] = current_offset; + } + current_offset += (kEventHeight + kEventPaddingBottom); + } + } + } + offsets.group_offsets.push_back(current_offset); + } + // The last element stores the total height of all levels. + if (!offsets.visible_level_offsets.empty()) { + offsets.visible_level_offsets.back() = current_offset; + } + return offsets; +} + +std::pair Timeline::GetVisibleGroupRange(float scroll_y, + float visible_height) const { + if (group_offsets_.empty()) return {0, -1}; + + auto it_start = + std::upper_bound(group_offsets_.begin(), group_offsets_.end(), scroll_y); + int start_index = std::distance(group_offsets_.begin(), it_start) - 1; + start_index = std::max(0, start_index); + + auto it_end = std::lower_bound(group_offsets_.begin(), group_offsets_.end(), + scroll_y + visible_height); + int end_index = std::distance(group_offsets_.begin(), it_end) - 1; + end_index = + std::min(end_index, static_cast(timeline_data_.groups.size() - 1)); + + return {start_index, end_index}; +} + // Draws the timeline ruler. This includes the main horizontal line, // vertical tick marks indicating time intervals, and their corresponding time // labels. @@ -537,14 +649,49 @@ void Timeline::DrawEvent(int group_index, int event_index, void Timeline::DrawEventsForLevel(int group_index, absl::Span event_indices, - double px_per_time_unit, int level_in_group, + double px_per_time_unit, int level, const ImVec2& pos, const ImVec2& max) { ImDrawList* const draw_list = ImGui::GetWindowDrawList(); if (!draw_list) { return; } - for (int event_index : event_indices) { + const Microseconds visible_start_time = PixelToTime(pos.x, px_per_time_unit); + const Microseconds visible_end_time = PixelToTime(max.x, px_per_time_unit); + + // Since we are drawing events for a single level, the events are guaranteed + // not to overlap. This implies that if the events are sorted by start time + // (which is a requirement for event_indices), they are effectively sorted by + // end time as well. Specifically, if event A comes before event B, then + // start_A < end_A <= start_B < end_B. Thus, we can use binary search for + // both start and end times. + + // Find the first event that ends after the visible start time. + // lower_bound with this comparator finds the first element where + // (end_time < visible_start_time) is false, i.e., end_time >= + // visible_start_time. + auto first_visible_it = std::lower_bound( + event_indices.begin(), event_indices.end(), visible_start_time, + [&](int event_index, Microseconds time) { + return timeline_data_.entry_start_times[event_index] + + timeline_data_.entry_total_times[event_index] < + time; + }); + + // Find the first event that starts after the visible end time. + // upper_bound with this comparator finds the first element where + // (visible_end_time < start_time) is true. + auto last_visible_it = std::upper_bound( + first_visible_it, event_indices.end(), visible_end_time, + [&](Microseconds time, int event_index) { + return time < timeline_data_.entry_start_times[event_index]; + }); + + const Pixel relative_y_pos = + visible_level_offsets_[level] - group_offsets_[group_index]; + + for (auto it = first_visible_it; it != last_visible_it; ++it) { + int event_index = *it; if (event_index < 0 || event_index >= timeline_data_.entry_start_times.size() || event_index >= timeline_data_.entry_total_times.size()) { @@ -556,7 +703,7 @@ void Timeline::DrawEventsForLevel(int group_index, start + timeline_data_.entry_total_times[event_index]; const EventRect rect = CalculateEventRect( - start, end, pos.x, pos.y, px_per_time_unit, level_in_group, max.x); + start, end, pos.x, pos.y, px_per_time_unit, relative_y_pos, max.x); DrawEvent(group_index, event_index, rect, draw_list); } @@ -696,18 +843,8 @@ void Timeline::DrawGroup(int group_index, double px_per_time_unit_val) { // If this is the last group, the end level is the total // number of levels. : timeline_data_.events_by_level.size(); - // Ensure end_level is not less than start_level, to avoid negative height. - end_level = std::max(start_level, end_level); - - // Calculate group height. Ensure a minimum height of one level to prevent - // ImGui::BeginChild from auto-resizing, even if a group contains no levels. - // This is important for parent groups (e.g., a process) that might not - // contain any event levels directly. - // TODO: b/453676716 - Add tests for group height calculation. - const Pixel group_height = group.type == Group::Type::kCounter - ? kCounterTrackHeight - : std::max(1, end_level - start_level) * - (kEventHeight + kEventPaddingBottom); + const Pixel group_height = + group_offsets_[group_index + 1] - group_offsets_[group_index]; // Groups might have the same name. We add the index of the group to the ID // to ensure each ImGui::BeginChild call has a unique ID, otherwise ImGui // might ignore later calls with the same name. @@ -734,7 +871,7 @@ void Timeline::DrawGroup(int group_index, double px_per_time_unit_val) { // TODO: b/453676716 - Add boundary test cases for this function. DrawEventsForLevel(group_index, timeline_data_.events_by_level[level], px_per_time_unit_val, - /*level_in_group=*/level - start_level, pos, max); + /*level=*/level, pos, max); } } } diff --git a/frontend/app/components/trace_viewer_v2/timeline/timeline.h b/frontend/app/components/trace_viewer_v2/timeline/timeline.h index f89ec8a8..232e91e7 100644 --- a/frontend/app/components/trace_viewer_v2/timeline/timeline.h +++ b/frontend/app/components/trace_viewer_v2/timeline/timeline.h @@ -113,11 +113,15 @@ class Timeline { void set_data_time_range(const TimeRange& range) { data_time_range_ = range; } - void set_timeline_data(FlameChartTimelineData data) { - timeline_data_ = std::move(data); - } + void SetTimelineData(FlameChartTimelineData data); const FlameChartTimelineData& timeline_data() const { return timeline_data_; } + // Returns the cached group offsets. This is for testing only. + const std::vector& group_offsets() const { return group_offsets_; } + const std::vector& visible_level_offsets() const { + return visible_level_offsets_; + } + int selected_event_index() const { return selected_event_index_; } int selected_group_index() const { return selected_group_index_; } int selected_counter_index() const { return selected_counter_index_; } @@ -134,7 +138,7 @@ class Timeline { // Calculates the screen coordinates of the rectangle for an event. EventRect CalculateEventRect(Microseconds start, Microseconds end, Pixel screen_x_offset, Pixel screen_y_offset, - double px_per_time_unit, int level_in_group, + double px_per_time_unit, Pixel relative_y_pos, Pixel timeline_width) const; // Calculates the top-left screen coordinates for the event name text. @@ -183,6 +187,23 @@ class Timeline { // zooming behavior. virtual void Zoom(float zoom_factor); + struct Offsets { + std::vector group_offsets; + std::vector visible_level_offsets; + }; + + // Pre-calculates the offsets of each group row based on the provided data. + // This avoids re-calculating these heights on every frame during the draw + // call. + virtual Offsets CalculateOffsets(const FlameChartTimelineData& data) const; + + // Calculates the range of visible group indices [start, end] based on the + // current scroll position and visible height. The returned range includes + // a buffer of one group before and after the visible area for smooth + // scrolling. + std::pair GetVisibleGroupRange(float scroll_y, + float visible_height) const; + private: double px_per_time_unit() const; double px_per_time_unit(Pixel timeline_width) const; @@ -198,8 +219,8 @@ class Timeline { ImDrawList* absl_nonnull draw_list); void DrawEventsForLevel(int group_index, absl::Span event_indices, - double px_per_time_unit, int level_in_group, - const ImVec2& pos, const ImVec2& max); + double px_per_time_unit, int level, const ImVec2& pos, + const ImVec2& max); void DrawCounterTooltip(int group_index, const CounterData& counter_data, double px_per_time_unit_val, const ImVec2& pos, @@ -250,10 +271,18 @@ class Timeline { static constexpr ImGuiWindowFlags kLaneFlags = ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; - FlameChartTimelineData timeline_data_; // TODO - b/444026851: Set the label width based on the real screen width. Pixel label_width_ = 250.0f; + FlameChartTimelineData timeline_data_; + + // Cached offsets of each group row. This is pre-calculated in + // `set_timeline_data` to avoid recalculating on every frame in the `Draw` + // call, which is a significant performance optimization. The last element + // stores the total height. + std::vector group_offsets_; + std::vector visible_level_offsets_; + // The visible time range in microseconds in the timeline. It is initialized // to {0, 0} by the `TimeRange` default constructor. // This range is updated through `SetVisibleRange`. diff --git a/frontend/app/components/trace_viewer_v2/timeline/timeline_test.cc b/frontend/app/components/trace_viewer_v2/timeline/timeline_test.cc index 5f5e00c7..bbe225f0 100644 --- a/frontend/app/components/trace_viewer_v2/timeline/timeline_test.cc +++ b/frontend/app/components/trace_viewer_v2/timeline/timeline_test.cc @@ -1,8 +1,10 @@ #include "xprof/frontend/app/components/trace_viewer_v2/timeline/timeline.h" #include +#include #include #include +#include #include "testing/base/public/gmock.h" #include "" @@ -42,13 +44,72 @@ TEST(TimelineTest, SetTimelineData) { data.entry_start_times.push_back(10.0); data.entry_total_times.push_back(5.0); - timeline.set_timeline_data(std::move(data)); + timeline.SetTimelineData(std::move(data)); EXPECT_THAT(timeline.timeline_data().entry_levels, ElementsAre(0)); EXPECT_THAT(timeline.timeline_data().entry_start_times, ElementsAre(10.0)); EXPECT_THAT(timeline.timeline_data().entry_total_times, ElementsAre(5.0)); } +TEST(TimelineTest, CalculateOffsets) { + Timeline timeline; + FlameChartTimelineData data; + + // Group 0: Flame chart with 2 levels (0, 1). + // Height = 2 * (kEventHeight + kEventPaddingBottom) = 2 * 17.0 = 34.0. + data.groups.push_back( + {.type = Group::Type::kFlame, .name = "Group 0", .start_level = 0}); + + // Group 1: Counter track. + // Height = kCounterTrackHeight = 40.0. + data.groups.push_back( + {.type = Group::Type::kCounter, .name = "Group 1", .start_level = 2}); + + // Group 2: Empty flame chart (0 levels). + // Height = 1 * (kEventHeight + kEventPaddingBottom) = 17.0 (min height). + data.groups.push_back( + {.type = Group::Type::kFlame, .name = "Group 2", .start_level = 3}); + + // Simulate 3 levels in total (0, 1, 2). + // Resize events_by_level to 4, meaning levels 0, 1, 2, 3 exist. + // Group 2 starts at level 3. Since no events are added to level 3, + // this group contains an empty level. + data.events_by_level.resize(4); + + timeline.SetTimelineData(std::move(data)); + + const std::vector& offsets = timeline.group_offsets(); + + // We expect 4 offsets: 0, height(G0), height(G0)+height(G1), total height. + ASSERT_THAT(offsets, ::testing::SizeIs(4)); + + EXPECT_THAT(offsets[0], FloatEq(0.0f)); + EXPECT_THAT(offsets[1], FloatEq(34.0f)); + EXPECT_THAT(offsets[2], FloatEq(34.0f + 40.0f)); // 74.0f + EXPECT_THAT(offsets[3], FloatEq(74.0f + 17.0f)); // 91.0f + + const std::vector& visible_level_offsets = + timeline.visible_level_offsets(); + + // Max level is 3. So size is 3 + 1 + 1 (end offset) = 5. + ASSERT_THAT(visible_level_offsets, ::testing::SizeIs(5)); + + // Group 0 + // Level 0: 0.0 + 0 * 17.0 = 0.0 + EXPECT_THAT(visible_level_offsets[0], FloatEq(0.0f)); + + // Level 1: 0.0 + 1 * 17.0 = 17.0 + EXPECT_THAT(visible_level_offsets[1], FloatEq(17.0f)); + + // Group 1 (Counter) - Level 2 + // Populated by CalculateOffsets for counter groups. + EXPECT_THAT(visible_level_offsets[2], FloatEq(34.0f)); + + // Group 2 (Flame) - Level 3 + // It covers level 3. + EXPECT_THAT(visible_level_offsets[3], FloatEq(74.0f)); +} + TEST(TimelineTest, SetVisibleRange) { Timeline timeline; TimeRange range(10.0, 50.0); @@ -116,7 +177,7 @@ TEST(TimelineTest, TimeToScreenX) { constexpr double kPxPerTimeUnit = 1.0; constexpr Pixel kScreenXOffset = 0.0f; constexpr Pixel kScreenYOffset = 0.0f; -constexpr int kLevelInGroup = 0; +constexpr Pixel kRelativeYPos = 0.0f; constexpr Pixel kTimelineWidth = 100.0f; TEST(TimelineTest, CalculateEventRect_EventFullyWithinView) { @@ -127,7 +188,7 @@ TEST(TimelineTest, CalculateEventRect_EventFullyWithinView) { // Screen range before adjustments: [10.0, 20.0]. EventRect rect = timeline.CalculateEventRect( /*start=*/110.0, /*end=*/120.0, kScreenXOffset, kScreenYOffset, - kPxPerTimeUnit, kLevelInGroup, kTimelineWidth); + kPxPerTimeUnit, kRelativeYPos, kTimelineWidth); EXPECT_FLOAT_EQ(rect.left, 10.0f); EXPECT_FLOAT_EQ(rect.right, 20.0f - kEventPaddingRight); @@ -143,7 +204,7 @@ TEST(TimelineTest, CalculateEventRect_EventPartiallyClippedLeft) { // Screen range after left clipping: [0.0, 10.0]. EventRect rect = timeline.CalculateEventRect( /*start=*/90.0, /*end=*/110.0, kScreenXOffset, kScreenYOffset, - kPxPerTimeUnit, kLevelInGroup, kTimelineWidth); + kPxPerTimeUnit, kRelativeYPos, kTimelineWidth); EXPECT_FLOAT_EQ(rect.left, 0.0f); EXPECT_FLOAT_EQ(rect.right, 10.0f - kEventPaddingRight); @@ -157,7 +218,7 @@ TEST(TimelineTest, CalculateEventRect_EventPartiallyClippedRight) { // Screen range after right clipping: [90.0, 100.0]. EventRect rect = timeline.CalculateEventRect( /*start=*/190.0, /*end=*/210.0, kScreenXOffset, kScreenYOffset, - kPxPerTimeUnit, kLevelInGroup, kTimelineWidth); + kPxPerTimeUnit, kRelativeYPos, kTimelineWidth); EXPECT_FLOAT_EQ(rect.left, 90.0f); EXPECT_FLOAT_EQ(rect.right, 100.0f); @@ -173,7 +234,7 @@ TEST(TimelineTest, CalculateEventRect_EventCompletelyOutsideLeft) { // won't effect here because the event is clipped to the left edge). EventRect rect = timeline.CalculateEventRect( /*start=*/80.0, /*end=*/90.0, kScreenXOffset, kScreenYOffset, - kPxPerTimeUnit, kLevelInGroup, kTimelineWidth); + kPxPerTimeUnit, kRelativeYPos, kTimelineWidth); EXPECT_FLOAT_EQ(rect.left, 0.0f); EXPECT_FLOAT_EQ(rect.right, 0.0f); @@ -189,7 +250,7 @@ TEST(TimelineTest, CalculateEventRect_EventCompletelyOutsideRight) { // right won't effect here because the event is clipped to the right edge). EventRect rect = timeline.CalculateEventRect( /*start=*/210.0, /*end=*/220.0, kScreenXOffset, kScreenYOffset, - kPxPerTimeUnit, kLevelInGroup, kTimelineWidth); + kPxPerTimeUnit, kRelativeYPos, kTimelineWidth); EXPECT_FLOAT_EQ(rect.left, 100.0f); EXPECT_FLOAT_EQ(rect.right, 100.0f); @@ -203,7 +264,7 @@ TEST(TimelineTest, CalculateEventRect_EventSmallerThanMinimumWidth) { // Screen width is expanded to kEventMinimumDrawWidth. EventRect rect = timeline.CalculateEventRect( /*start=*/110.0, /*end=*/110.1, kScreenXOffset, kScreenYOffset, - kPxPerTimeUnit, kLevelInGroup, kTimelineWidth); + kPxPerTimeUnit, kRelativeYPos, kTimelineWidth); EXPECT_FLOAT_EQ(rect.left, 10.0f); EXPECT_FLOAT_EQ(rect.right, @@ -218,7 +279,7 @@ TEST(TimelineTest, CalculateEventRect_ZeroPxPerTimeUnit) { // kEventMinimumDrawWidth. EventRect rect = timeline.CalculateEventRect( /*start=*/110.0, /*end=*/120.0, kScreenXOffset, kScreenYOffset, - /*px_per_time_unit=*/0.0, kLevelInGroup, kTimelineWidth); + /*px_per_time_unit=*/0.0, kRelativeYPos, kTimelineWidth); // left becomes screen_x_offset (0), right becomes max(0, 0 + // kEventMinimumDrawWidth) @@ -528,7 +589,7 @@ TEST(TimelineTest, NavigateToEvent) { data.entry_start_times.push_back(100.0); data.entry_total_times.push_back(10.0); data.entry_total_times.push_back(20.0); - timeline.set_timeline_data(std::move(data)); + timeline.SetTimelineData(std::move(data)); timeline.set_data_time_range({0.0, 200.0}); timeline.SetVisibleRange({0.0, 50.0}); @@ -548,7 +609,7 @@ TEST(TimelineTest, NavigateToEventWithNegativeIndex) { Timeline timeline; FlameChartTimelineData data; data.entry_start_times.push_back(10.0); - timeline.set_timeline_data(std::move(data)); + timeline.SetTimelineData(std::move(data)); TimeRange initial_range(0.0, 50.0); timeline.SetVisibleRange(initial_range); @@ -563,7 +624,7 @@ TEST(TimelineTest, NavigateToEventWithIndexOutOfBounds) { Timeline timeline; FlameChartTimelineData data; data.entry_start_times.push_back(10.0); - timeline.set_timeline_data(std::move(data)); + timeline.SetTimelineData(std::move(data)); TimeRange initial_range(0.0, 50.0); timeline.SetVisibleRange(initial_range); @@ -574,6 +635,185 @@ TEST(TimelineTest, NavigateToEventWithIndexOutOfBounds) { EXPECT_EQ(timeline.visible_range().end(), initial_range.end()); } +// Specialized class for testing internal behavior and state. +class TestableTimeline : public Timeline { + public: + // Expose protected methods for testing. + using Timeline::GetVisibleGroupRange; + + // Intercept CalculateOffsets to verify internal state during updates. + Offsets CalculateOffsets(const FlameChartTimelineData& data) const override { + if (on_calculate_group_offsets) { + on_calculate_group_offsets(); + } + return Timeline::CalculateOffsets(data); + } + + std::function on_calculate_group_offsets; +}; + +TEST(TimelineTest, DataRemainsOldDuringCalculation) { + TestableTimeline timeline; + + // 1. Set initial data + FlameChartTimelineData old_data; + old_data.groups.push_back({.name = "Old Group", .start_level = 0}); + timeline.SetTimelineData(old_data); // Initial set + + ASSERT_EQ(timeline.timeline_data().groups.size(), 1); + ASSERT_EQ(timeline.timeline_data().groups[0].name, "Old Group"); + + // 2. Prepare new data + FlameChartTimelineData new_data; + new_data.groups.push_back({.name = "New Group", .start_level = 0}); + + // 3. Setup verification hook + bool checked = false; + timeline.on_calculate_group_offsets = [&]() { + checked = true; + // CRITICAL CHECK: The timeline should still hold the old data + EXPECT_EQ(timeline.timeline_data().groups.size(), 1); + EXPECT_EQ(timeline.timeline_data().groups[0].name, "Old Group"); + + // And obviously not the new data (though size=1 check above is weak if both + // are size 1) + EXPECT_NE(timeline.timeline_data().groups[0].name, "New Group"); + }; + + // 4. Trigger update + timeline.SetTimelineData(std::move(new_data)); + + EXPECT_TRUE(checked); + + // 5. Verify final state + EXPECT_EQ(timeline.timeline_data().groups.size(), 1); + EXPECT_EQ(timeline.timeline_data().groups[0].name, "New Group"); +} + +class GetVisibleGroupRangeTest : public ::testing::Test { + protected: + void SetUp() override { + // Setup some dummy data with predictable offsets. + // 10 groups, each height 10.0 (simulated). + // group_offsets_ will be {0, 10, 20, ..., 100} + FlameChartTimelineData data; + for (int i = 0; i < 10; ++i) { + data.groups.push_back({.name = "Group " + std::to_string(i)}); + } + // We can't control CalculateGroupOffsets easily to produce exact numbers + // without mocking or setting up complex events. + // Instead, we rely on setting up data such that CalculateGroupOffsets + // produces known values. + // Let's assume kCounterTrackHeight is 40.0f. + // We'll use counter tracks for simplicity. + data.groups.clear(); + for (int i = 0; i < 10; ++i) { + data.groups.push_back({.type = Group::Type::kCounter, + .name = "Group " + std::to_string(i), + .start_level = 0}); + } + // Height of each group will be kCounterTrackHeight (40.0f). + // Offsets: 0, 40, 80, 120, 160, 200, 240, 280, 320, 360, 400. + timeline_.SetTimelineData(std::move(data)); + } + + TestableTimeline timeline_; + const float kRowHeight = kCounterTrackHeight; // 40.0f +}; + +TEST_F(GetVisibleGroupRangeTest, EmptyData) { + TestableTimeline empty_timeline; + empty_timeline.SetTimelineData({}); + auto [start, end] = empty_timeline.GetVisibleGroupRange(0.0f, 100.0f); + + // Implementation returns {0, -1} for empty offsets. + EXPECT_EQ(start, 0); + EXPECT_EQ(end, -1); +} + +TEST_F(GetVisibleGroupRangeTest, ScrollAtTop_VisibleOneRow) { + // Scroll 0, Visible 40 (1 row). + // Visible: Group 0 [0, 40). + // Start: 0. + // End: 0. + // Expected: [0, 0]. + auto [start, end] = timeline_.GetVisibleGroupRange(0.0f, kRowHeight); + + EXPECT_EQ(start, 0); + EXPECT_EQ(end, 0); +} + +TEST_F(GetVisibleGroupRangeTest, ScrollAtTop_VisibleTwoRows) { + // Scroll 0, Visible 80 (2 rows). + // Visible: Group 0 [0, 40), Group 1 [40, 80). + // Start: 0. + // End: 1. + auto [start, end] = timeline_.GetVisibleGroupRange(0.0f, kRowHeight * 2); + + EXPECT_EQ(start, 0); + EXPECT_EQ(end, 1); +} + +TEST_F(GetVisibleGroupRangeTest, ScrollInMiddle_ExactAlign) { + // Scroll 40 (start of Group 1), Visible 40. + // Visible: Group 1 [40, 80). + // Start: 1. + // End: 1. + auto [start, end] = timeline_.GetVisibleGroupRange(kRowHeight, kRowHeight); + + EXPECT_EQ(start, 1); + EXPECT_EQ(end, 1); +} + +TEST_F(GetVisibleGroupRangeTest, ScrollInMiddle_PartialAlign) { + // Scroll 60 (middle of Group 1), Visible 40. + // Visible Range: [60, 100). + // Covers Group 1 [40, 80) and Group 2 [80, 120). + // Start: Group 1. + // End: Group 2. + auto [start, end] = + timeline_.GetVisibleGroupRange(kRowHeight * 1.5f, kRowHeight); + + EXPECT_EQ(start, 1); + EXPECT_EQ(end, 2); +} + +TEST_F(GetVisibleGroupRangeTest, ScrollAtBottom) { + // Scroll 360 (start of Group 9, last one), Visible 40. + // Visible: Group 9 [360, 400). + // Start: 9. + // End: 9. + auto [start, end] = + timeline_.GetVisibleGroupRange(kRowHeight * 9, kRowHeight); + + EXPECT_EQ(start, 9); + EXPECT_EQ(end, 9); +} + +TEST_F(GetVisibleGroupRangeTest, ScrollPastBottom) { + // Scroll 500 (beyond 400), Visible 100. + // Visible Range: [500, 600). + // Start: > 400. upper_bound returns end(), distance is 11. start_index 10. + // End: > 400. lower_bound returns end(), distance 11. end_index 10. + // Clamped to 9. + // Result: [10, 9]. (Empty range because start > end) + auto [start, end] = timeline_.GetVisibleGroupRange(500.0f, 100.0f); + + EXPECT_EQ(start, 10); + EXPECT_EQ(end, 9); +} + +TEST_F(GetVisibleGroupRangeTest, VisibleHeightLargerThanContent) { + // Scroll 0, Visible 1000. + // Visible Range [0, 1000). + // Start: 0. + // End: 9 (clamped). + auto [start, end] = timeline_.GetVisibleGroupRange(0.0f, 1000.0f); + + EXPECT_EQ(start, 0); + EXPECT_EQ(end, 9); +} + // Test fixture for tests that require an ImGui context. template class TimelineImGuiTestFixture : public Test { @@ -581,13 +821,21 @@ class TimelineImGuiTestFixture : public Test { void SetUp() override { ImGui::CreateContext(); + // Match the default styles applied in Application::Initialize(). + ImGuiStyle& style = ImGui::GetStyle(); + style.ScrollbarSize = 10.0f; + style.WindowRounding = 0.0f; + style.WindowPadding = ImVec2(0.0f, 0.0f); + style.CellPadding = ImVec2(0.0f, 0.0f); + style.ItemSpacing = ImVec2(0.0f, 0.0f); + ImGuiIO& io = ImGui::GetIO(); // Set dummy display size and delta time, required for ImGui to function. io.DisplaySize = ImVec2(1920, 1080); io.DeltaTime = 0.1f; // The font atlas must be built before ImGui::NewFrame() is called. io.Fonts->Build(); - timeline_.set_timeline_data( + timeline_.SetTimelineData( {{}, {}, {}, @@ -915,7 +1163,7 @@ TEST_F(MockTimelineImGuiFixture, DrawEventNameTextHiddenWhenTooNarrow) { data.entry_levels.push_back(0); data.entry_start_times.push_back(10.0); data.entry_total_times.push_back(0.001); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); // The event rect width will be kEventMinimumDrawWidth = 2.0f because @@ -940,7 +1188,7 @@ TEST_F(MockTimelineImGuiFixture, data.entry_levels.push_back(0); data.entry_start_times.push_back(10.0); data.entry_total_times.push_back(0.255); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); // The event rect width will be around 4.51f, which is < kMinTextWidth (5.0f). @@ -979,7 +1227,7 @@ TEST_F(RealTimelineImGuiFixture, ClickEventSelectsEvent) { data.entry_levels.push_back(0); data.entry_start_times.push_back(0.0); data.entry_total_times.push_back(100.0); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); bool callback_called = false; @@ -994,9 +1242,7 @@ TEST_F(RealTimelineImGuiFixture, ClickEventSelectsEvent) { // Set a mouse position that is guaranteed to be over the event, since the // event spans the entire timeline. - // y=28 is safely within the event rect (starts at 20, height 16 -> ends at - // 36). - ImGui::GetIO().MousePos = ImVec2(300.f, 28.f); + ImGui::GetIO().MousePos = ImVec2(300.f, 30.f); ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); @@ -1020,7 +1266,7 @@ TEST_F(RealTimelineImGuiFixture, ClickOutsideEventDoesNotSelectEvent) { data.entry_levels.push_back(0); data.entry_start_times.push_back(0.0); data.entry_total_times.push_back(100.0); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); bool callback_called = false; @@ -1049,7 +1295,7 @@ TEST_F(RealTimelineImGuiFixture, data.entry_levels.push_back(0); data.entry_start_times.push_back(0.0); data.entry_total_times.push_back(100.0); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); int callback_count = 0; @@ -1058,7 +1304,7 @@ TEST_F(RealTimelineImGuiFixture, callback_count++; }); - ImGui::GetIO().MousePos = ImVec2(300.f, 28.f); + ImGui::GetIO().MousePos = ImVec2(300.f, 30.f); // First click. ImGui::GetIO().MouseDown[0] = true; @@ -1088,11 +1334,11 @@ TEST_F(RealTimelineImGuiFixture, ClickEmptyAreaDeselectsEvent) { data.entry_levels.push_back(0); data.entry_start_times.push_back(0.0); data.entry_total_times.push_back(100.0); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); // First, select an event. - ImGui::GetIO().MousePos = ImVec2(300.f, 28.f); // A position over the event. + ImGui::GetIO().MousePos = ImVec2(300.f, 30.f); // A position over the event. ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); ImGui::GetIO().MouseDown[0] = false; // Release the mouse. @@ -1131,11 +1377,11 @@ TEST_F(RealTimelineImGuiFixture, ClickEmptyAreaDeselectsOnlyOnce) { data.entry_levels.push_back(0); data.entry_start_times.push_back(0.0); data.entry_total_times.push_back(100.0); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); // First, select an event. - ImGui::GetIO().MousePos = ImVec2(300.f, 28.f); // A position over the event. + ImGui::GetIO().MousePos = ImVec2(300.f, 30.f); // A position over the event. ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); ImGui::GetIO().MouseDown[0] = false; // Release the mouse. @@ -1182,7 +1428,7 @@ TEST_F(RealTimelineImGuiFixture, ClickEmptyAreaWhenNoEventSelectedDoesNothing) { data.entry_levels.push_back(0); data.entry_start_times.push_back(0.0); data.entry_total_times.push_back(100.0); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); bool callback_called = false; @@ -1201,8 +1447,10 @@ TEST_F(RealTimelineImGuiFixture, ClickEmptyAreaWhenNoEventSelectedDoesNothing) { EXPECT_FALSE(callback_called); } +// This is a test for making sure the wasm is not crashing when timeline data +// is empty. TEST_F(RealTimelineImGuiFixture, DrawsTimelineWindowWhenTimelineDataIsEmpty) { - timeline_.set_timeline_data({}); + timeline_.SetTimelineData({}); // We don't use SimulateFrame() here because we need to inspect the draw list // before ImGui::EndFrame() is called. @@ -1223,11 +1471,11 @@ TEST_F(RealTimelineImGuiFixture, ShiftClickEventTogglesCurtain) { data.entry_levels.push_back(0); data.entry_start_times.push_back(10.0); data.entry_total_times.push_back(20.0); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); // Mouse is over the event - ImGui::GetIO().MousePos = ImVec2(500.f, 28.f); + ImGui::GetIO().MousePos = ImVec2(500.f, 30.f); ImGui::GetIO().AddKeyEvent(ImGuiMod_Shift, true); ImGui::GetIO().MouseDown[0] = true; @@ -1267,13 +1515,13 @@ TEST_F(RealTimelineImGuiFixture, data.entry_start_times.push_back(50.0); data.entry_total_times.push_back(20.0); data.entry_total_times.push_back(10.0); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); ImGui::GetIO().AddKeyEvent(ImGuiMod_Shift, true); // First shift-click on event 1. - ImGui::GetIO().MousePos = ImVec2(500.f, 28.f); // Position over event 1. + ImGui::GetIO().MousePos = ImVec2(500.f, 30.f); // Position over event 1. ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); @@ -1286,7 +1534,7 @@ TEST_F(RealTimelineImGuiFixture, SimulateFrame(); // Second shift-click on event 2. - ImGui::GetIO().MousePos = ImVec2(1100.f, 28.f); // Position over event 2. + ImGui::GetIO().MousePos = ImVec2(1100.f, 30.f); // Position over event 2. ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); @@ -1301,7 +1549,7 @@ TEST_F(RealTimelineImGuiFixture, SimulateFrame(); // Third shift-click on event 1 again to deselect. - ImGui::GetIO().MousePos = ImVec2(500.f, 28.f); // Position over event 1. + ImGui::GetIO().MousePos = ImVec2(500.f, 30.f); // Position over event 1. ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); @@ -1363,8 +1611,8 @@ class TimelineDragSelectionTest : public RealTimelineImGuiFixture { RealTimelineImGuiFixture::SetUp(); // Set a visible range that results in a round number for px_per_time_unit // to make test calculations predictable. With a timeline width of 1669px - // (based on 1920px window width, 250px label width, and 1px padding), - // a duration of 166.9 gives 10px per microsecond. + // (based on 1920px window width and no paddings), a duration of 166.9 gives + // 10px per microsecond. timeline_.SetVisibleRange({0.0, 166.9}); timeline_.set_data_time_range({0.0, 166.9}); @@ -1574,7 +1822,7 @@ TEST_F(RealTimelineImGuiFixture, DrawCounterTrack) { counter_data.max_value = 10.0; data.counter_data_by_group_index[0] = std::move(counter_data); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); ImGui::NewFrame(); @@ -1613,7 +1861,7 @@ TEST_F(RealTimelineImGuiFixture, HoverCounterTrackShowsTooltip) { counter_data.max_value = 10.0; data.counter_data_by_group_index[0] = std::move(counter_data); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); // Render first frame to layout windows and find the counter track location. @@ -1690,11 +1938,11 @@ TEST_F(RealTimelineImGuiFixture, ClickEventSetsSelectionIndices) { data.entry_levels.push_back(0); data.entry_start_times.push_back(0.0); data.entry_total_times.push_back(100.0); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); // Set a mouse position that is guaranteed to be over the event. - ImGui::GetIO().MousePos = ImVec2(300.f, 28.f); + ImGui::GetIO().MousePos = ImVec2(300.f, 30.f); ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); @@ -1718,7 +1966,7 @@ TEST_F(RealTimelineImGuiFixture, ClickCounterEventSetsSelectionIndices) { counter_data.max_value = 10.0; data.counter_data_by_group_index[0] = std::move(counter_data); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); ImGui::NewFrame(); @@ -1782,11 +2030,11 @@ TEST_F(RealTimelineImGuiFixture, SelectionMutualExclusion) { counter_data.max_value = 10.0; data.counter_data_by_group_index[1] = std::move(counter_data); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); // Step 1: Select Flame Event - ImGui::GetIO().MousePos = ImVec2(300.f, 28.f); // Over flame event + ImGui::GetIO().MousePos = ImVec2(300.f, 30.f); // Over flame event ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); ImGui::GetIO().MouseDown[0] = false; @@ -1825,7 +2073,7 @@ TEST_F(RealTimelineImGuiFixture, SelectionMutualExclusion) { EXPECT_EQ(timeline_.selected_counter_index(), 0); // Step 3: Select Flame Event Again - ImGui::GetIO().MousePos = ImVec2(300.f, 28.f); + ImGui::GetIO().MousePos = ImVec2(300.f, 30.f); ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); @@ -1843,11 +2091,11 @@ TEST_F(RealTimelineImGuiFixture, ClickEmptyAreaClearsSelectionIndices) { data.entry_levels.push_back(0); data.entry_start_times.push_back(0.0); data.entry_total_times.push_back(100.0); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); timeline_.SetVisibleRange({0.0, 100.0}); // Select event - ImGui::GetIO().MousePos = ImVec2(300.f, 28.f); + ImGui::GetIO().MousePos = ImVec2(300.f, 30.f); ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); ImGui::GetIO().MouseDown[0] = false; @@ -1869,7 +2117,7 @@ TEST_F(RealTimelineImGuiFixture, SelectionOverlayIsDrawnOnTopOfTracks) { // Ensure we have some data so tracks are drawn. FlameChartTimelineData data; data.groups.push_back({.name = "Group 1", .start_level = 0}); - timeline_.set_timeline_data(std::move(data)); + timeline_.SetTimelineData(std::move(data)); ImGui::NewFrame(); timeline_.Draw(); @@ -1900,6 +2148,114 @@ TEST_F(RealTimelineImGuiFixture, SelectionOverlayIsDrawnOnTopOfTracks) { ImGui::EndFrame(); } +TEST_F(RealTimelineImGuiFixture, DrawsOnlyVisibleGroups) { + FlameChartTimelineData data; + // Create 100 groups. + for (int i = 0; i < 100; ++i) { + data.groups.push_back( + {.name = "Group " + std::to_string(i), .start_level = 0}); + } + // Ensure we have enough levels so DrawGroup doesn't crash or skip? + // Timeline::CalculateGroupOffsets ensures min height of 1 level (17px). + // So empty groups have height 17px. + + timeline_.SetTimelineData(std::move(data)); + // Total height ~ 1700px. + // Viewport ~1080px. + + ImGui::NewFrame(); + timeline_.Draw(); + + int child_window_count = 0; + for (ImGuiWindow* w : ImGui::GetCurrentContext()->Windows) { + if (absl::StrContains(std::string(w->Name), "TimelineChild_")) { + child_window_count++; + } + } + + // Should verify culling. + // If visible height is 1080 (minus ruler ~20, minus labels...), say 1000. + // 1000/17 ~ 58. + // Plus buffer (1 before, 1 after). ~60. + // Definitely < 100. + EXPECT_LT(child_window_count, 90); + // And > 0. + EXPECT_GT(child_window_count, 10); + + ImGui::EndFrame(); +} + +TEST_F(RealTimelineImGuiFixture, ContentHeightMatchesTotalGroupHeight) { + FlameChartTimelineData data; + // Add enough groups to have some height. + // 10 groups of height 40 (Counter). Total 400. + for (int i = 0; i < 10; ++i) { + data.groups.push_back({.type = Group::Type::kCounter, + .name = "Group " + std::to_string(i), + .start_level = 0}); + } + timeline_.SetTimelineData(std::move(data)); + // Total height should be 400.0f. + + ImGui::NewFrame(); + timeline_.Draw(); + + // Find the "Tracks" child window. + ImGuiWindow* tracks_window = nullptr; + for (ImGuiWindow* w : ImGui::GetCurrentContext()->Windows) { + if (absl::StrContains(w->Name, "Tracks") && + !absl::StrContains(w->Name, "TimelineChild")) { + tracks_window = w; + break; + } + } + ASSERT_NE(tracks_window, nullptr); + + // The expected total height is the last offset. + const float expected_height = timeline_.group_offsets().back(); + + // ContentSize is updated at the end of the frame/child. Since we are + // calling this after Draw() (which calls EndChild()), ContentSize should be + // valid. However, if it's 0, it might be because the table handling defers + // size report. + // Instead, let's look at the cursor position which tracks the layout. + // DC.CursorMaxPos tracks the maximum position reached by the cursor (content + // size). + float content_height = + tracks_window->DC.CursorMaxPos.y - tracks_window->DC.CursorStartPos.y; + + // We expect it to match exactly. + EXPECT_FLOAT_EQ(content_height, expected_height); + + ImGui::EndFrame(); +} + +TEST_F(RealTimelineImGuiFixture, + DrawGroupChildWindowHeightMatchesOffsetDifference) { + FlameChartTimelineData data; + data.groups.push_back( + {.type = Group::Type::kCounter, .name = "TestGroup", .start_level = 0}); + timeline_.SetTimelineData(std::move(data)); + + ImGui::NewFrame(); + timeline_.Draw(); + + ImGuiWindow* group_window = nullptr; + for (ImGuiWindow* w : ImGui::GetCurrentContext()->Windows) { + if (absl::StrContains(w->Name, "TimelineChild_TestGroup_0")) { + group_window = w; + break; + } + } + ASSERT_NE(group_window, nullptr); + + const float expected_height = + timeline_.group_offsets()[1] - timeline_.group_offsets()[0]; + EXPECT_FLOAT_EQ(group_window->Size.y, expected_height); + + ImGui::EndFrame(); +} + } // namespace } // namespace testing } // namespace traceviewer