diff --git a/frontend/app/components/trace_viewer_v2/BUILD b/frontend/app/components/trace_viewer_v2/BUILD index cc9127f7..3d237743 100644 --- a/frontend/app/components/trace_viewer_v2/BUILD +++ b/frontend/app/components/trace_viewer_v2/BUILD @@ -23,6 +23,31 @@ bzl_library( ], ) +wasm_cc_library( + name = "scheduler", + srcs = ["scheduler.cc"], + hdrs = ["scheduler.h"], + deps = [ + "@com_google_absl//absl/base:no_destructor", + ], +) + +# This test won't be run by TAP because emscripten tests require special build flags. +# To run this test locally, use: +# blaze test //third_party/xprof/frontend/app/components/trace_viewer_v2:scheduler_test +wasm_cc_test( + name = "scheduler_test", + srcs = ["scheduler_test.cc"], + linkopts = [ + "-sASYNCIFY=1", + "-sNO_EXIT_RUNTIME=1", + ], + deps = [ + ":scheduler", + "@com_google_googletest//:gtest_main", + ], +) + cc_library( name = "animation", hdrs = ["animation.h"], @@ -70,6 +95,7 @@ wasm_cc_library( ":canvas_state", ":event_manager", ":input_handler", + ":scheduler", ":webgpu_render_platform", "@org_xprof//frontend/app/components/trace_viewer_v2/fonts", "@org_xprof//frontend/app/components/trace_viewer_v2/timeline", @@ -86,6 +112,7 @@ wasm_cc_library( srcs = ["input_handler.cc"], hdrs = ["input_handler.h"], deps = [ + ":scheduler", "//third_party/dear_imgui", "//util/gtl:flat_map", "@com_google_absl//absl/strings", @@ -115,6 +142,9 @@ wasm_cc_library( ], ) +# This test won't be run by TAP because emscripten tests require special build flags. +# To run this test locally, use: +# blaze test //third_party/xprof/frontend/app/components/trace_viewer_v2:event_manager_test wasm_cc_test( name = "event_manager_test", srcs = ["event_manager_test.cc"], @@ -182,6 +212,7 @@ cc_binary( ":canvas_state", ":imgui_webgpu_backend", ":input_handler", + ":scheduler", ":webgpu_render_platform", "//third_party/emscripten:embind", "@org_xprof//frontend/app/components/trace_viewer_v2/fonts", diff --git a/frontend/app/components/trace_viewer_v2/animation.h b/frontend/app/components/trace_viewer_v2/animation.h index 218ad84e..0c0d4126 100644 --- a/frontend/app/components/trace_viewer_v2/animation.h +++ b/frontend/app/components/trace_viewer_v2/animation.h @@ -38,6 +38,9 @@ class Animation { finished_->clear(); } + // Returns true if there are any active animations. + static bool HasActiveAnimations() { return !animations_->empty(); } + protected: virtual void on_finished() = 0; diff --git a/frontend/app/components/trace_viewer_v2/application.cc b/frontend/app/components/trace_viewer_v2/application.cc index e0eba5b7..ad38bc95 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" @@ -16,6 +17,7 @@ #include "xprof/frontend/app/components/trace_viewer_v2/event_manager.h" #include "xprof/frontend/app/components/trace_viewer_v2/fonts/fonts.h" #include "xprof/frontend/app/components/trace_viewer_v2/input_handler.h" // NO_LINT +#include "xprof/frontend/app/components/trace_viewer_v2/scheduler.h" #include "xprof/frontend/app/components/trace_viewer_v2/timeline/timeline.h" #include "xprof/frontend/app/components/trace_viewer_v2/webgpu_render_platform.h" @@ -27,6 +29,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(); @@ -39,6 +50,12 @@ void ApplyLightTheme() { style.Colors[ImGuiCol_TableBorderLight] = ImVec4(0.4f, 0.4f, 0.4f, 1.0f); } +EM_BOOL OnResize(int eventType, const EmscriptenUiEvent* uiEvent, + void* userData) { + Application::Instance().RequestRedraw(); + return EM_FALSE; +} + } // namespace // This function initializes the application, setting up the ImGui context, @@ -57,7 +74,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(); @@ -95,8 +112,14 @@ void Application::Initialize() { // Register wheel event handlers to the canvas element. emscripten_set_wheel_callback(kCanvasTarget, /*user_data=*/this, /*use_capture=*/true, HandleWheel); + emscripten_set_resize_callback(kWindowTarget, /*user_data=*/nullptr, + /*use_capture=*/true, OnResize); + + Scheduler::Instance().SetMainLoopCallback([this]() { MainLoop(); }); } +void Application::RequestRedraw() { Scheduler::Instance().RequestRedraw(); } + void Application::MainLoop() { // TODO: b/454172203 - Replace polling `CanvasState::Update()` with a // push-based model. Use the `ResizeObserver` API in TypeScript to listen for @@ -117,14 +140,10 @@ void Application::MainLoop() { platform_->NewFrame(); timeline_->Draw(); platform_->RenderFrame(); -} -void Application::Main() { - emscripten_set_main_loop_arg( - [](void* app) { - static_cast(app)->MainLoop(); - }, - this, 0, true); + if (Animation::HasActiveAnimations()) { + RequestRedraw(); + } } float Application::GetDeltaTime() { diff --git a/frontend/app/components/trace_viewer_v2/application.h b/frontend/app/components/trace_viewer_v2/application.h index f574a5a2..8a023f7e 100644 --- a/frontend/app/components/trace_viewer_v2/application.h +++ b/frontend/app/components/trace_viewer_v2/application.h @@ -35,7 +35,8 @@ class Application { ~Application() { ImGui::DestroyContext(); } void Initialize(); - void Main(); + void Main() { RequestRedraw(); } + void RequestRedraw(); Timeline& timeline() { return *timeline_; }; const std::vector process_list() { diff --git a/frontend/app/components/trace_viewer_v2/color/colors.h b/frontend/app/components/trace_viewer_v2/color/colors.h index dbfbd206..7d1fd61b 100644 --- a/frontend/app/components/trace_viewer_v2/color/colors.h +++ b/frontend/app/components/trace_viewer_v2/color/colors.h @@ -24,14 +24,6 @@ inline constexpr ImU32 kPurple70 = 0xFFFF97C5; inline constexpr ImU32 kYellow90 = 0xFF7CE0FF; // go/keep-sorted end -// Baseline palette: -// go/keep-sorted start -// Baseline Primary: #0B57D0 -inline constexpr ImU32 kPrimary40 = 0xFFD0570B; -// Baseline Secondary: #C2E7FF -inline constexpr ImU32 kSecondary90 = 0xFFFFE7C2; -// go/keep-sorted end - } // namespace traceviewer #endif // THIRD_PARTY_XPROF_FRONTEND_APP_COMPONENTS_TRACE_VIEWER_V2_COLOR_COLORS_H_ diff --git a/frontend/app/components/trace_viewer_v2/input_handler.cc b/frontend/app/components/trace_viewer_v2/input_handler.cc index d8a3ae13..08eb61d8 100644 --- a/frontend/app/components/trace_viewer_v2/input_handler.cc +++ b/frontend/app/components/trace_viewer_v2/input_handler.cc @@ -5,6 +5,7 @@ #include "absl/strings/string_view.h" #include "third_party/dear_imgui/imgui.h" +#include "xprof/frontend/app/components/trace_viewer_v2/scheduler.h" #include "util/gtl/flat_map.h" namespace traceviewer { @@ -45,7 +46,24 @@ int IsActiveElementInput() { }); } +// Input Event Handlers +// +// Common behavior for all the following input handlers: +// +// 1. Redraw Requests: +// All handlers unconditionally request a redraw for the next animation frame +// via Scheduler::Instance().RequestRedraw(). Since drawing is asynchronous +// (scheduled for the next frame), the input updates processed here will be +// correctly applied before that frame is rendered. +// +// 2. Return Values (Event Propagation): +// The return value (EM_BOOL) indicates whether the event was handled: +// - EM_TRUE (true): The event was handled and should NOT be propagated. +// - EM_FALSE (false): The event was not handled and SHOULD be propagated to +// other listeners (e.g., browser default behavior). + EM_BOOL HandleKeyDown(int, const EmscriptenKeyboardEvent* event, void*) { + Scheduler::Instance().RequestRedraw(); UpdateModifierKeys(event); // If a native input element has focus, do not let ImGui capture the keyboard. @@ -61,6 +79,7 @@ EM_BOOL HandleKeyDown(int, const EmscriptenKeyboardEvent* event, void*) { } EM_BOOL HandleKeyUp(int, const EmscriptenKeyboardEvent* event, void*) { + Scheduler::Instance().RequestRedraw(); UpdateModifierKeys(event); // If a native input element has focus, do not let ImGui capture the keyboard. @@ -76,24 +95,28 @@ EM_BOOL HandleKeyUp(int, const EmscriptenKeyboardEvent* event, void*) { } EM_BOOL HandleMouseMove(int, const EmscriptenMouseEvent* event, void*) { + Scheduler::Instance().RequestRedraw(); ImGuiIO& io = ImGui::GetIO(); io.AddMousePosEvent(event->targetX, event->targetY); return io.WantCaptureMouse; } EM_BOOL HandleMouseDown(int, const EmscriptenMouseEvent* event, void*) { + Scheduler::Instance().RequestRedraw(); ImGuiIO& io = ImGui::GetIO(); io.AddMouseButtonEvent(event->button, true); return io.WantCaptureMouse; } EM_BOOL HandleMouseUp(int, const EmscriptenMouseEvent* event, void*) { + Scheduler::Instance().RequestRedraw(); ImGuiIO& io = ImGui::GetIO(); io.AddMouseButtonEvent(event->button, false); return io.WantCaptureMouse; } EM_BOOL HandleWheel(int, const EmscriptenWheelEvent* event, void*) { + Scheduler::Instance().RequestRedraw(); ImGuiIO& io = ImGui::GetIO(); io.AddKeyEvent(ImGuiMod_Ctrl, event->mouse.ctrlKey); diff --git a/frontend/app/components/trace_viewer_v2/scheduler.cc b/frontend/app/components/trace_viewer_v2/scheduler.cc new file mode 100644 index 00000000..fbf622ad --- /dev/null +++ b/frontend/app/components/trace_viewer_v2/scheduler.cc @@ -0,0 +1,43 @@ +#include "xprof/frontend/app/components/trace_viewer_v2/scheduler.h" + +#include + +#include + +#include "absl/base/no_destructor.h" + +namespace traceviewer { + +Scheduler& Scheduler::Instance() { + static absl::NoDestructor instance; + return *instance; +} + +void Scheduler::SetMainLoopCallback(std::function callback) { + callback_ = std::move(callback); +} + +void Scheduler::Reset() { + frame_scheduled_ = false; + callback_ = {}; +} + +void Scheduler::RequestRedraw() { + if (!frame_scheduled_) { + emscripten_request_animation_frame(LoopOnce, this); + frame_scheduled_ = true; + } +} + +EM_BOOL Scheduler::LoopOnce(double time, void* user_data) { + auto* scheduler = static_cast(user_data); + scheduler->frame_scheduled_ = false; + if (scheduler->callback_) { + scheduler->callback_(); + } + // Return EM_FALSE because this is a demand-driven system. Redraws should + // only happen when explicitly requested via RequestRedraw(). + return EM_FALSE; +} + +} // namespace traceviewer diff --git a/frontend/app/components/trace_viewer_v2/scheduler.h b/frontend/app/components/trace_viewer_v2/scheduler.h new file mode 100644 index 00000000..f205fe36 --- /dev/null +++ b/frontend/app/components/trace_viewer_v2/scheduler.h @@ -0,0 +1,40 @@ +#ifndef THIRD_PARTY_XPROF_FRONTEND_APP_COMPONENTS_TRACE_VIEWER_V2_SCHEDULER_H_ +#define THIRD_PARTY_XPROF_FRONTEND_APP_COMPONENTS_TRACE_VIEWER_V2_SCHEDULER_H_ + +#include + +#include + +#include "absl/base/no_destructor.h" + +namespace traceviewer { + +class Scheduler { + public: + static Scheduler& Instance(); + + Scheduler(const Scheduler&) = delete; + Scheduler& operator=(const Scheduler&) = delete; + Scheduler(Scheduler&&) = delete; + Scheduler& operator=(Scheduler&&) = delete; + + void RequestRedraw(); + + void SetMainLoopCallback(std::function callback); + + // Resets the scheduler state. + void Reset(); + + private: + friend class absl::NoDestructor; + Scheduler() = default; + + static EM_BOOL LoopOnce(double time, void* user_data); + + bool frame_scheduled_ = false; + std::function callback_; +}; + +} // namespace traceviewer + +#endif // THIRD_PARTY_XPROF_FRONTEND_APP_COMPONENTS_TRACE_VIEWER_V2_SCHEDULER_H_ diff --git a/frontend/app/components/trace_viewer_v2/scheduler_test.cc b/frontend/app/components/trace_viewer_v2/scheduler_test.cc new file mode 100644 index 00000000..e6fc791f --- /dev/null +++ b/frontend/app/components/trace_viewer_v2/scheduler_test.cc @@ -0,0 +1,50 @@ +#include "xprof/frontend/app/components/trace_viewer_v2/scheduler.h" + +#include + +#include "" + +namespace traceviewer { + +namespace { + +class SchedulerTest : public ::testing::Test { + protected: + void SetUp() override { + Scheduler::Instance().Reset(); + } +}; + +TEST_F(SchedulerTest, CallbackIsCalledOnlyOnceForMultipleRequests) { + int call_count = 0; + Scheduler::Instance().SetMainLoopCallback([&]() { call_count++; }); + + Scheduler::Instance().RequestRedraw(); + Scheduler::Instance().RequestRedraw(); + Scheduler::Instance().RequestRedraw(); + + // Sleep for 100ms to allow RAF to fire. + // This requires Asyncify (-sASYNCIFY=1). + emscripten_sleep(100); + + EXPECT_EQ(call_count, 1); +} + +TEST_F(SchedulerTest, CallbackIsCalledAgainIfRequestedAfterFrame) { + int call_count = 0; + Scheduler::Instance().SetMainLoopCallback([&]() { call_count++; }); + + Scheduler::Instance().RequestRedraw(); + + emscripten_sleep(100); + + EXPECT_EQ(call_count, 1); + + Scheduler::Instance().RequestRedraw(); + emscripten_sleep(100); + + EXPECT_EQ(call_count, 2); +} + +} // namespace +} // namespace traceviewer diff --git a/frontend/app/components/trace_viewer_v2/timeline/BUILD b/frontend/app/components/trace_viewer_v2/timeline/BUILD index 3ba1e3da..bcc4bd49 100644 --- a/frontend/app/components/trace_viewer_v2/timeline/BUILD +++ b/frontend/app/components/trace_viewer_v2/timeline/BUILD @@ -44,7 +44,6 @@ cc_library( ], deps = [ ":constants", - ":draw_utils", ":time_range", "//third_party/dear_imgui", "@com_google_absl//absl/base:nullability", @@ -94,18 +93,6 @@ cc_library( ], ) -cc_library( - name = "draw_utils", - srcs = ["draw_utils.cc"], - hdrs = ["draw_utils.h"], - deps = [ - ":constants", - "//third_party/dear_imgui", - "@com_google_absl//absl/strings:string_view", - "@org_xprof//frontend/app/components/trace_viewer_v2/color:colors", - ], -) - cc_test( name = "data_provider_test", srcs = ["data_provider_test.cc"], 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 53c90d3b..38c0eef2 100644 --- a/frontend/app/components/trace_viewer_v2/timeline/data_provider.cc +++ b/frontend/app/components/trace_viewer_v2/timeline/data_provider.cc @@ -303,7 +303,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; @@ -372,7 +372,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/draw_utils.cc b/frontend/app/components/trace_viewer_v2/timeline/draw_utils.cc deleted file mode 100644 index 3fb206b9..00000000 --- a/frontend/app/components/trace_viewer_v2/timeline/draw_utils.cc +++ /dev/null @@ -1,138 +0,0 @@ -#include "xprof/frontend/app/components/trace_viewer_v2/timeline/draw_utils.h" - -#include -#include -#include - -#include "xprof/frontend/app/components/trace_viewer_v2/color/colors.h" -#include "xprof/frontend/app/components/trace_viewer_v2/timeline/constants.h" -#include "absl/strings/string_view.h" -#include "third_party/dear_imgui/imgui.h" - -namespace traceviewer { - -namespace { - -// GM3-style indefinite linear progress indicator constants. -// The width of the progress bar as a ratio of the viewport width. -constexpr float kProgressBarWidthRatio = 0.4f; -constexpr Pixel kProgressBarHeight = 4.0f; -// The duration in seconds for one full animation cycle of the progress bar. -constexpr float kAnimationDuration = 3.0f; -// The gap in pixels between the primary and secondary indicators. -constexpr Pixel kProgressBarGap = 4.0f; -// A scaling factor used in easing calculations for animation. -constexpr float kProgressScale = 2.0f; -constexpr Pixel kTextOffsetY = 8.0f; - -// Tutorial messages displayed in the loading indicator. -constexpr absl::string_view kTutorials[] = { - "Pan: A/D or Shift+Scroll", - "Zoom: W/S or Ctrl+Scroll", - "Scroll: Up/Down Arrow or Scroll", -}; -// The interval in seconds to display each tutorial message. -constexpr float kTutorialInterval = 1.5f; -// The padding adding a gap between the progress indicator and the tutorial text -constexpr float kTutorialTextPaddingTop = 32.0f; - -void DrawTutorialText(ImDrawList* draw_list, ImVec2 center, float time) { - int num_tutorials = std::size(kTutorials); - int tutorial_index = - static_cast(time / kTutorialInterval) % num_tutorials; - absl::string_view tutorial = kTutorials[tutorial_index]; - - ImVec2 text_size = - ImGui::CalcTextSize(tutorial.data(), tutorial.data() + tutorial.size()); - ImVec2 text_pos = - ImVec2(center.x - text_size.x / 2.0f, center.y + kTutorialTextPaddingTop); - draw_list->AddText(text_pos, kBlackColor, tutorial.data(), - tutorial.data() + tutorial.size()); -} - -// Draws a "Loading data..." text message centered below the progress bar. -void DrawLoadingText(const ImGuiViewport* viewport) { - ImDrawList* draw_list = ImGui::GetForegroundDrawList(); - if (!draw_list) return; - - const char* text = "Loading data..."; - const ImVec2 text_size = ImGui::CalcTextSize(text); - const ImVec2 center = viewport->GetCenter(); - - const float text_x = center.x - text_size.x / 2.0f; - // Position text below the progress bar. The progress bar's center is at - // viewport->GetCenter().y. - const float text_y = center.y + kProgressBarHeight / 2.0f + kTextOffsetY; - - draw_list->AddText(ImVec2(text_x, text_y), kBlackColor, text); -} - -} // namespace - -// Draws a loading indicator in the center of the viewport. -void DrawLoadingIndicator(const ImGuiViewport* viewport) { - ImDrawList* draw_list = ImGui::GetForegroundDrawList(); - if (!draw_list) return; - - const Pixel kProgressBarCornerRadius = kProgressBarHeight / 2.0f; - const Pixel progress_bar_width = viewport->Size.x * kProgressBarWidthRatio; - - const ImVec2 center = viewport->GetCenter(); - const Pixel start_x = center.x - progress_bar_width / 2.0f; - const Pixel end_x = center.x + progress_bar_width / 2.0f; - const Pixel y = center.y - kProgressBarHeight / 2.0f; - - const float time = static_cast(ImGui::GetTime()); - const float cycle_progress = - fmod(time, kAnimationDuration) / kAnimationDuration; - - // The animation of the primary progress bar is defined by the movement of its - // head (right side) and tail (left side). The head moves with an ease-out - // curve, starting fast and slowing down, while the tail moves linearly. This - // creates the effect of the bar first growing in width and then shrinking as - // the tail catches up to the head. - - // Quadratic ease-out for the head of the bar. - const float head_progress = - kProgressScale * cycle_progress * (kProgressScale - cycle_progress); - // Linear progress for the tail of the bar. - const float tail_progress = kProgressScale * cycle_progress; - - const Pixel primary_start = start_x + progress_bar_width * tail_progress; - const Pixel primary_end = start_x + progress_bar_width * head_progress; - - // Draw the two secondary segments and one primary segment, with gaps. - // All segments are clipped to the progress bar's bounds. - // Something like this: - // (-----s1-----) (--p--) (-----s2-----) - // ^ gap ^ - const Pixel s1_start = start_x; - const Pixel s1_end = std::min(primary_start - kProgressBarGap, end_x); - if (s1_start < s1_end) { - draw_list->AddRectFilled( - ImVec2(s1_start, y), ImVec2(s1_end, y + kProgressBarHeight), - kSecondary90, kProgressBarCornerRadius, ImDrawFlags_RoundCornersAll); - } - - const Pixel p_start = std::max(primary_start, start_x); - const Pixel p_end = std::min(primary_end, end_x); - if (p_start < p_end) { - draw_list->AddRectFilled( - ImVec2(p_start, y), ImVec2(p_end, y + kProgressBarHeight), kPrimary40, - kProgressBarCornerRadius, ImDrawFlags_RoundCornersAll); - } - - const Pixel s2_start = std::max(primary_end + kProgressBarGap, start_x); - const Pixel s2_end = end_x; - if (s2_start < s2_end) { - draw_list->AddRectFilled( - ImVec2(s2_start, y), ImVec2(s2_end, y + kProgressBarHeight), - kSecondary90, kProgressBarCornerRadius, ImDrawFlags_RoundCornersAll); - } - - DrawLoadingText(viewport); - - DrawTutorialText(draw_list, center, time); -} - -} // namespace traceviewer diff --git a/frontend/app/components/trace_viewer_v2/timeline/draw_utils.h b/frontend/app/components/trace_viewer_v2/timeline/draw_utils.h deleted file mode 100644 index 0f738cbd..00000000 --- a/frontend/app/components/trace_viewer_v2/timeline/draw_utils.h +++ /dev/null @@ -1,13 +0,0 @@ -#ifndef THIRD_PARTY_XPROF_FRONTEND_APP_COMPONENTS_TRACE_VIEWER_V2_TIMELINE_DRAW_UTILS_H_ -#define THIRD_PARTY_XPROF_FRONTEND_APP_COMPONENTS_TRACE_VIEWER_V2_TIMELINE_DRAW_UTILS_H_ - -#include "third_party/dear_imgui/imgui.h" - -namespace traceviewer { - -// Draws a loading indicator in the center of the viewport. -void DrawLoadingIndicator(const ImGuiViewport* viewport); - -} // namespace traceviewer - -#endif // THIRD_PARTY_XPROF_FRONTEND_APP_COMPONENTS_TRACE_VIEWER_V2_TIMELINE_DRAW_UTILS_H_ diff --git a/frontend/app/components/trace_viewer_v2/timeline/timeline.cc b/frontend/app/components/trace_viewer_v2/timeline/timeline.cc index 32ef8ed8..36c6fa5c 100644 --- a/frontend/app/components/trace_viewer_v2/timeline/timeline.cc +++ b/frontend/app/components/trace_viewer_v2/timeline/timeline.cc @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include "absl/base/nullability.h" #include "absl/log/log.h" @@ -19,7 +21,6 @@ #include "xprof/frontend/app/components/trace_viewer_v2/event_data.h" #include "xprof/frontend/app/components/trace_viewer_v2/helper/time_formatter.h" #include "xprof/frontend/app/components/trace_viewer_v2/timeline/constants.h" -#include "xprof/frontend/app/components/trace_viewer_v2/timeline/draw_utils.h" #include "xprof/frontend/app/components/trace_viewer_v2/timeline/time_range.h" #include "xprof/frontend/app/components/trace_viewer_v2/trace_helper/trace_event.h" @@ -51,6 +52,13 @@ void Timeline::SetVisibleRange(const TimeRange& range, bool animate) { } } +void Timeline::SetTimelineData(FlameChartTimelineData data) { + // Calculate offsets first to avoid partial state in group_offsets_ member. + std::vector new_offsets = CalculateGroupOffsets(data); + timeline_data_ = std::move(data); + group_offsets_ = std::move(new_offsets); +} + void Timeline::Draw() { event_clicked_this_frame_ = false; @@ -59,16 +67,8 @@ void Timeline::Draw() { ImGui::SetNextWindowSize(viewport->Size); ImGui::SetNextWindowViewport(viewport->ID); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.f); - ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(0.0f, 0.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f)); - ImGui::Begin("Timeline viewer", nullptr, kImGuiWindowFlags); - if (timeline_data_.groups.empty()) { - DrawLoadingIndicator(viewport); - } - const Pixel timeline_width = ImGui::GetContentRegionAvail().x - label_width_ - kTimelinePaddingRight; const double px_per_time_unit_val = px_per_time_unit(timeline_width); @@ -88,8 +88,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(); @@ -107,6 +142,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(); @@ -127,10 +171,7 @@ void Timeline::Draw() { // window, without affecting global foreground elements like tooltips. DrawSelectedTimeRanges(timeline_width, px_per_time_unit_val); - ImGui::EndChild(); - ImGui::PopStyleVar(); // ItemSpacing - ImGui::PopStyleVar(); // CellPadding - ImGui::PopStyleVar(); // WindowRounding + ImGui::EndChild(); // Tracks ImGui::End(); // Timeline viewer } @@ -345,6 +386,56 @@ double Timeline::px_per_time_unit(Pixel timeline_width) const { } } +std::vector Timeline::CalculateGroupOffsets( + const FlameChartTimelineData& data) const { + std::vector offsets; + // There is one more element in offsets than in groups to store the total + // height. + offsets.reserve(data.groups.size() + 1); + + float current_offset = 0.0f; + 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(); + + // 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); + current_offset += group_height; + offsets.push_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. @@ -690,18 +781,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. diff --git a/frontend/app/components/trace_viewer_v2/timeline/timeline.h b/frontend/app/components/trace_viewer_v2/timeline/timeline.h index d1347e7b..e0c35f98 100644 --- a/frontend/app/components/trace_viewer_v2/timeline/timeline.h +++ b/frontend/app/components/trace_viewer_v2/timeline/timeline.h @@ -113,11 +113,12 @@ 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_; } + 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_; } @@ -176,6 +177,19 @@ class Timeline { // zooming behavior. virtual void Zoom(float zoom_factor); + // 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 std::vector CalculateGroupOffsets( + 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; @@ -238,10 +252,17 @@ 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_; + // 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 e5f2049a..b8ebcd0b 100644 --- a/frontend/app/components/trace_viewer_v2/timeline/timeline_test.cc +++ b/frontend/app/components/trace_viewer_v2/timeline/timeline_test.cc @@ -1,11 +1,14 @@ #include "xprof/frontend/app/components/trace_viewer_v2/timeline/timeline.h" #include +#include #include #include +#include #include "testing/base/public/gmock.h" #include "" +#include "absl/strings/match.h" #include "absl/strings/string_view.h" #include "third_party/dear_imgui/imgui.h" #include "third_party/dear_imgui/imgui_internal.h" @@ -41,13 +44,48 @@ 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, CalculateGroupOffsets) { + 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). Group 2 starts at 3, so it's empty. + data.events_by_level.resize(3); + + 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 +} + TEST(TimelineTest, SetVisibleRange) { Timeline timeline; TimeRange range(10.0, 50.0); @@ -527,7 +565,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}); @@ -547,7 +585,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); @@ -562,7 +600,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); @@ -573,6 +611,186 @@ 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 CalculateGroupOffsets to verify internal state during updates. + std::vector CalculateGroupOffsets( + const FlameChartTimelineData& data) const override { + if (on_calculate_group_offsets) { + on_calculate_group_offsets(); + } + return Timeline::CalculateGroupOffsets(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 { @@ -580,13 +798,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( {{}, {}, {}, @@ -834,24 +1060,24 @@ TEST_F(MockTimelineImGuiFixture, ShiftClickAndReleaseShiftMidDragContinuesSelection) { // Setup similar to TimelineDragSelectionTest to ensure predictable // coordinates. - timeline_.SetVisibleRange({0.0, 165.3}); - timeline_.set_data_time_range({0.0, 165.3}); + timeline_.SetVisibleRange({0.0, 166.9}); + timeline_.set_data_time_range({0.0, 166.9}); ImGuiIO& io = ImGui::GetIO(); // Start with Shift held down. io.AddKeyEvent(ImGuiMod_Shift, true); // Start drag in timeline area. - // X=308 is safely inside the timeline (250 + padding < 308). - io.MousePos = ImVec2(308.0f, 50.0f); + // X=300 is safely inside the timeline (250 + padding < 300). + io.MousePos = ImVec2(300.0f, 50.0f); io.AddMouseButtonEvent(0, true); SimulateFrame(); // Release Shift key. io.AddKeyEvent(ImGuiMod_Shift, false); - // Drag mouse to X=508. - io.MousePos = ImVec2(508.0f, 50.0f); + // Drag mouse to X=500. + io.MousePos = ImVec2(500.0f, 50.0f); SimulateFrame(); // End drag. @@ -863,22 +1089,22 @@ TEST_F(MockTimelineImGuiFixture, const TimeRange& range = timeline_.selected_time_ranges()[0]; // Calculate expected range based on pixel movement. // 10px/us assumption from TimelineDragSelectionTest. - // 308 -> 5.0. 508 -> 25.0. + // 300 -> 5.0. 500 -> 25.0. EXPECT_NEAR(range.start(), 5.0, 1e-5); EXPECT_NEAR(range.end(), 25.0, 1e-5); } TEST_F(MockTimelineImGuiFixture, ClickAndPressShiftMidDragContinuesPanning) { // Setup similar to TimelineDragSelectionTest. - timeline_.SetVisibleRange({0.0, 165.3}); - timeline_.set_data_time_range({0.0, 165.3}); + timeline_.SetVisibleRange({0.0, 166.9}); + timeline_.set_data_time_range({0.0, 166.9}); ImGuiIO& io = ImGui::GetIO(); // Start without Shift. io.AddKeyEvent(ImGuiMod_Shift, false); // Start drag in timeline area. - io.MousePos = ImVec2(308.0f, 50.0f); + io.MousePos = ImVec2(300.0f, 50.0f); io.AddMouseButtonEvent(0, true); EXPECT_CALL(timeline_, Pan(0.0f)); @@ -889,8 +1115,8 @@ TEST_F(MockTimelineImGuiFixture, ClickAndPressShiftMidDragContinuesPanning) { io.AddKeyEvent(ImGuiMod_Shift, true); // Drag mouse to left (simulate pan right). - // Move from 308 to 208 (-100px). - io.MousePos = ImVec2(208.0f, 50.0f); + // Move from 300 to 200 (-100px). + io.MousePos = ImVec2(200.0f, 50.0f); EXPECT_CALL(timeline_, Pan(FloatEq(100.0f))); EXPECT_CALL(timeline_, Scroll(FloatEq(0.0f))); @@ -914,7 +1140,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 @@ -939,7 +1165,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). @@ -963,7 +1189,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; @@ -978,7 +1204,7 @@ TEST_F(RealTimelineImGuiFixture, ClickEventSelectsEvent) { // Set a mouse position that is guaranteed to be over the event, since the // event spans the entire timeline. - ImGui::GetIO().MousePos = ImVec2(300.f, 40.f); + ImGui::GetIO().MousePos = ImVec2(300.f, 30.f); ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); @@ -1002,7 +1228,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; @@ -1031,7 +1257,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; @@ -1040,7 +1266,7 @@ TEST_F(RealTimelineImGuiFixture, callback_count++; }); - ImGui::GetIO().MousePos = ImVec2(300.f, 40.f); + ImGui::GetIO().MousePos = ImVec2(300.f, 30.f); // First click. ImGui::GetIO().MouseDown[0] = true; @@ -1070,11 +1296,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, 40.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. @@ -1113,11 +1339,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, 40.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. @@ -1164,7 +1390,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; @@ -1183,8 +1409,10 @@ TEST_F(RealTimelineImGuiFixture, ClickEmptyAreaWhenNoEventSelectedDoesNothing) { EXPECT_FALSE(callback_called); } -TEST_F(RealTimelineImGuiFixture, DrawsLoadingAnimationWhenTimelineDataIsEmpty) { - timeline_.set_timeline_data({}); +// This is a test for making sure the wasm is not crashing when timeline data +// is empty. +TEST_F(RealTimelineImGuiFixture, DrawsTimelineWindowWhenTimelineDataIsEmpty) { + timeline_.SetTimelineData({}); // We don't use SimulateFrame() here because we need to inspect the draw list // before ImGui::EndFrame() is called. @@ -1193,12 +1421,6 @@ TEST_F(RealTimelineImGuiFixture, DrawsLoadingAnimationWhenTimelineDataIsEmpty) { EXPECT_NE(ImGui::FindWindowByName("Timeline viewer"), nullptr); - ImDrawList* draw_list = ImGui::GetForegroundDrawList(); - - ASSERT_NE(draw_list, nullptr); - // The loading animation should be drawn to the foreground draw list. - EXPECT_FALSE(draw_list->VtxBuffer.empty()); - ImGui::EndFrame(); } @@ -1211,11 +1433,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, 40.f); + ImGui::GetIO().MousePos = ImVec2(500.f, 30.f); ImGui::GetIO().AddKeyEvent(ImGuiMod_Shift, true); ImGui::GetIO().MouseDown[0] = true; @@ -1255,13 +1477,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, 40.f); // Position over event 1. + ImGui::GetIO().MousePos = ImVec2(500.f, 30.f); // Position over event 1. ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); @@ -1274,7 +1496,7 @@ TEST_F(RealTimelineImGuiFixture, SimulateFrame(); // Second shift-click on event 2. - ImGui::GetIO().MousePos = ImVec2(1100.f, 40.f); // Position over event 2. + ImGui::GetIO().MousePos = ImVec2(1100.f, 30.f); // Position over event 2. ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); @@ -1289,7 +1511,7 @@ TEST_F(RealTimelineImGuiFixture, SimulateFrame(); // Third shift-click on event 1 again to deselect. - ImGui::GetIO().MousePos = ImVec2(500.f, 40.f); // Position over event 1. + ImGui::GetIO().MousePos = ImVec2(500.f, 30.f); // Position over event 1. ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); @@ -1350,11 +1572,11 @@ class TimelineDragSelectionTest : public RealTimelineImGuiFixture { void SetUp() override { 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 1653px - // (based on 1920px window width and paddings), a duration of 165.3 gives + // to make test calculations predictable. With a timeline width of 1669px + // (based on 1920px window width and no paddings), a duration of 166.9 gives // 10px per microsecond. - timeline_.SetVisibleRange({0.0, 165.3}); - timeline_.set_data_time_range({0.0, 165.3}); + timeline_.SetVisibleRange({0.0, 166.9}); + timeline_.set_data_time_range({0.0, 166.9}); ImGui::GetIO().AddKeyEvent(ImGuiMod_Shift, true); } @@ -1370,12 +1592,12 @@ TEST_F(TimelineDragSelectionTest, ShiftDragCreatesTimeSelection) { // Start drag in timeline area. // The label column is 250px wide, so timeline starts after that. - io.MousePos = ImVec2(308.0f, 50.0f); + io.MousePos = ImVec2(300.0f, 50.0f); io.AddMouseButtonEvent(0, true); SimulateFrame(); // Dragging - io.MousePos = ImVec2(508.0f, 50.0f); + io.MousePos = ImVec2(500.0f, 50.0f); SimulateFrame(); // End drag @@ -1392,10 +1614,10 @@ TEST_F(TimelineDragSelectionTest, ShiftDragCreatesMultipleTimeSelections) { ImGuiIO& io = ImGui::GetIO(); // First drag - io.MousePos = ImVec2(308.0f, 50.0f); + io.MousePos = ImVec2(300.0f, 50.0f); io.AddMouseButtonEvent(0, true); SimulateFrame(); - io.MousePos = ImVec2(408.0f, 50.0f); + io.MousePos = ImVec2(400.0f, 50.0f); SimulateFrame(); io.AddMouseButtonEvent(0, false); SimulateFrame(); @@ -1405,10 +1627,10 @@ TEST_F(TimelineDragSelectionTest, ShiftDragCreatesMultipleTimeSelections) { EXPECT_DOUBLE_EQ(timeline_.selected_time_ranges()[0].end(), 15.0); // Second drag - io.MousePos = ImVec2(508.0f, 50.0f); + io.MousePos = ImVec2(500.0f, 50.0f); io.AddMouseButtonEvent(0, true); SimulateFrame(); - io.MousePos = ImVec2(608.0f, 50.0f); + io.MousePos = ImVec2(600.0f, 50.0f); SimulateFrame(); io.AddMouseButtonEvent(0, false); SimulateFrame(); @@ -1424,12 +1646,12 @@ TEST_F(TimelineDragSelectionTest, DraggingUpdatesCurrentSelectedTimeRange) { ImGuiIO& io = ImGui::GetIO(); // Start drag in timeline area. - io.MousePos = ImVec2(308.0f, 50.0f); + io.MousePos = ImVec2(300.0f, 50.0f); io.AddMouseButtonEvent(0, true); SimulateFrame(); // Dragging - io.MousePos = ImVec2(508.0f, 50.0f); + io.MousePos = ImVec2(500.0f, 50.0f); SimulateFrame(); // During drag, current_selected_time_range_ should be set, but @@ -1465,7 +1687,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(); @@ -1504,7 +1726,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. @@ -1581,11 +1803,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, 40.f); + ImGui::GetIO().MousePos = ImVec2(300.f, 30.f); ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); @@ -1609,7 +1831,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(); @@ -1673,11 +1895,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, 40.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; @@ -1716,7 +1938,7 @@ TEST_F(RealTimelineImGuiFixture, SelectionMutualExclusion) { EXPECT_EQ(timeline_.selected_counter_index(), 0); // Step 3: Select Flame Event Again - ImGui::GetIO().MousePos = ImVec2(300.f, 40.f); + ImGui::GetIO().MousePos = ImVec2(300.f, 30.f); ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); @@ -1734,11 +1956,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, 40.f); + ImGui::GetIO().MousePos = ImVec2(300.f, 30.f); ImGui::GetIO().MouseDown[0] = true; SimulateFrame(); ImGui::GetIO().MouseDown[0] = false; @@ -1756,6 +1978,114 @@ TEST_F(RealTimelineImGuiFixture, ClickEmptyAreaClearsSelectionIndices) { EXPECT_EQ(timeline_.selected_counter_index(), -1); } +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 diff --git a/frontend/app/components/trace_viewer_v2/trace_helper/trace_event_parser.cc b/frontend/app/components/trace_viewer_v2/trace_helper/trace_event_parser.cc index add29694..4a0e8982 100644 --- a/frontend/app/components/trace_viewer_v2/trace_helper/trace_event_parser.cc +++ b/frontend/app/components/trace_viewer_v2/trace_helper/trace_event_parser.cc @@ -141,6 +141,7 @@ void ParseAndProcessTraceEvents(const emscripten::val& trace_data) { Application::Instance().data_provider().ProcessTraceEvents( parsed_events, Application::Instance().timeline()); + Application::Instance().RequestRedraw(); } EMSCRIPTEN_BINDINGS(trace_event_parser) {