From a760edacf1dbc5c9dd7f1d51990775e1114cddf0 Mon Sep 17 00:00:00 2001 From: Mick Date: Wed, 25 Mar 2026 19:43:16 -0400 Subject: [PATCH] feat: Details panel - siblings browser tab (#81) Extract reusable EventBrowser component from the children tab's table/filter/sort/aggregation logic, then use it for both children and the new siblings tab. Siblings (events sharing a direct parent) are shown when depth > 0, grouped by name by default to collapse loop noise. The list can be ungrouped to view individual events. Co-Authored-By: Claude Opus 4.6 --- src/ui/INDEX.md | 13 +- src/ui/detail_panel.cpp | 352 +++++---------------------------- src/ui/detail_panel.h | 54 ++--- src/ui/event_browser.cpp | 349 ++++++++++++++++++++++++++++++++ src/ui/event_browser.h | 59 ++++++ tests/test_detail_siblings.cpp | 161 +++++++++++++++ 6 files changed, 651 insertions(+), 337 deletions(-) create mode 100644 src/ui/event_browser.cpp create mode 100644 src/ui/event_browser.h create mode 100644 tests/test_detail_siblings.cpp diff --git a/src/ui/INDEX.md b/src/ui/INDEX.md index 117c661..ac9f9ec 100644 --- a/src/ui/INDEX.md +++ b/src/ui/INDEX.md @@ -65,7 +65,18 @@ DiagStats diag_stats; // written each frame, read by DiagnosticsPanel static int32_t select_best_candidate(const std::vector& candidates, const std::vector& events, const std::unordered_set& hidden_cats, int clicked_depth, double click_time, double tolerance); ``` -## detail_panel.h / detail_panel.cpp — selected-event details: timing, args, call stack, children table, range summary +## event_browser.h / event_browser.cpp — reusable sortable/filterable event list with group-by-name aggregation +``` +explicit EventBrowser(bool default_group_by_name = false); +void set_entries(std::vector entries, double parent_dur, const TraceModel& model); +void render(const char* id, const TraceModel& model, ViewState& view); +size_t entry_count() const; +void reset(); +// Entry: event_idx, name_idx, dur, pct +// Aggregated: name_idx, count, total_dur, avg_dur, min_dur, max_dur, pct, longest_idx +``` + +## detail_panel.h / detail_panel.cpp — selected-event details: timing, args, call stack, children table, siblings table, range summary ``` void render(const TraceModel&, ViewState&); void on_model_changed(); diff --git a/src/ui/detail_panel.cpp b/src/ui/detail_panel.cpp index 679932b..694361f 100644 --- a/src/ui/detail_panel.cpp +++ b/src/ui/detail_panel.cpp @@ -1,16 +1,12 @@ #include "detail_panel.h" #include "format_time.h" -#include "sort_utils.h" -#include "string_utils.h" #include "tracing.h" #include "imgui.h" #include "imgui_internal.h" #include #include -#include #include #include -#include namespace { struct VisibleStackEntry { @@ -29,24 +25,16 @@ void DetailPanel::reset() { cached_event_idx_ = -1; include_all_descendants_ = false; cached_descendants_flag_ = false; - children_dirty_ = false; - group_by_name_ = false; - cached_group_flag_ = false; + self_time_ = 0.0; + self_pct_ = 0.0f; + children_browser_.reset(); + cached_siblings_event_idx_ = -1; + siblings_browser_.reset(); cached_stack_event_idx_ = -1; cached_call_stack_.clear(); cached_stack_children_.clear(); stack_collapsed_.clear(); stack_has_children_.clear(); - filter_buf_[0] = '\0'; - active_filter_.clear(); - children_.clear(); - filtered_children_.clear(); - aggregated_.clear(); - filtered_aggregated_.clear(); - self_time_ = 0.0; - self_pct_ = 0.0f; - scroll_children_to_top_ = false; - scroll_aggregated_to_top_ = false; } void DetailPanel::on_model_changed() { @@ -274,7 +262,6 @@ void DetailPanel::render(const TraceModel& model, ViewState& view) { cached_event_idx_ = view.selected_event_idx(); cached_descendants_flag_ = include_all_descendants_; rebuild_children(model, ev); - children_dirty_ = true; } } @@ -353,7 +340,7 @@ void DetailPanel::render(const TraceModel& model, ViewState& view) { ImGui::Separator(); - // --- Tab bar for Children / Call Stack / Arguments --- + // --- Tab bar for Children / Siblings / Call Stack / Arguments --- if (ImGui::BeginTabBar("##DetailTabs")) { // Children tab { @@ -365,45 +352,39 @@ void DetailPanel::render(const TraceModel& model, ViewState& view) { cached_event_idx_ = view.selected_event_idx(); cached_descendants_flag_ = include_all_descendants_; rebuild_children(model, current_ev); - children_dirty_ = true; - } - - if (cached_group_flag_ != group_by_name_ || children_dirty_) { - cached_group_flag_ = group_by_name_; - if (group_by_name_) { - rebuild_aggregated(model, current_ev.dur); - } - rebuild_filter(model); } } char children_label[32]; - snprintf(children_label, sizeof(children_label), "Children (%zu)###Children", children_.size()); + snprintf(children_label, sizeof(children_label), "Children (%zu)###Children", + children_browser_.entry_count()); if (ImGui::BeginTabItem(children_label)) { - if (children_.empty()) { + if (children_browser_.entry_count() == 0) { ImGui::TextDisabled("No children."); } else { ImGui::Checkbox("Include all descendants", &include_all_descendants_); ImGui::SameLine(); - ImGui::Checkbox("Group by name", &group_by_name_); - - ImGui::SetNextItemWidth(-1); - if (ImGui::InputTextWithHint("##filter", "Filter by name...", filter_buf_, sizeof(filter_buf_))) { - rebuild_filter(model); - } - if (filter_buf_[0] != '\0') { - size_t shown = group_by_name_ ? filtered_aggregated_.size() : filtered_children_.size(); - size_t total = group_by_name_ ? aggregated_.size() : children_.size(); - ImGui::TextDisabled("Showing %zu / %zu", shown, total); - } + children_browser_.render("children", model, view); + } + ImGui::EndTabItem(); + } + } - ImGui::Spacing(); + // Siblings tab (only for events with depth > 0) + if (ev.depth > 0) { + if (cached_siblings_event_idx_ != view.selected_event_idx()) { + cached_siblings_event_idx_ = view.selected_event_idx(); + rebuild_siblings(model, ev, (uint32_t)view.selected_event_idx()); + } - if (group_by_name_) { - render_aggregated_table(model, view); - } else { - render_children_table(model, view); - } + char siblings_label[32]; + snprintf(siblings_label, sizeof(siblings_label), "Siblings (%zu)###Siblings", + siblings_browser_.entry_count()); + if (ImGui::BeginTabItem(siblings_label)) { + if (siblings_browser_.entry_count() == 0) { + ImGui::TextDisabled("No siblings."); + } else { + siblings_browser_.render("siblings", model, view); } ImGui::EndTabItem(); } @@ -703,217 +684,9 @@ void DetailPanel::render(const TraceModel& model, ViewState& view) { ImGui::End(); } -void DetailPanel::render_aggregated_table(const TraceModel& model, ViewState& view) { - if (ImGui::BeginTable("AggChildrenTable", 7, - ImGuiTableFlags_Sortable | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersOuter | - ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable, - ImVec2(0, ImGui::GetContentRegionAvail().y))) { - ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_None, 0.0f, 0); - ImGui::TableSetupColumn("Count", ImGuiTableColumnFlags_None, 0.0f, 1); - ImGui::TableSetupColumn("Total", ImGuiTableColumnFlags_DefaultSort | ImGuiTableColumnFlags_PreferSortDescending, - 0.0f, 2); - ImGui::TableSetupColumn("Avg", ImGuiTableColumnFlags_None, 0.0f, 3); - ImGui::TableSetupColumn("Min", ImGuiTableColumnFlags_None, 0.0f, 4); - ImGui::TableSetupColumn("Max", ImGuiTableColumnFlags_None, 0.0f, 5); - ImGui::TableSetupColumn("%", ImGuiTableColumnFlags_None, 0.0f, 6); - ImGui::TableHeadersRow(); - - if (ImGuiTableSortSpecs* sort_specs = ImGui::TableGetSortSpecs()) { - if (children_dirty_) { - sort_specs->SpecsDirty = true; - } - if (sort_specs->SpecsDirty) { - bool user_sorted = !children_dirty_; - sort_specs->SpecsDirty = false; - children_dirty_ = false; - if (sort_specs->SpecsCount > 0) { - const auto& spec = sort_specs->Specs[0]; - bool asc = (spec.SortDirection == ImGuiSortDirection_Ascending); - std::sort(filtered_aggregated_.begin(), filtered_aggregated_.end(), [&](size_t ai, size_t bi) { - const auto& a = aggregated_[ai]; - const auto& b = aggregated_[bi]; - int cmp = 0; - switch (spec.ColumnUserID) { - case 0: { - const auto& na = model.get_string(a.name_idx); - const auto& nb = model.get_string(b.name_idx); - cmp = na.compare(nb); - break; - } - case 1: - cmp = sort_utils::three_way_cmp(a.count, b.count); - break; - case 2: - cmp = sort_utils::three_way_cmp(a.total_dur, b.total_dur); - break; - case 3: - cmp = sort_utils::three_way_cmp(a.avg_dur, b.avg_dur); - break; - case 4: - cmp = sort_utils::three_way_cmp(a.min_dur, b.min_dur); - break; - case 5: - cmp = sort_utils::three_way_cmp(a.max_dur, b.max_dur); - break; - case 6: - cmp = sort_utils::three_way_cmp(a.pct, b.pct); - break; - } - return asc ? (cmp < 0) : (cmp > 0); - }); - } - if (user_sorted) { - scroll_aggregated_to_top_ = true; - } - } - } - - if (scroll_aggregated_to_top_) { - ImGui::SetScrollY(0.0f); - scroll_aggregated_to_top_ = false; - } - - char buf[64]; - ImGuiListClipper clipper; - clipper.Begin((int)filtered_aggregated_.size()); - while (clipper.Step()) { - for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) { - const auto& ag = aggregated_[filtered_aggregated_[i]]; - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - char id_buf[32]; - snprintf(id_buf, sizeof(id_buf), "##ag%d", i); - if (ImGui::Selectable(id_buf, false, - ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { - view.navigate_to_event(ag.longest_idx, model.events()[ag.longest_idx]); - } - ImGui::SameLine(); - ImGui::TextUnformatted(model.get_string(ag.name_idx).c_str()); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) - ImGui::SetTooltip("%s", model.get_string(ag.name_idx).c_str()); - - ImGui::TableNextColumn(); - ImGui::Text("%u", ag.count); - - ImGui::TableNextColumn(); - format_time(ag.total_dur, buf, sizeof(buf)); - ImGui::TextUnformatted(buf); - - ImGui::TableNextColumn(); - format_time(ag.avg_dur, buf, sizeof(buf)); - ImGui::TextUnformatted(buf); - - ImGui::TableNextColumn(); - format_time(ag.min_dur, buf, sizeof(buf)); - ImGui::TextUnformatted(buf); - - ImGui::TableNextColumn(); - format_time(ag.max_dur, buf, sizeof(buf)); - ImGui::TextUnformatted(buf); - - ImGui::TableNextColumn(); - render_heat_bar(ag.pct); - } - } - - ImGui::EndTable(); - } -} - -void DetailPanel::render_children_table(const TraceModel& model, ViewState& view) { - if (ImGui::BeginTable("ChildrenTable", 3, - ImGuiTableFlags_Sortable | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersOuter | - ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable, - ImVec2(0, ImGui::GetContentRegionAvail().y))) { - ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_None, 0.0f, 0); - ImGui::TableSetupColumn( - "Duration", ImGuiTableColumnFlags_DefaultSort | ImGuiTableColumnFlags_PreferSortDescending, 0.0f, 1); - ImGui::TableSetupColumn("%", ImGuiTableColumnFlags_None, 0.0f, 2); - ImGui::TableHeadersRow(); - - if (ImGuiTableSortSpecs* sort_specs = ImGui::TableGetSortSpecs()) { - if (children_dirty_) { - sort_specs->SpecsDirty = true; - } - if (sort_specs->SpecsDirty) { - bool user_sorted = !children_dirty_; - sort_specs->SpecsDirty = false; - children_dirty_ = false; - if (sort_specs->SpecsCount > 0) { - const auto& spec = sort_specs->Specs[0]; - bool asc = (spec.SortDirection == ImGuiSortDirection_Ascending); - std::sort(filtered_children_.begin(), filtered_children_.end(), [&](size_t ai, size_t bi) { - const auto& a = children_[ai]; - const auto& b = children_[bi]; - int cmp = 0; - switch (spec.ColumnUserID) { - case 0: { - const auto& na = model.get_string(a.name_idx); - const auto& nb = model.get_string(b.name_idx); - cmp = na.compare(nb); - break; - } - case 1: - cmp = sort_utils::three_way_cmp(a.dur, b.dur); - break; - case 2: - cmp = sort_utils::three_way_cmp(a.pct, b.pct); - break; - } - return asc ? (cmp < 0) : (cmp > 0); - }); - } - if (user_sorted) { - scroll_children_to_top_ = true; - } - } - } - - if (scroll_children_to_top_) { - ImGui::SetScrollY(0.0f); - scroll_children_to_top_ = false; - } - - char buf[64]; - ImGuiListClipper clipper; - clipper.Begin((int)filtered_children_.size()); - while (clipper.Step()) { - for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) { - const auto& c = children_[filtered_children_[i]]; - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - char id_buf[32]; - snprintf(id_buf, sizeof(id_buf), "##c%d", i); - bool is_selected = (view.selected_event_idx() == (int32_t)c.event_idx); - if (ImGui::Selectable(id_buf, is_selected, - ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { - view.navigate_to_event(c.event_idx, model.events()[c.event_idx]); - } - ImGui::SameLine(); - ImGui::TextUnformatted(model.get_string(c.name_idx).c_str()); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) - ImGui::SetTooltip("%s", model.get_string(c.name_idx).c_str()); - - ImGui::TableNextColumn(); - format_time(c.dur, buf, sizeof(buf)); - ImGui::TextUnformatted(buf); - - ImGui::TableNextColumn(); - render_heat_bar(c.pct); - } - } - - ImGui::EndTable(); - } -} - void DetailPanel::rebuild_children(const TraceModel& model, const TraceEvent& ev) { TRACE_FUNCTION_CAT("ui"); - children_.clear(); + std::vector entries; double immediate_children_total = 0.0; if (const auto* thread = model.find_thread(ev.pid, ev.tid)) { @@ -932,7 +705,7 @@ void DetailPanel::rebuild_children(const TraceModel& model, const TraceEvent& ev if (include_all_descendants_ || child.depth == ev.depth + 1) { float pct = (float)(child.dur / ev.dur * 100.0); - children_.push_back({idx, child.name_idx, child.dur, pct}); + entries.push_back({idx, child.name_idx, child.dur, pct}); } if (child.ts > ev.end_ts()) break; @@ -941,53 +714,36 @@ void DetailPanel::rebuild_children(const TraceModel& model, const TraceEvent& ev self_time_ = ev.dur - immediate_children_total; self_pct_ = (float)(self_time_ / ev.dur * 100.0); + children_browser_.set_entries(std::move(entries), ev.dur, model); } -void DetailPanel::rebuild_aggregated(const TraceModel& model, double parent_dur) { - aggregated_.clear(); - - // Group by name_idx using a map to accumulator index - std::unordered_map name_to_idx; - name_to_idx.reserve(children_.size() / 4); - - for (const auto& c : children_) { - auto it = name_to_idx.find(c.name_idx); - if (it == name_to_idx.end()) { - name_to_idx[c.name_idx] = aggregated_.size(); - aggregated_.push_back({c.name_idx, 1, c.dur, 0.0, c.dur, c.dur, 0.0f, c.event_idx}); - } else { - auto& ag = aggregated_[it->second]; - ag.count++; - ag.total_dur += c.dur; - if (c.dur < ag.min_dur) ag.min_dur = c.dur; - if (c.dur > ag.max_dur) ag.max_dur = c.dur; - if (c.dur > model.events()[ag.longest_idx].dur) { - ag.longest_idx = c.event_idx; - } - } - } +void DetailPanel::rebuild_siblings(const TraceModel& model, const TraceEvent& ev, uint32_t event_idx) { + TRACE_FUNCTION_CAT("ui"); + std::vector entries; - for (auto& ag : aggregated_) { - ag.avg_dur = ag.total_dur / ag.count; - ag.pct = (float)(ag.total_dur / parent_dur * 100.0); + if (ev.parent_idx < 0) { + siblings_browser_.set_entries({}, 0.0, model); + return; } -} - -void DetailPanel::rebuild_filter(const TraceModel& model) { - TRACE_FUNCTION_CAT("ui"); - active_filter_ = filter_buf_; - filtered_children_.clear(); - for (size_t i = 0; i < children_.size(); i++) { - if (contains_case_insensitive(model.get_string(children_[i].name_idx), active_filter_)) { - filtered_children_.push_back(i); - } + const auto* thread = model.find_thread(ev.pid, ev.tid); + if (!thread) { + siblings_browser_.set_entries({}, 0.0, model); + return; } - filtered_aggregated_.clear(); - for (size_t i = 0; i < aggregated_.size(); i++) { - if (contains_case_insensitive(model.get_string(aggregated_[i].name_idx), active_filter_)) { - filtered_aggregated_.push_back(i); - } + const auto& parent = model.events()[ev.parent_idx]; + for (uint32_t idx : thread->event_indices) { + const auto& sib = model.events()[idx]; + if (sib.ts >= parent.end_ts()) break; + if (sib.depth != ev.depth) continue; + if (sib.parent_idx != ev.parent_idx) continue; + if (sib.dur <= 0) continue; + if (idx == event_idx) continue; + + float pct = parent.dur > 0 ? (float)(sib.dur / parent.dur * 100.0) : 0.0f; + entries.push_back({idx, sib.name_idx, sib.dur, pct}); } + + siblings_browser_.set_entries(std::move(entries), parent.dur, model); } diff --git a/src/ui/detail_panel.h b/src/ui/detail_panel.h index b80dd25..1ba5ef9 100644 --- a/src/ui/detail_panel.h +++ b/src/ui/detail_panel.h @@ -1,5 +1,6 @@ #pragma once #include "model/trace_model.h" +#include "ui/event_browser.h" #include "ui/view_state.h" #include "ui/range_stats.h" #include @@ -19,49 +20,26 @@ class DetailPanel { void render_range_selection(const TraceModel& model, ViewState& view); - struct ChildInfo { - uint32_t event_idx; - uint32_t name_idx; - double dur; - float pct; - }; - - struct AggregatedChild { - uint32_t name_idx; - uint32_t count; - double total_dur; - double avg_dur; - double min_dur; - double max_dur; - float pct; // total_dur as % of parent - uint32_t longest_idx; // event_idx of longest instance - }; - + // Children tab int32_t cached_event_idx_ = -1; bool include_all_descendants_ = false; bool cached_descendants_flag_ = false; - bool children_dirty_ = false; - bool group_by_name_ = false; - bool cached_group_flag_ = false; - int32_t cached_stack_event_idx_ = -1; - std::vector cached_call_stack_; - std::vector cached_stack_children_; - std::unordered_set stack_collapsed_; // collapsed event indices - std::unordered_set stack_has_children_; // events that have children in the tree - char filter_buf_[256] = {}; - std::string active_filter_; - std::vector children_; - std::vector filtered_children_; - std::vector aggregated_; - std::vector filtered_aggregated_; double self_time_ = 0.0; float self_pct_ = 0.0f; - bool scroll_children_to_top_ = false; - bool scroll_aggregated_to_top_ = false; + EventBrowser children_browser_; void rebuild_children(const TraceModel& model, const TraceEvent& ev); - void rebuild_aggregated(const TraceModel& model, double parent_dur); - void rebuild_filter(const TraceModel& model); - void render_children_table(const TraceModel& model, ViewState& view); - void render_aggregated_table(const TraceModel& model, ViewState& view); + + // Siblings tab + int32_t cached_siblings_event_idx_ = -1; + EventBrowser siblings_browser_{/*default_group_by_name=*/true}; + + void rebuild_siblings(const TraceModel& model, const TraceEvent& ev, uint32_t event_idx); + + // Call stack tab + int32_t cached_stack_event_idx_ = -1; + std::vector cached_call_stack_; + std::vector cached_stack_children_; + std::unordered_set stack_collapsed_; + std::unordered_set stack_has_children_; }; diff --git a/src/ui/event_browser.cpp b/src/ui/event_browser.cpp new file mode 100644 index 0000000..a032712 --- /dev/null +++ b/src/ui/event_browser.cpp @@ -0,0 +1,349 @@ +#include "event_browser.h" +#include "format_time.h" +#include "sort_utils.h" +#include "string_utils.h" +#include "tracing.h" +#include "imgui.h" +#include +#include + +// Returns a color interpolated from blue (cool, 0%) through green/yellow to red (hot, 100%) +static ImVec4 heat_color(float pct) { + float t = std::min(std::max(pct / 100.0f, 0.0f), 1.0f); + float r, g, b; + if (t < 0.25f) { + float s = t / 0.25f; + r = 0.0f; + g = s; + b = 1.0f; + } else if (t < 0.5f) { + float s = (t - 0.25f) / 0.25f; + r = 0.0f; + g = 1.0f; + b = 1.0f - s; + } else if (t < 0.75f) { + float s = (t - 0.5f) / 0.25f; + r = s; + g = 1.0f; + b = 0.0f; + } else { + float s = (t - 0.75f) / 0.25f; + r = 1.0f; + g = 1.0f - s; + b = 0.0f; + } + return ImVec4(r, g, b, 1.0f); +} + +static void render_heat_bar(float pct) { + ImVec4 col = heat_color(pct); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, col); + ImGui::ProgressBar(pct / 100.0f, ImVec2(-1, 0), ""); + ImGui::PopStyleColor(); + ImGui::SameLine(0, 0); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() - ImGui::GetItemRectSize().x); + ImGui::Text("%.1f%%", pct); +} + +EventBrowser::EventBrowser(bool default_group_by_name) + : group_by_name_(default_group_by_name), default_group_by_name_(default_group_by_name) {} + +void EventBrowser::reset() { + entries_.clear(); + aggregated_.clear(); + filtered_.clear(); + filtered_agg_.clear(); + parent_dur_ = 0.0; + dirty_ = false; + group_by_name_ = default_group_by_name_; + cached_group_ = false; + filter_buf_[0] = '\0'; + active_filter_.clear(); + scroll_to_top_ = false; + scroll_agg_to_top_ = false; +} + +void EventBrowser::set_entries(std::vector entries, double parent_dur, const TraceModel& model) { + entries_ = std::move(entries); + parent_dur_ = parent_dur; + dirty_ = true; + if (group_by_name_) { + rebuild_aggregated(model); + } + rebuild_filter(model); +} + +void EventBrowser::render(const char* id, const TraceModel& model, ViewState& view) { + ImGui::PushID(id); + + if (cached_group_ != group_by_name_ || dirty_) { + cached_group_ = group_by_name_; + if (group_by_name_) { + rebuild_aggregated(model); + } + rebuild_filter(model); + } + + ImGui::Checkbox("Group by name", &group_by_name_); + + ImGui::SetNextItemWidth(-1); + if (ImGui::InputTextWithHint("##filter", "Filter by name...", filter_buf_, sizeof(filter_buf_))) { + rebuild_filter(model); + } + if (filter_buf_[0] != '\0') { + size_t shown = group_by_name_ ? filtered_agg_.size() : filtered_.size(); + size_t total = group_by_name_ ? aggregated_.size() : entries_.size(); + ImGui::TextDisabled("Showing %zu / %zu", shown, total); + } + + ImGui::Spacing(); + + if (group_by_name_) { + render_aggregated_table(model, view); + } else { + render_table(model, view); + } + + ImGui::PopID(); +} + +void EventBrowser::rebuild_aggregated(const TraceModel& model) { + aggregated_.clear(); + std::unordered_map name_to_idx; + name_to_idx.reserve(entries_.size() / 4); + + for (const auto& e : entries_) { + auto it = name_to_idx.find(e.name_idx); + if (it == name_to_idx.end()) { + name_to_idx[e.name_idx] = aggregated_.size(); + aggregated_.push_back({e.name_idx, 1, e.dur, 0.0, e.dur, e.dur, 0.0f, e.event_idx}); + } else { + auto& ag = aggregated_[it->second]; + ag.count++; + ag.total_dur += e.dur; + if (e.dur < ag.min_dur) ag.min_dur = e.dur; + if (e.dur > ag.max_dur) ag.max_dur = e.dur; + if (e.dur > model.events()[ag.longest_idx].dur) { + ag.longest_idx = e.event_idx; + } + } + } + + for (auto& ag : aggregated_) { + ag.avg_dur = ag.total_dur / ag.count; + ag.pct = parent_dur_ > 0 ? (float)(ag.total_dur / parent_dur_ * 100.0) : 0.0f; + } +} + +void EventBrowser::rebuild_filter(const TraceModel& model) { + TRACE_FUNCTION_CAT("ui"); + active_filter_ = filter_buf_; + + filtered_.clear(); + for (size_t i = 0; i < entries_.size(); i++) { + if (contains_case_insensitive(model.get_string(entries_[i].name_idx), active_filter_)) { + filtered_.push_back(i); + } + } + + filtered_agg_.clear(); + for (size_t i = 0; i < aggregated_.size(); i++) { + if (contains_case_insensitive(model.get_string(aggregated_[i].name_idx), active_filter_)) { + filtered_agg_.push_back(i); + } + } +} + +void EventBrowser::render_table(const TraceModel& model, ViewState& view) { + if (ImGui::BeginTable("Table", 3, + ImGuiTableFlags_Sortable | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersOuter | + ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable, + ImVec2(0, ImGui::GetContentRegionAvail().y))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_None, 0.0f, 0); + ImGui::TableSetupColumn( + "Duration", ImGuiTableColumnFlags_DefaultSort | ImGuiTableColumnFlags_PreferSortDescending, 0.0f, 1); + ImGui::TableSetupColumn("%", ImGuiTableColumnFlags_None, 0.0f, 2); + ImGui::TableHeadersRow(); + + if (ImGuiTableSortSpecs* sort_specs = ImGui::TableGetSortSpecs()) { + if (dirty_) sort_specs->SpecsDirty = true; + if (sort_specs->SpecsDirty) { + bool user_sorted = !dirty_; + sort_specs->SpecsDirty = false; + dirty_ = false; + if (sort_specs->SpecsCount > 0) { + const auto& spec = sort_specs->Specs[0]; + bool asc = (spec.SortDirection == ImGuiSortDirection_Ascending); + std::sort(filtered_.begin(), filtered_.end(), [&](size_t ai, size_t bi) { + const auto& a = entries_[ai]; + const auto& b = entries_[bi]; + int cmp = 0; + switch (spec.ColumnUserID) { + case 0: + cmp = model.get_string(a.name_idx).compare(model.get_string(b.name_idx)); + break; + case 1: + cmp = sort_utils::three_way_cmp(a.dur, b.dur); + break; + case 2: + cmp = sort_utils::three_way_cmp(a.pct, b.pct); + break; + } + return asc ? (cmp < 0) : (cmp > 0); + }); + } + if (user_sorted) scroll_to_top_ = true; + } + } + + if (scroll_to_top_) { + ImGui::SetScrollY(0.0f); + scroll_to_top_ = false; + } + + char buf[64]; + ImGuiListClipper clipper; + clipper.Begin((int)filtered_.size()); + while (clipper.Step()) { + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) { + const auto& e = entries_[filtered_[i]]; + ImGui::TableNextRow(); + + ImGui::TableNextColumn(); + char id_buf[32]; + snprintf(id_buf, sizeof(id_buf), "##r%d", i); + bool is_selected = (view.selected_event_idx() == (int32_t)e.event_idx); + if (ImGui::Selectable(id_buf, is_selected, + ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { + view.navigate_to_event(e.event_idx, model.events()[e.event_idx]); + } + ImGui::SameLine(); + ImGui::TextUnformatted(model.get_string(e.name_idx).c_str()); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) + ImGui::SetTooltip("%s", model.get_string(e.name_idx).c_str()); + + ImGui::TableNextColumn(); + format_time(e.dur, buf, sizeof(buf)); + ImGui::TextUnformatted(buf); + + ImGui::TableNextColumn(); + render_heat_bar(e.pct); + } + } + + ImGui::EndTable(); + } +} + +void EventBrowser::render_aggregated_table(const TraceModel& model, ViewState& view) { + if (ImGui::BeginTable("AggTable", 7, + ImGuiTableFlags_Sortable | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersOuter | + ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable, + ImVec2(0, ImGui::GetContentRegionAvail().y))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_None, 0.0f, 0); + ImGui::TableSetupColumn("Count", ImGuiTableColumnFlags_None, 0.0f, 1); + ImGui::TableSetupColumn("Total", ImGuiTableColumnFlags_DefaultSort | ImGuiTableColumnFlags_PreferSortDescending, + 0.0f, 2); + ImGui::TableSetupColumn("Avg", ImGuiTableColumnFlags_None, 0.0f, 3); + ImGui::TableSetupColumn("Min", ImGuiTableColumnFlags_None, 0.0f, 4); + ImGui::TableSetupColumn("Max", ImGuiTableColumnFlags_None, 0.0f, 5); + ImGui::TableSetupColumn("%", ImGuiTableColumnFlags_None, 0.0f, 6); + ImGui::TableHeadersRow(); + + if (ImGuiTableSortSpecs* sort_specs = ImGui::TableGetSortSpecs()) { + if (dirty_) sort_specs->SpecsDirty = true; + if (sort_specs->SpecsDirty) { + bool user_sorted = !dirty_; + sort_specs->SpecsDirty = false; + dirty_ = false; + if (sort_specs->SpecsCount > 0) { + const auto& spec = sort_specs->Specs[0]; + bool asc = (spec.SortDirection == ImGuiSortDirection_Ascending); + std::sort(filtered_agg_.begin(), filtered_agg_.end(), [&](size_t ai, size_t bi) { + const auto& a = aggregated_[ai]; + const auto& b = aggregated_[bi]; + int cmp = 0; + switch (spec.ColumnUserID) { + case 0: + cmp = model.get_string(a.name_idx).compare(model.get_string(b.name_idx)); + break; + case 1: + cmp = sort_utils::three_way_cmp(a.count, b.count); + break; + case 2: + cmp = sort_utils::three_way_cmp(a.total_dur, b.total_dur); + break; + case 3: + cmp = sort_utils::three_way_cmp(a.avg_dur, b.avg_dur); + break; + case 4: + cmp = sort_utils::three_way_cmp(a.min_dur, b.min_dur); + break; + case 5: + cmp = sort_utils::three_way_cmp(a.max_dur, b.max_dur); + break; + case 6: + cmp = sort_utils::three_way_cmp(a.pct, b.pct); + break; + } + return asc ? (cmp < 0) : (cmp > 0); + }); + } + if (user_sorted) scroll_agg_to_top_ = true; + } + } + + if (scroll_agg_to_top_) { + ImGui::SetScrollY(0.0f); + scroll_agg_to_top_ = false; + } + + char buf[64]; + ImGuiListClipper clipper; + clipper.Begin((int)filtered_agg_.size()); + while (clipper.Step()) { + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) { + const auto& ag = aggregated_[filtered_agg_[i]]; + ImGui::TableNextRow(); + + ImGui::TableNextColumn(); + char id_buf[32]; + snprintf(id_buf, sizeof(id_buf), "##a%d", i); + if (ImGui::Selectable(id_buf, false, + ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { + view.navigate_to_event(ag.longest_idx, model.events()[ag.longest_idx]); + } + ImGui::SameLine(); + ImGui::TextUnformatted(model.get_string(ag.name_idx).c_str()); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) + ImGui::SetTooltip("%s", model.get_string(ag.name_idx).c_str()); + + ImGui::TableNextColumn(); + ImGui::Text("%u", ag.count); + + ImGui::TableNextColumn(); + format_time(ag.total_dur, buf, sizeof(buf)); + ImGui::TextUnformatted(buf); + + ImGui::TableNextColumn(); + format_time(ag.avg_dur, buf, sizeof(buf)); + ImGui::TextUnformatted(buf); + + ImGui::TableNextColumn(); + format_time(ag.min_dur, buf, sizeof(buf)); + ImGui::TextUnformatted(buf); + + ImGui::TableNextColumn(); + format_time(ag.max_dur, buf, sizeof(buf)); + ImGui::TextUnformatted(buf); + + ImGui::TableNextColumn(); + render_heat_bar(ag.pct); + } + } + + ImGui::EndTable(); + } +} diff --git a/src/ui/event_browser.h b/src/ui/event_browser.h new file mode 100644 index 0000000..c781e57 --- /dev/null +++ b/src/ui/event_browser.h @@ -0,0 +1,59 @@ +#pragma once +#include "model/trace_model.h" +#include "ui/view_state.h" +#include +#include + +// Reusable sortable/filterable event list with optional group-by-name aggregation. +// Used by DetailPanel for children tab, siblings tab, and any future event lists. +class EventBrowser { +public: + struct Entry { + uint32_t event_idx; + uint32_t name_idx; + double dur; + float pct; + }; + + struct Aggregated { + uint32_t name_idx; + uint32_t count; + double total_dur; + double avg_dur; + double min_dur; + double max_dur; + float pct; + uint32_t longest_idx; + }; + + explicit EventBrowser(bool default_group_by_name = false); + + // Replace entries and rebuild aggregation + filter. + void set_entries(std::vector entries, double parent_dur, const TraceModel& model); + + // Renders group-by-name checkbox, filter input, and the appropriate table. + void render(const char* id, const TraceModel& model, ViewState& view); + + size_t entry_count() const { return entries_.size(); } + void reset(); + +private: + std::vector entries_; + std::vector aggregated_; + std::vector filtered_; + std::vector filtered_agg_; + double parent_dur_ = 0.0; + bool dirty_ = false; + bool group_by_name_; + bool default_group_by_name_; + bool cached_group_ = false; + char filter_buf_[256] = {}; + std::string active_filter_; + bool scroll_to_top_ = false; + bool scroll_agg_to_top_ = false; + + void rebuild_aggregated(const TraceModel& model); + void rebuild_filter(const TraceModel& model); + void render_table(const TraceModel& model, ViewState& view); + void render_aggregated_table(const TraceModel& model, ViewState& view); +}; diff --git a/tests/test_detail_siblings.cpp b/tests/test_detail_siblings.cpp new file mode 100644 index 0000000..f502534 --- /dev/null +++ b/tests/test_detail_siblings.cpp @@ -0,0 +1,161 @@ +#include +#include "model/trace_model.h" +#include "ui/event_browser.h" + +// Replicates the sibling-finding logic from DetailPanel::rebuild_siblings() +// to unit-test it without requiring an ImGui context. +static std::vector find_siblings(const TraceModel& model, uint32_t event_idx) { + std::vector result; + const auto& ev = model.events()[event_idx]; + if (ev.parent_idx < 0) return result; + + const auto* thread = model.find_thread(ev.pid, ev.tid); + if (!thread) return result; + + const auto& parent = model.events()[ev.parent_idx]; + for (uint32_t idx : thread->event_indices) { + const auto& sib = model.events()[idx]; + if (sib.ts >= parent.end_ts()) break; + if (sib.depth != ev.depth) continue; + if (sib.parent_idx != ev.parent_idx) continue; + if (sib.dur <= 0) continue; + if (idx == event_idx) continue; + result.push_back(idx); + } + return result; +} + +static TraceModel make_sibling_model() { + // 0: root [0, 200) depth=0 + // 1: A [0, 50) depth=1 parent=0 + // 2: B [50,100) depth=1 parent=0 + // 3: D [50, 80) depth=2 parent=2 (child of B, no siblings) + // 4: C [100,150) depth=1 parent=0 + // 5: C [150,200) depth=1 parent=0 + TraceModel m; + uint32_t n_root = m.intern_string("root"); + uint32_t n_a = m.intern_string("A"); + uint32_t n_b = m.intern_string("B"); + uint32_t n_c = m.intern_string("C"); + uint32_t n_d = m.intern_string("D"); + uint32_t cat = m.intern_string("test"); + + auto& t = m.get_or_create_process(1).get_or_create_thread(1); + + auto push = [&](uint32_t name, double ts, double dur) { + TraceEvent e; + e.name_idx = name; + e.cat_idx = cat; + e.ph = Phase::Complete; + e.ts = ts; + e.dur = dur; + e.pid = 1; + e.tid = 1; + t.event_indices.push_back(m.add_event(e)); + }; + + push(n_root, 0, 200); // 0 + push(n_a, 0, 50); // 1 + push(n_b, 50, 50); // 2 + push(n_d, 50, 30); // 3: child of B + push(n_c, 100, 50); // 4 + push(n_c, 150, 50); // 5 + + m.build_index(); + return m; +} + +TEST(DetailSiblings, RootEventHasNoSiblings) { + auto m = make_sibling_model(); + EXPECT_EQ(m.events()[0].depth, 0); + EXPECT_EQ(m.events()[0].parent_idx, -1); + auto sibs = find_siblings(m, 0); + EXPECT_TRUE(sibs.empty()); +} + +TEST(DetailSiblings, SiblingsAtDepth1) { + auto m = make_sibling_model(); + // Event 1 (A) should have siblings B, C, C (events 2, 4, 5) + auto sibs = find_siblings(m, 1); + EXPECT_EQ(sibs.size(), 3u); + EXPECT_EQ(m.get_string(m.events()[sibs[0]].name_idx), "B"); + EXPECT_EQ(m.get_string(m.events()[sibs[1]].name_idx), "C"); + EXPECT_EQ(m.get_string(m.events()[sibs[2]].name_idx), "C"); +} + +TEST(DetailSiblings, SiblingsExcludesSelf) { + auto m = make_sibling_model(); + auto sibs = find_siblings(m, 2); + EXPECT_EQ(sibs.size(), 3u); + for (uint32_t idx : sibs) { + EXPECT_NE(idx, 2u); + } +} + +TEST(DetailSiblings, OnlyChildHasNoSiblings) { + auto m = make_sibling_model(); + // Event 3 (D) is the only child of B + auto sibs = find_siblings(m, 3); + EXPECT_TRUE(sibs.empty()); +} + +TEST(DetailSiblings, SiblingsDontCrossParents) { + TraceModel m; + uint32_t n_r = m.intern_string("root"); + uint32_t n_c = m.intern_string("child"); + uint32_t cat = m.intern_string("test"); + + auto& t = m.get_or_create_process(1).get_or_create_thread(1); + + auto push = [&](uint32_t name, double ts, double dur) { + TraceEvent e; + e.name_idx = name; + e.cat_idx = cat; + e.ph = Phase::Complete; + e.ts = ts; + e.dur = dur; + e.pid = 1; + e.tid = 1; + t.event_indices.push_back(m.add_event(e)); + }; + + push(n_r, 0, 100); // 0: root1 + push(n_c, 10, 30); // 1: child of root1 + push(n_r, 200, 100); // 2: root2 + push(n_c, 210, 30); // 3: child of root2 + + m.build_index(); + + EXPECT_TRUE(find_siblings(m, 1).empty()); + EXPECT_TRUE(find_siblings(m, 3).empty()); +} + +TEST(DetailSiblings, DuplicateNameSiblingsAllFound) { + auto m = make_sibling_model(); + // Event 4 (first C) should have siblings A, B, and the other C + auto sibs = find_siblings(m, 4); + EXPECT_EQ(sibs.size(), 3u); +} + +TEST(EventBrowserUnit, SetEntriesPopulatesCount) { + TraceModel m; + uint32_t n = m.intern_string("foo"); + + EventBrowser browser; + std::vector entries; + entries.push_back({0, n, 10.0, 50.0f}); + entries.push_back({1, n, 20.0, 100.0f}); + browser.set_entries(std::move(entries), 30.0, m); + EXPECT_EQ(browser.entry_count(), 2u); +} + +TEST(EventBrowserUnit, ResetClearsEntries) { + TraceModel m; + uint32_t n = m.intern_string("bar"); + + EventBrowser browser; + browser.set_entries({{0, n, 5.0, 25.0f}}, 20.0, m); + EXPECT_EQ(browser.entry_count(), 1u); + browser.reset(); + EXPECT_EQ(browser.entry_count(), 0u); +}