From fa69b7f64501a0b4299450a5f0793903a52f2de6 Mon Sep 17 00:00:00 2001 From: Dipal Date: Thu, 10 Sep 2026 11:02:58 -0400 Subject: [PATCH 1/4] View: add Chrome-style project tab groups Introduce a "Project" as a named, colored, collapsible group of tabs (Chrome tab-group style), and rename the per-tab unit to ProjectItem. - Rename per-tab Project -> ProjectItem (+ the ProjectItemSetting family); rocprofvis_project.{h,cpp} -> rocprofvis_project_item.{h,cpp}. - Add Project (rocprofvis_project.{h,cpp}): an ordered group of ProjectItems with a name, color, and remembered closed items. - Replace the ImGui tab bar with a custom tab strip: inline group chips, collapse/expand, drag-to-reorder tabs, and dragging a whole group via its chip (collapse-on-drag) with a floating drag ghost. - Group management via tab and chip right-click menus and a File > Projects submenu: create / add / move / remove / ungroup, rename, recolor, reopen closed items, close group; auto-group when multiple files are opened together. - A .rpv is always a project now. Saving embeds each item's full per-view settings (track order/heights, bookmarks, annotations), and Save / Save As are unified to operate on the whole project. Old single-item .rpv files still parse and open as a one-tab project. - Reuse SettingsManager::GetColorWheel() for group colors (theme-aware) instead of a duplicate palette. --- CMakeLists.txt | 1 + src/app/test/app_tests.cpp | 24 +- src/view/src/rocprofvis_annotations.cpp | 12 +- src/view/src/rocprofvis_annotations.h | 10 +- src/view/src/rocprofvis_appwindow.cpp | 1094 ++++++++++++++++- src/view/src/rocprofvis_appwindow.h | 70 +- src/view/src/rocprofvis_presets.cpp | 14 +- src/view/src/rocprofvis_project.cpp | 427 ++----- src/view/src/rocprofvis_project.h | 234 +--- src/view/src/rocprofvis_project_item.cpp | 498 ++++++++ src/view/src/rocprofvis_project_item.h | 233 ++++ .../src/rocprofvis_timeline_track_options.cpp | 14 +- .../src/rocprofvis_timeline_track_options.h | 10 +- src/view/src/rocprofvis_timeline_view.cpp | 16 +- src/view/src/rocprofvis_timeline_view.h | 12 +- src/view/src/rocprofvis_trace_view.cpp | 14 +- src/view/src/rocprofvis_trace_view.h | 12 +- src/view/src/rocprofvis_view_module.cpp | 6 +- .../src/widgets/rocprofvis_tab_container.cpp | 747 +++++++++-- .../src/widgets/rocprofvis_tab_container.h | 53 +- src/view/src/widgets/rocprofvis_widget.h | 8 + 21 files changed, 2738 insertions(+), 771 deletions(-) create mode 100644 src/view/src/rocprofvis_project_item.cpp create mode 100644 src/view/src/rocprofvis_project_item.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 2a9401f8f..52c112da2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -141,6 +141,7 @@ set(VIEW_FILES src/view/src/rocprofvis_timeline_track_options.cpp src/view/src/rocprofvis_track_topology.cpp src/view/src/rocprofvis_track_details.cpp + src/view/src/rocprofvis_project_item.cpp src/view/src/rocprofvis_project.cpp src/view/src/rocprofvis_multi_track_table.cpp src/view/src/rocprofvis_event_search.cpp diff --git a/src/app/test/app_tests.cpp b/src/app/test/app_tests.cpp index 60a567d25..e6ef587bf 100644 --- a/src/app/test/app_tests.cpp +++ b/src/app/test/app_tests.cpp @@ -5,7 +5,7 @@ #include "imgui_te_context.h" #include "imgui.h" #include "rocprofvis_appwindow.h" -#include "rocprofvis_project.h" +#include "rocprofvis_project_item.h" #include "rocprofvis_trace_view.h" #include "rocprofvis_timeline_selection.h" #include "rocprofvis_analysis_view.h" @@ -37,7 +37,7 @@ namespace TraceView* GetTraceViewOrSkip(ImGuiTestContext* ctx) { AppWindow* app = AppWindow::GetInstance(); - Project* project = app->GetCurrentProject(); + ProjectItem* project = app->GetCurrentItem(); // A null project means the db never opened (a real regression); fail hard. // A non-null project of the wrong view type is an expected wrong-db skip. IM_CHECK_RETV(project != nullptr, nullptr); @@ -53,7 +53,7 @@ namespace ComputeView* GetComputeViewOrSkip(ImGuiTestContext* ctx) { AppWindow* app = AppWindow::GetInstance(); - Project* project = app->GetCurrentProject(); + ProjectItem* project = app->GetCurrentItem(); IM_CHECK_RETV(project != nullptr, nullptr); ComputeView* cv = dynamic_cast(project->GetView().get()); if (cv == nullptr) @@ -2034,16 +2034,16 @@ void RegisterAppTests(ImGuiTestEngine* e) // Open DB_A; afterward its project must exist. app->OpenFile(db_a); ctx->Yield(3); - IM_CHECK(app->GetProject(id_a) != nullptr); + IM_CHECK(app->GetItem(id_a) != nullptr); // Open DB_B as a second, active tab so the later switch back to DB_A is // actually observable. app->OpenFile(db_b); ctx->Yield(3); - IM_CHECK(app->GetProject(id_b) != nullptr); - IM_CHECK(app->GetCurrentProject() != nullptr); - if (app->GetCurrentProject() == nullptr) return; - IM_CHECK(app->GetCurrentProject()->GetID() == id_b); + IM_CHECK(app->GetItem(id_b) != nullptr); + IM_CHECK(app->GetCurrentItem() != nullptr); + if (app->GetCurrentItem() == nullptr) return; + IM_CHECK(app->GetCurrentItem()->GetID() == id_b); // Write a temp .rpv pointing at DB_A by absolute path, so it resolves // back to DB_A's id no matter where the .rpv lives. Escape the path so @@ -2068,12 +2068,12 @@ void RegisterAppTests(ImGuiTestEngine* e) // Opening the .rpv must switch back to the existing DB_A tab instead of // opening a duplicate. - IM_CHECK(app->GetCurrentProject() != nullptr); - if (app->GetCurrentProject() == nullptr) return; - IM_CHECK(app->GetCurrentProject()->GetID() == id_a); + IM_CHECK(app->GetCurrentItem() != nullptr); + if (app->GetCurrentItem() == nullptr) return; + IM_CHECK(app->GetCurrentItem()->GetID() == id_a); // No project should be keyed at the .rpv path itself. - IM_CHECK(app->GetProject(rpv_path.string()) == nullptr); + IM_CHECK(app->GetItem(rpv_path.string()) == nullptr); // Remove the temp .rpv and dismiss the dedup popup so it can't cover // later tests. tab_guard restores the tab set on scope exit. diff --git a/src/view/src/rocprofvis_annotations.cpp b/src/view/src/rocprofvis_annotations.cpp index dfc2a29c4..723410405 100644 --- a/src/view/src/rocprofvis_annotations.cpp +++ b/src/view/src/rocprofvis_annotations.cpp @@ -10,17 +10,17 @@ namespace RocProfVis { namespace View { -AnnotationsManagerProjectSettings::AnnotationsManagerProjectSettings( +AnnotationsManagerProjectItemSettings::AnnotationsManagerProjectItemSettings( const std::string& project_id, AnnotationsManager& annotations_view) -: ProjectSetting(project_id) +: ProjectItemSetting(project_id) , m_annotations_manager(annotations_view) {} -AnnotationsManagerProjectSettings::~AnnotationsManagerProjectSettings() {} +AnnotationsManagerProjectItemSettings::~AnnotationsManagerProjectItemSettings() {} void -AnnotationsManagerProjectSettings::FromJson() +AnnotationsManagerProjectItemSettings::FromJson() { m_annotations_manager.Clear(); std::vector& annotation_vec = @@ -77,7 +77,7 @@ AnnotationsManagerProjectSettings::FromJson() } void -AnnotationsManagerProjectSettings::ToJson() +AnnotationsManagerProjectItemSettings::ToJson() { const std::vector& notes = m_annotations_manager.GetStickyNotes(); m_settings_json[JSON_KEY_ANNOTATIONS] = jt::Json(); @@ -104,7 +104,7 @@ AnnotationsManagerProjectSettings::ToJson() } bool -AnnotationsManagerProjectSettings::Valid() const +AnnotationsManagerProjectItemSettings::Valid() const { // Check that "annotations" exists and is an array if(!m_settings_json.contains(JSON_KEY_ANNOTATIONS) || diff --git a/src/view/src/rocprofvis_annotations.h b/src/view/src/rocprofvis_annotations.h index 2e1c747a2..75231064f 100644 --- a/src/view/src/rocprofvis_annotations.h +++ b/src/view/src/rocprofvis_annotations.h @@ -5,7 +5,7 @@ #include "imgui.h" #include "rocprofvis_data_provider.h" -#include "rocprofvis_project.h" +#include "rocprofvis_project_item.h" #include "rocprofvis_stickynote.h" #include #include @@ -19,12 +19,12 @@ constexpr double INVALID_TIME_NS = std::numeric_limits::lowest(); class AnnotationsManager; -class AnnotationsManagerProjectSettings : public ProjectSetting +class AnnotationsManagerProjectItemSettings : public ProjectItemSetting { public: - AnnotationsManagerProjectSettings(const std::string& project_id, + AnnotationsManagerProjectItemSettings(const std::string& project_id, AnnotationsManager& annotations_view); - ~AnnotationsManagerProjectSettings() override; + ~AnnotationsManagerProjectItemSettings() override; void ToJson() override; void FromJson(); @@ -57,7 +57,7 @@ class AnnotationsManager private: std::vector m_sticky_notes; bool m_show_annotations; - AnnotationsManagerProjectSettings m_project_settings; + AnnotationsManagerProjectItemSettings m_project_settings; }; } // namespace View diff --git a/src/view/src/rocprofvis_appwindow.cpp b/src/view/src/rocprofvis_appwindow.cpp index 585815576..11c06463d 100644 --- a/src/view/src/rocprofvis_appwindow.cpp +++ b/src/view/src/rocprofvis_appwindow.cpp @@ -12,7 +12,12 @@ #include "rocprofvis_appmonitor.h" #include "rocprofvis_controller.h" #include "rocprofvis_events.h" +#include "rocprofvis_project_item.h" #include "rocprofvis_project.h" +#include +#include +#include +#include #include "rocprofvis_settings_manager.h" #include "rocprofvis_hotkey_manager.h" #include "rocprofvis_render_scheduler.h" @@ -67,6 +72,12 @@ const std::vector ALL_EXTENSIONS = { "db", "rpd", "yaml", "rpv" #endif const std::vector COMPARE_EXTENSIONS = { "db" }; +// A .rpv project file can hold either a single saved item or a whole tab group +// (name, color, member filelists). Group saves reuse the .rpv extension; the +// loader disambiguates by inspecting the JSON (a group has a top-level "items"). +const std::vector PROJECT_GROUP_EXTENSIONS = { "rpv" }; +constexpr const char* PROJECT_GROUP_EXTENSION = ".rpv"; + constexpr const char* CLEANUP_MESSAGE = "Waiting for requests to finish cleanup..."; constexpr const char* CLOSING_MESSAGE = "Closing..."; @@ -159,7 +170,7 @@ AppWindow::~AppWindow() } } m_provider_cleanup_jobs.clear(); - m_projects.clear(); + m_items.clear(); // Destroy owners of monitored sessions (e.g. the profiler dialog and the // remote-trace orchestrator) before tearing down the monitor so they // unregister cleanly instead of lazily re-creating the singleton during @@ -214,6 +225,11 @@ AppWindow::Init() m_tab_container->SetEventSourceName(TAB_CONTAINER_SRC_NAME); m_tab_container->EnableSendCloseEvent(true); m_tab_container->EnableSendChangeEvent(true); + m_tab_container->SetTabContextMenuCallback( + [this](const std::string& item_id) { RenderTabGroupContextMenu(item_id); }); + m_tab_container->SetChipContextMenuCallback( + [this](const std::string& group_id) { RenderProjectChipContextMenu(group_id); }); + m_tab_container->SetTabsReorderedCallback([this]() { SyncProjectOrderToTabs(); }); main_area_item.m_item = std::make_shared([this]() { if(m_shutdown_requested) @@ -440,29 +456,923 @@ AppWindow::ShowPathPickerDialog(const std::string& title, const std::string& ini ShowImGuiFileDialog(title, {}, initial_path, false, callback, true); } -Project* -AppWindow::GetProject(const std::string& id) +ProjectItem* +AppWindow::GetItem(const std::string& id) { - Project* project = nullptr; - if(m_projects.count(id) > 0) + ProjectItem* project = nullptr; + if(m_items.count(id) > 0) { - project = m_projects[id].get(); + project = m_items[id].get(); } return project; } -Project* -AppWindow::GetCurrentProject() +ProjectItem* +AppWindow::GetCurrentItem() { - Project* project = nullptr; + ProjectItem* project = nullptr; const TabItem* active_tab = m_tab_container->GetActiveTab(); if(active_tab) { - project = GetProject(active_tab->m_id); + project = GetItem(active_tab->m_id); } return project; } +Project* +AppWindow::GetProjectById(const std::string& project_id) +{ + Project* result = nullptr; + for(std::unique_ptr& p : m_projects) + { + if(p->GetID() == project_id) + { + result = p.get(); + break; + } + } + return result; +} + +Project* +AppWindow::GetProjectForItem(const std::string& item_id) +{ + Project* result = nullptr; + for(std::unique_ptr& p : m_projects) + { + if(p->ContainsItem(item_id)) + { + result = p.get(); + break; + } + } + return result; +} + +Project* +AppWindow::CreateProject() +{ + // Reuse the lowest free "Project N" number so repeatedly creating/deleting groups + // does not make the number climb forever. Color is derived from the number so a + // given "Project N" always gets the same palette color. + int number = 1; + bool taken = true; + while(taken) + { + std::string candidate = "Project " + std::to_string(number); + taken = false; + for(std::unique_ptr& p : m_projects) + { + if(p->GetName() == candidate) + { + taken = true; + break; + } + } + if(taken) + { + number++; + } + } + const std::vector& palette = SettingsManager::GetInstance().GetColorWheel(); + ImU32 color = palette[(number - 1) % palette.size()]; + std::string name = "Project " + std::to_string(number); + std::string project_id = "project://" + std::to_string(number) + "-" + + std::to_string(m_project_counter++); + m_projects.push_back(std::make_unique(project_id, name, color)); + return m_projects.back().get(); +} + +Project* +AppWindow::CreateProjectNamed(const std::string& name, ImU32 color) +{ + std::string project_id = "project://named-" + std::to_string(m_project_counter++); + m_projects.push_back(std::make_unique(project_id, name, color)); + return m_projects.back().get(); +} + +bool +AppWindow::IsProjectGroupFile(const std::string& file_path) +{ + std::ifstream file(file_path); + if(!file.is_open()) + { + return false; + } + std::string json_string; + std::string line; + while(std::getline(file, line)) + { + json_string += line; + } + file.close(); + std::pair parsed = jt::Json::parse(json_string); + if(parsed.first != jt::Json::success) + { + return false; + } + // A project (tab group) file has a top-level "items" array; a single-item .rpv + // does not (its content lives under "general"/"timeline"). + return parsed.second["items"].isArray(); +} + +void +AppWindow::HandleSaveProjectGroup(const std::string& project_id) +{ + if(!GetProjectById(project_id)) + { + return; + } + FileFilter project_filter; + project_filter.m_name = "Project"; + project_filter.m_extensions = PROJECT_GROUP_EXTENSIONS; + std::vector filters; + filters.push_back(project_filter); + ShowSaveFileDialog( + "Save Project", filters, "", + [this, project_id](std::string file_path) { SaveProjectGroup(project_id, file_path); }); +} + +void +AppWindow::SaveProjectGroup(const std::string& project_id, const std::string& save_path) +{ + Project* project = GetProjectById(project_id); + if(!project) + { + return; + } + + // Ensure the file carries the project-group extension so reopening it is routed + // back to the group loader (a dialog may not append the filter extension). + std::string file_path = save_path; + if(std::filesystem::path(file_path).extension().string() != PROJECT_GROUP_EXTENSION) + { + file_path += PROJECT_GROUP_EXTENSION; + } + + jt::Json root; + root = ""; + root["version"] = "1.0"; + root["name"] = project->GetName(); + char color_buf[16]; + std::snprintf(color_buf, sizeof(color_buf), "%08X", + static_cast(project->GetColor())); + root["color"] = std::string(color_buf); + + // Persist each open member with its full per-view settings (track heights/order, + // bookmarks, annotations, ...) so a reopened project restores exactly as saved. + // Remembered closed items are stored separately (filelist only) so they stay in + // the project's reopen list without being reopened as tabs. + std::filesystem::path dir = std::filesystem::path(file_path).parent_path(); + size_t open_index = 0; + for(const std::string& member_id : project->GetItemIds()) + { + ProjectItem* member = GetItem(member_id); + if(!member) + { + continue; + } + root["items"][open_index]["settings"] = member->ExportSettingsJson(dir); + open_index++; + } + size_t closed_index = 0; + for(const Project::ClosedItem& closed : project->GetClosedItems()) + { + root["closed"][closed_index]["name"] = closed.name; + for(size_t j = 0; j < closed.files.size(); j++) + { + root["closed"][closed_index]["files"][j] = + std::filesystem::proximate(closed.files[j], dir).generic_string(); + } + closed_index++; + } + + std::ofstream file(file_path); + if(file.is_open()) + { + file << root.toStringPretty() << "\n"; + file.close(); + // Remember the path so "Save" can re-save the whole project without a dialog. + project->SetFilePath(file_path); + SettingsManager::GetInstance().AddRecentFile(file_path); + NotificationManager::GetInstance().Show("Saved project \"" + project->GetName() + "\".", + NotificationLevel::Success); + } + else + { + NotificationManager::GetInstance().Show("Failed to save project.", + NotificationLevel::Error); + } +} + +void +AppWindow::OpenProjectGroupFile(const std::string& file_path) +{ + std::ifstream file(file_path); + if(!file.is_open()) + { + ShowMessageDialog("Error", "Could not open project file:\n\n" + file_path); + return; + } + std::string json_string; + std::string line; + while(std::getline(file, line)) + { + json_string += line; + } + file.close(); + + std::pair parsed = jt::Json::parse(json_string); + if(parsed.first != jt::Json::success) + { + ShowMessageDialog("Error", "The project file is invalid or corrupted:\n\n" + file_path); + return; + } + jt::Json& root = parsed.second; + + // A .rpv is always a project. New files carry an "items" array (a real group); + // old single-item files do not, and become a one-tab project named after the file. + bool is_group_format = root["items"].isArray(); + const std::vector& palette = SettingsManager::GetInstance().GetColorWheel(); + std::string name; + ImU32 color = palette[0]; + if(is_group_format) + { + name = root["name"].isString() ? root["name"].getString() : "Project"; + if(root["color"].isString()) + { + color = static_cast(std::stoul(root["color"].getString(), nullptr, 16)); + } + } + else + { + name = std::filesystem::path(file_path).stem().string(); + } + Project* project = CreateProjectNamed(name, color); + std::string project_id = project->GetID(); + project->SetFilePath(file_path); + + std::filesystem::path dir = std::filesystem::path(file_path).parent_path(); + if(is_group_format) + { + for(jt::Json& item : root["items"].getArray()) + { + std::string opened_id; + if(!item["settings"].isNull()) + { + // New format: the item carries its full settings; restore it (with its + // track heights/order, bookmarks, annotations) through the settings + // path, then hand it a tab. + std::unique_ptr new_item = std::make_unique(); + std::string out_id; + ProjectItem::OpenResult open_result = + new_item->OpenFromSettingsJson(item["settings"], dir, out_id); + if(open_result == ProjectItem::OpenResult::Success) + { + TabItem tab{ new_item->GetName(), new_item->GetID(), + new_item->GetView(), true }; + m_tab_container->AddTab(std::move(tab)); + opened_id = new_item->GetID(); + m_items[opened_id] = std::move(new_item); + } + else if(open_result == ProjectItem::OpenResult::Duplicate) + { + opened_id = out_id; + } + } + else if(item["files"].isArray()) + { + // Legacy format (filelist only, no per-view settings). + std::vector files; + for(jt::Json& entry : item["files"].getArray()) + { + if(entry.isString()) + { + files.push_back( + std::filesystem::weakly_canonical(dir / entry.getString()) + .string()); + } + } + if(files.empty()) + { + continue; + } + std::unordered_set before; + for(const std::pair>& kv : m_items) + { + before.insert(kv.first); + } + if(files.size() >= 2) + { + OpenCompare(files[0], files[1]); + } + else + { + OpenFile(files[0]); + } + for(const std::pair>& kv : m_items) + { + if(before.find(kv.first) == before.end()) + { + opened_id = kv.first; + } + } + } + if(!opened_id.empty()) + { + Project* group = GetProjectById(project_id); + if(group) + { + group->AddItem(opened_id); + } + } + } + } + + // Restore remembered closed items (kept in the project's reopen list, not opened + // as tabs). + if(root["closed"].isArray()) + { + for(jt::Json& closed_json : root["closed"].getArray()) + { + Project::ClosedItem closed; + closed.name = closed_json["name"].isString() ? closed_json["name"].getString() + : std::string(); + if(closed_json["files"].isArray()) + { + for(jt::Json& entry : closed_json["files"].getArray()) + { + if(entry.isString()) + { + closed.files.push_back( + std::filesystem::weakly_canonical(dir / entry.getString()) + .string()); + } + } + } + if(!closed.files.empty()) + { + Project* group = GetProjectById(project_id); + if(group) + { + group->AddClosedItem(closed); + } + } + } + } + + if(!is_group_format) + { + // Old single-item .rpv: the whole file is one item's settings; open it as the + // project's single tab (settings restored). + std::unique_ptr new_item = std::make_unique(); + std::string out_id; + ProjectItem::OpenResult open_result = + new_item->OpenFromSettingsJson(root, dir, out_id); + std::string opened_id; + if(open_result == ProjectItem::OpenResult::Success) + { + TabItem tab{ new_item->GetName(), new_item->GetID(), new_item->GetView(), true }; + m_tab_container->AddTab(std::move(tab)); + opened_id = new_item->GetID(); + m_items[opened_id] = std::move(new_item); + } + else if(open_result == ProjectItem::OpenResult::Duplicate) + { + opened_id = out_id; + } + if(!opened_id.empty()) + { + Project* group = GetProjectById(project_id); + if(group) + { + group->AddItem(opened_id); + } + } + } + + // If nothing actually opened (e.g. all traces were already open), drop the empty + // group so it does not linger as a phantom in the "Add to project" menus. + Project* group = GetProjectById(project_id); + if(group && group->GetItemIds().empty() && group->GetClosedItems().empty()) + { + for(size_t idx = 0; idx < m_projects.size(); idx++) + { + if(m_projects[idx]->GetID() == project_id) + { + m_projects.erase(m_projects.begin() + idx); + break; + } + } + } + + SettingsManager::GetInstance().AddRecentFile(file_path); + RefreshTabGroups(); +} + +void +AppWindow::AssignItemToProject(const std::string& item_id, const std::string& project_id) +{ + // No-op if it already belongs to the target project. + Project* current = GetProjectForItem(item_id); + if(current && current->GetID() == project_id) + { + return; + } + RemoveItemFromProjectMembership(item_id); + Project* target = GetProjectById(project_id); + if(target) + { + target->AddItem(item_id); + } + RefreshTabGroups(); +} + +void +AppWindow::RemoveItemFromProjectMembership(const std::string& item_id) +{ + for(size_t i = 0; i < m_projects.size(); i++) + { + if(m_projects[i]->RemoveItem(item_id)) + { + if(m_projects[i]->Empty()) + { + m_projects.erase(m_projects.begin() + i); + } + break; + } + } +} + +void +AppWindow::UngroupProject(const std::string& project_id) +{ + for(size_t i = 0; i < m_projects.size(); i++) + { + if(m_projects[i]->GetID() == project_id) + { + m_projects.erase(m_projects.begin() + i); + break; + } + } + RefreshTabGroups(); +} + +void +AppWindow::CloseProjectTabs(const std::string& project_id) +{ + Project* project = GetProjectById(project_id); + if(!project) + { + return; + } + // Copy ids: RemoveTab -> kTabClosed -> HandleTabClosed mutates the member list. + std::vector ids = project->GetItemIds(); + for(const std::string& id : ids) + { + m_tab_container->RemoveTab(id); + } + RefreshTabGroups(); +} + +void +AppWindow::ReopenClosedItem(const std::string& project_id, size_t closed_index) +{ + Project* project = GetProjectById(project_id); + if(!project || closed_index >= project->GetClosedItems().size()) + { + return; + } + Project::ClosedItem closed = project->GetClosedItems()[closed_index]; + project->RemoveClosedItemAt(closed_index); + if(closed.files.empty()) + { + return; + } + + // Discover the id(s) produced by the open by diffing the open-item map. + std::unordered_set before; + for(const std::pair>& kv : m_items) + { + before.insert(kv.first); + } + if(closed.files.size() >= 2) + { + OpenCompare(closed.files[0], closed.files[1]); + } + else + { + OpenFile(closed.files[0]); + } + // Re-fetch the project pointer (OpenFile can mutate m_projects indirectly). + project = GetProjectById(project_id); + if(!project) + { + return; + } + for(const std::pair>& kv : m_items) + { + if(before.find(kv.first) == before.end()) + { + project->AddItem(kv.first); + } + } + RefreshTabGroups(); +} + +void +AppWindow::RefreshTabGroups() +{ + if(!m_tab_container) + { + return; + } + const std::vector tabs = m_tab_container->GetTabs(); + for(const TabItem* tab : tabs) + { + Project* project = GetProjectForItem(tab->m_id); + if(project) + { + m_tab_container->SetTabGroup(tab->m_id, project->GetColor(), project->GetID(), + project->GetName()); + } + else + { + m_tab_container->SetTabGroup(tab->m_id, 0, std::string(), std::string()); + } + } + ReorderTabsForGroups(); +} + +void +AppWindow::SyncProjectOrderToTabs() +{ + const std::vector tabs = m_tab_container->GetTabs(); + for(std::unique_ptr& project : m_projects) + { + std::vector ordered; + for(const TabItem* tab : tabs) + { + if(project->ContainsItem(tab->m_id)) + { + ordered.push_back(tab->m_id); + } + } + project->SetItemOrder(ordered); + } +} + +void +AppWindow::ReorderTabsForGroups() +{ + const std::vector tabs = m_tab_container->GetTabs(); + std::vector order; + order.reserve(tabs.size()); + std::unordered_set emitted_projects; + for(const TabItem* tab : tabs) + { + Project* project = GetProjectForItem(tab->m_id); + if(project) + { + if(emitted_projects.find(project->GetID()) == emitted_projects.end()) + { + for(const std::string& member_id : project->GetItemIds()) + { + order.push_back(member_id); + } + emitted_projects.insert(project->GetID()); + } + } + else + { + order.push_back(tab->m_id); + } + } + m_tab_container->ReorderTabs(order); +} + +void +AppWindow::OpenFiles(const std::vector& file_paths) +{ + if(file_paths.size() < 2) + { + for(const std::string& path : file_paths) + { + OpenFile(path); + } + return; + } + + // Opening several files together auto-groups them into a new project. + std::unordered_set before; + for(const std::pair>& kv : m_items) + { + before.insert(kv.first); + } + for(const std::string& path : file_paths) + { + OpenFile(path); + } + std::vector new_ids; + for(const std::pair>& kv : m_items) + { + if(before.find(kv.first) == before.end()) + { + new_ids.push_back(kv.first); + } + } + if(new_ids.size() >= 2) + { + Project* project = CreateProject(); + for(const std::string& id : new_ids) + { + project->AddItem(id); + } + RefreshTabGroups(); + } +} + +void +AppWindow::RenderTabGroupContextMenu(const std::string& item_id) +{ + Project* current = GetProjectForItem(item_id); + if(current) + { + // Inline rename of the item's current project. + char name_buf[128] = { 0 }; + size_t copied = current->GetName().copy(name_buf, sizeof(name_buf) - 1); + name_buf[copied] = '\0'; + ImGui::SetNextItemWidth(160.0f); + if(ImGui::InputText("###ctx_rename", name_buf, sizeof(name_buf))) + { + current->SetName(std::string(name_buf)); + // Update the group's tab labels immediately (no reorder, so this is safe + // to do during the tab render). + for(const std::string& member_id : current->GetItemIds()) + { + m_tab_container->SetTabGroup(member_id, current->GetColor(), + current->GetID(), current->GetName()); + } + } + ImGui::Separator(); + + // Pull another open tab into this group (Chrome-style "add to group"), + // reachable from the group chip or any member tab. + std::vector> addable; + for(const TabItem* other : m_tab_container->GetTabs()) + { + Project* owner = GetProjectForItem(other->m_id); + if(!owner || owner->GetID() != current->GetID()) + { + ProjectItem* member = GetItem(other->m_id); + addable.emplace_back(other->m_id, member ? member->GetName() : other->m_id); + } + } + if(!addable.empty() && ImGui::BeginMenu("Add tab to group")) + { + std::string group_id = current->GetID(); + for(const std::pair& entry : addable) + { + if(ImGui::MenuItem((entry.second + "###addtab_" + entry.first).c_str())) + { + std::string add_id = entry.first; + m_pending_project_action = [this, add_id, group_id]() { + AssignItemToProject(add_id, group_id); + }; + } + } + ImGui::EndMenu(); + } + } + + // "Add to new project" only makes sense when it would actually change grouping: + // disable it when the item is already the sole member of a group (it would just + // create a fresh, renumbered group for the same tab). + bool can_new_project = !(current && current->GetItemIds().size() == 1); + if(ImGui::MenuItem("Add to new project", nullptr, false, can_new_project)) + { + m_pending_project_action = [this, item_id]() { + Project* project = CreateProject(); + AssignItemToProject(item_id, project->GetID()); + }; + } + + // Add-to / move-to existing projects. Only list real (open) groups; a project + // with no open tabs (only remembered closed files) is not a valid target and + // must not appear here. + bool has_other = false; + for(std::unique_ptr& p : m_projects) + { + if((!current || p->GetID() != current->GetID()) && !p->GetItemIds().empty()) + { + has_other = true; + break; + } + } + if(has_other && ImGui::BeginMenu(current ? "Move to project" : "Add to project")) + { + for(std::unique_ptr& p : m_projects) + { + if((current && p->GetID() == current->GetID()) || p->GetItemIds().empty()) + { + continue; + } + if(ImGui::MenuItem((p->GetName() + "###mv_" + p->GetID()).c_str())) + { + std::string target_id = p->GetID(); + m_pending_project_action = [this, item_id, target_id]() { + AssignItemToProject(item_id, target_id); + }; + } + } + ImGui::EndMenu(); + } + + if(current) + { + std::string project_id = current->GetID(); + ImGui::Separator(); + if(ImGui::MenuItem("Remove from project")) + { + m_pending_project_action = [this, item_id]() { + RemoveItemFromProjectMembership(item_id); + RefreshTabGroups(); + }; + } + if(ImGui::MenuItem("Ungroup")) + { + m_pending_project_action = [this, project_id]() { UngroupProject(project_id); }; + } + } +} + +void +AppWindow::RenderProjectMenuBody(Project* project) +{ + if(!project) + { + return; + } + const std::string project_id = project->GetID(); + + // Inline rename. Updates the group's tab labels directly (no reorder), so this is + // safe whether invoked from the File menu or from the tab-strip chip context menu + // (which runs during the tab strip's own render). + char name_buf[128] = { 0 }; + size_t copied = project->GetName().copy(name_buf, sizeof(name_buf) - 1); + name_buf[copied] = '\0'; + ImGui::SetNextItemWidth(180.0f); + if(ImGui::InputText(("###pm_rename_" + project_id).c_str(), name_buf, sizeof(name_buf))) + { + project->SetName(std::string(name_buf)); + for(const std::string& member_id : project->GetItemIds()) + { + m_tab_container->SetTabGroup(member_id, project->GetColor(), project_id, + project->GetName()); + } + } + + // Color swatch grid. + if(ImGui::BeginMenu(("Color###pm_color_" + project_id).c_str())) + { + const std::vector& palette = SettingsManager::GetInstance().GetColorWheel(); + for(size_t c = 0; c < palette.size(); c++) + { + ImVec4 swatch = ImGui::ColorConvertU32ToFloat4(palette[c]); + std::string swatch_id = "###pm_col_" + std::to_string(c) + project_id; + if(ImGui::ColorButton(swatch_id.c_str(), swatch, + ImGuiColorEditFlags_NoTooltip | ImGuiColorEditFlags_NoAlpha, + ImVec2(22.0f, 22.0f))) + { + project->SetColor(palette[c]); + for(const std::string& member_id : project->GetItemIds()) + { + m_tab_container->SetTabGroup(member_id, project->GetColor(), project_id, + project->GetName()); + } + } + if((c % 3) != 2) + { + ImGui::SameLine(); + } + } + ImGui::EndMenu(); + } + + // Pull another open tab into this group. + std::vector> addable; + for(const TabItem* tab : m_tab_container->GetTabs()) + { + Project* owner = GetProjectForItem(tab->m_id); + if(!owner || owner->GetID() != project_id) + { + ProjectItem* member = GetItem(tab->m_id); + addable.emplace_back(tab->m_id, member ? member->GetName() : tab->m_id); + } + } + if(!addable.empty() && ImGui::BeginMenu(("Add tab to group###pm_add_" + project_id).c_str())) + { + for(const std::pair& entry : addable) + { + if(ImGui::MenuItem((entry.second + "###pm_addtab_" + entry.first).c_str())) + { + std::string add_id = entry.first; + m_pending_project_action = [this, add_id, project_id]() { + AssignItemToProject(add_id, project_id); + }; + } + } + ImGui::EndMenu(); + } + + ImGui::Separator(); + + // Open members (click to focus the tab). + const std::vector& members = project->GetItemIds(); + if(!members.empty()) + { + ImGui::TextDisabled("Open"); + for(const std::string& member_id : members) + { + ProjectItem* member = GetItem(member_id); + std::string label = + (member ? member->GetName() : member_id) + "###pm_open_" + member_id; + if(ImGui::MenuItem(label.c_str())) + { + m_tab_container->SetActiveTab(member_id); + } + } + } + + // Closed members (click to reopen). + const std::vector& closed = project->GetClosedItems(); + if(!closed.empty()) + { + ImGui::Separator(); + ImGui::TextDisabled("Closed"); + for(size_t ci = 0; ci < closed.size(); ci++) + { + std::string label = closed[ci].name + " (reopen)###pm_closed_" + + std::to_string(ci) + project_id; + if(ImGui::MenuItem(label.c_str())) + { + size_t index = ci; + m_pending_project_action = [this, project_id, index]() { + ReopenClosedItem(project_id, index); + }; + } + } + } + + ImGui::Separator(); + if(ImGui::MenuItem(("Save Project...###pm_save_" + project_id).c_str())) + { + m_pending_project_action = [this, project_id]() { HandleSaveProjectGroup(project_id); }; + } + if(ImGui::MenuItem(("Ungroup###pm_ungroup_" + project_id).c_str())) + { + m_pending_project_action = [this, project_id]() { UngroupProject(project_id); }; + } + if(!members.empty() && + ImGui::MenuItem(("Close all tabs###pm_close_" + project_id).c_str())) + { + m_pending_project_action = [this, project_id]() { CloseProjectTabs(project_id); }; + } +} + +void +AppWindow::RenderProjectChipContextMenu(const std::string& group_id) +{ + Project* project = GetProjectById(group_id); + if(project) + { + RenderProjectMenuBody(project); + } +} + +void +AppWindow::RenderProjectsMenu() +{ + if(!ImGui::BeginMenu("Projects", !m_projects.empty())) + { + return; + } + for(std::unique_ptr& proj : m_projects) + { + const std::string project_id = proj->GetID(); + ImGui::PushStyleColor(ImGuiCol_Text, proj->GetColor()); + bool project_open = ImGui::BeginMenu((proj->GetName() + "###proj_" + project_id).c_str()); + ImGui::PopStyleColor(); + if(!project_open) + { + continue; + } + RenderProjectMenuBody(proj.get()); + ImGui::EndMenu(); + } + ImGui::EndMenu(); +} + void AppWindow::BeginAppShutdown() { @@ -482,12 +1392,12 @@ AppWindow::BeginAppShutdown() " cleanup job(s) remaining", NotificationLevel::Info); - for(auto& item : m_projects) + for(auto& item : m_items) { if(item.second) { item.second->Close(); - DetachProjectProviderCleanup(*item.second, + DetachItemProviderCleanup(*item.second, ProviderCleanupReason::kAppShutdown); } } @@ -498,7 +1408,7 @@ AppWindow::BeginAppShutdown() ProviderCleanupReason::kAppShutdown); #endif - m_projects.clear(); + m_items.clear(); if(m_main_view) { m_main_view->GetMutableAt(m_tool_bar_index)->m_item = nullptr; @@ -526,7 +1436,7 @@ AppWindow::BeginAppShutdown() } void -AppWindow::DetachProjectProviderCleanup(Project& project, ProviderCleanupReason reason) +AppWindow::DetachItemProviderCleanup(ProjectItem& project, ProviderCleanupReason reason) { std::shared_ptr root_view = std::dynamic_pointer_cast(project.GetView()); @@ -708,7 +1618,7 @@ AppWindow::WantsContinuousRender() // progressing. kLoading spans the whole load even when the pending count // briefly hits zero between stages, so we never freeze mid-load. bool wants_render = false; - for(const auto& [id, project] : m_projects) + for(const auto& [id, project] : m_items) { RootView* root_view = dynamic_cast(project->GetView().get()); if(root_view) @@ -759,7 +1669,7 @@ AppWindow::Render() ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10, 6)); if(ImGui::BeginMenuBar()) { - Project* project = GetCurrentProject(); + ProjectItem* project = GetCurrentItem(); RenderFileMenu(project); RenderEditMenu(project); RenderViewMenu(project); @@ -776,6 +1686,19 @@ AppWindow::Render() m_main_view->Render(); } + // Apply any queued project-group mutation now that all tab/menu rendering for the + // frame is done (reordering tabs / editing m_projects mid-render is unsafe). + if(m_pending_project_action) + { + std::function action = std::move(m_pending_project_action); + m_pending_project_action = nullptr; + action(); + // The action reorders/relabels tabs; keep drawing a few more frames so the + // lazy render loop actually paints the new layout (otherwise the change is + // invisible until the next input event). + RenderScheduler::GetInstance().RequestRenderForSeconds(0.2); + } + if(m_open_about_dialog) { ImGui::OpenPopup(ABOUT_DIALOG_NAME); @@ -935,25 +1858,35 @@ AppWindow::OpenFile(std::string file_path) return; } + // Every .rpv (and legacy .rpvproj) is a project: route it to the project loader, + // which handles both the new group format and old single-item files (opening the + // latter as a project with one tab). + std::string extension = std::filesystem::path(file_path).extension().string(); + if(extension == ".rpv" || extension == ".rpvproj") + { + OpenProjectGroupFile(file_path); + return; + } + spdlog::info("Opening file: {}", file_path); - std::unique_ptr project = std::make_unique(); + std::unique_ptr project = std::make_unique(); switch(project->Open(file_path)) { - case Project::OpenResult::Success: + case ProjectItem::OpenResult::Success: { TabItem tab = TabItem{ project->GetName(), project->GetID(), project->GetView(), true }; m_tab_container->AddTab(std::move(tab)); m_tab_container->SetActiveTab(project->GetID()); - m_projects[project->GetID()] = std::move(project); + m_items[project->GetID()] = std::move(project); SettingsManager::GetInstance().AddRecentFile(file_path); break; } - case Project::OpenResult::Duplicate: + case ProjectItem::OpenResult::Duplicate: { // trace already open, tell the user which tab and switch to it - Project* existing = GetProject(file_path); + ProjectItem* existing = GetItem(file_path); ShowMessageDialog("Trace Already Open", "This trace is already open in \"" + (existing ? existing->GetName() : file_path) + @@ -992,20 +1925,20 @@ AppWindow::OpenCompare(const std::string& first_file, const std::string& second_ // Synthetic, deterministic project id so the compare tab has a stable identity // without a file on disk (the two traces are loaded directly by the controller). const std::string compare_id = MakeCompareId({ first_file, second_file }); - if(GetProject(compare_id)) + if(GetItem(compare_id)) { m_tab_container->SetActiveTab(compare_id); return; } - std::unique_ptr project = std::make_unique(); + std::unique_ptr project = std::make_unique(); if(project->OpenCompare(compare_id, { first_file, second_file }) == - Project::OpenResult::Success) + ProjectItem::OpenResult::Success) { TabItem tab = TabItem{ project->GetName(), project->GetID(), project->GetView(), true }; m_tab_container->AddTab(std::move(tab)); - m_projects[project->GetID()] = std::move(project); + m_items[project->GetID()] = std::move(project); } } @@ -1043,7 +1976,7 @@ AppWindow::RenderDisableScreen() } void -AppWindow::RenderFileMenu(Project* project) +AppWindow::RenderFileMenu(ProjectItem* project) { bool is_open_file_dialog_open = ImGuiFileDialog::Instance()->IsOpened(FILE_DIALOG_NAME); #ifdef ROCPROFVIS_HAVE_NATIVE_FILE_DIALOG @@ -1062,17 +1995,31 @@ AppWindow::RenderFileMenu(Project* project) HandleCompareFiles(); } #endif - if(ImGui::MenuItem("Save", nullptr, false, - !is_open_file_dialog_open && (project && project->IsProject()))) - { - project->Save(); + // A single Save / Save As that operates on the active tab's whole project when + // it is grouped, or on just that item when it is not. "Save" re-saves to the + // remembered path; "Save As" always prompts. + Project* current_group = project ? GetProjectForItem(project->GetID()) : nullptr; + bool can_save = + !is_open_file_dialog_open && + (current_group ? current_group->IsSaved() : (project && project->IsSaved())); + if(ImGui::MenuItem("Save", nullptr, false, can_save)) + { + if(current_group) + { + SaveProjectGroup(current_group->GetID(), current_group->GetFilePath()); + } + else if(project) + { + project->Save(); + } } - if(ImGui::MenuItem("Save As", nullptr, false, - project && project->GetTraceType() == Project::System && - !is_open_file_dialog_open)) + if(ImGui::MenuItem("Save As...", nullptr, false, + project != nullptr && !is_open_file_dialog_open)) { HandleSaveAsFile(); } + + RenderProjectsMenu(); #ifdef ROCPROFVIS_ENABLE_PROFILER // TEMPORARY (profiler launch): remove guard when the feature graduates. @@ -1086,7 +2033,7 @@ AppWindow::RenderFileMenu(Project* project) TraceView* trace_view = nullptr; bool has_trace = false; bool cleanup_pending = false; - if(project && project->GetTraceType() == Project::System) + if(project && project->GetTraceType() == ProjectItem::System) { trace_view = dynamic_cast(project->GetView().get()); has_trace = (trace_view != nullptr); @@ -1156,7 +2103,7 @@ AppWindow::RenderFileMenu(Project* project) } void -AppWindow::RenderEditMenu(Project* project) +AppWindow::RenderEditMenu(ProjectItem* project) { if(ImGui::BeginMenu("Edit")) { @@ -1178,7 +2125,7 @@ AppWindow::RenderEditMenu(Project* project) } void -AppWindow::RenderViewMenu(Project* project) +AppWindow::RenderViewMenu(ProjectItem* project) { (void) project; @@ -1312,20 +2259,34 @@ AppWindow::HandleCompareFileBrowse(CompareFilesDialog::FileSlot slot) void AppWindow::HandleSaveAsFile() { - Project* project = GetCurrentProject(); - if(project) + ProjectItem* item = GetCurrentItem(); + if(!item) { - FileFilter trace_filter; - trace_filter.m_name = "Projects"; - trace_filter.m_extensions = { "rpv" }; - - std::vector filters; - filters.push_back(trace_filter); + return; + } - ShowSaveFileDialog( - "Save as Project", filters, "", - [project](std::string file_path) { project->SaveAs(file_path); }); + // Single "Save As": if the active tab belongs to a project, save the whole + // project (reusing the project save path); otherwise save just this item. + Project* group = GetProjectForItem(item->GetID()); + if(group) + { + HandleSaveProjectGroup(group->GetID()); + return; } + + FileFilter filter; + filter.m_name = "Projects"; + filter.m_extensions = { "rpv" }; + std::vector filters; + filters.push_back(filter); + std::string item_id = item->GetID(); + ShowSaveFileDialog("Save As", filters, "", [this, item_id](std::string file_path) { + ProjectItem* current = GetItem(item_id); + if(current) + { + current->SaveAs(file_path); + } + }); } void @@ -1333,12 +2294,12 @@ AppWindow::HandleTabClosed(std::shared_ptr e) { auto tab_closed_event = std::dynamic_pointer_cast(e); auto project_it = - tab_closed_event ? m_projects.find(tab_closed_event->GetTabId()) - : m_projects.end(); - if(tab_closed_event && project_it != m_projects.end()) + tab_closed_event ? m_items.find(tab_closed_event->GetTabId()) + : m_items.end(); + if(tab_closed_event && project_it != m_items.end()) { - auto activeProject = GetCurrentProject(); - if(!activeProject) + auto active_item = GetCurrentItem(); + if(!active_item) { spdlog::debug("No active project found after tab closed"); m_main_view->GetMutableAt(m_tool_bar_index)->m_item = nullptr; @@ -1346,9 +2307,9 @@ AppWindow::HandleTabClosed(std::shared_ptr e) else { spdlog::debug("Active project found after tab closed: {}", - activeProject->GetName()); + active_item->GetName()); std::shared_ptr root_view = - std::dynamic_pointer_cast(activeProject->GetView()); + std::dynamic_pointer_cast(active_item->GetView()); if(root_view) { m_main_view->GetMutableAt(m_tool_bar_index)->m_item = @@ -1356,10 +2317,27 @@ AppWindow::HandleTabClosed(std::shared_ptr e) } } spdlog::debug("Tab closed: {}", tab_closed_event->GetTabId()); + + // If this tab belonged to a project group, remember it as a closed item so it + // can be reopened from File > Projects, then drop its open membership. + const std::string& closed_id = tab_closed_event->GetTabId(); + Project* owning = GetProjectForItem(closed_id); + if(owning) + { + Project::ClosedItem closed; + closed.name = project_it->second->GetName(); + closed.files = project_it->second->GetFiles(); + owning->AddClosedItem(closed); + owning->RemoveItem(closed_id); + } + project_it->second->Close(); - DetachProjectProviderCleanup(*project_it->second, + DetachItemProviderCleanup(*project_it->second, ProviderCleanupReason::kTabClose); - m_projects.erase(project_it); + m_items.erase(project_it); + + // Refresh coloring/order after the tab is gone. + RefreshTabGroups(); } } @@ -1376,7 +2354,7 @@ AppWindow::HandleTabSelectionChanged(std::shared_ptr e) auto id = tab_selected_event->GetTabId(); spdlog::debug("Tab selected: {}", id); - auto project = GetProject(id); + auto project = GetItem(id); if(!project) { spdlog::warn("Project not found for tab: {}", id); @@ -1757,7 +2735,7 @@ AppWindow::UpdateStatusBar() { // Get number of pending requests from data provider size_t pending_requests = 0; - for(const auto& [id, project] : m_projects) + for(const auto& [id, project] : m_items) { auto root_view = dynamic_cast(project->GetView().get()); if(root_view) diff --git a/src/view/src/rocprofvis_appwindow.h b/src/view/src/rocprofvis_appwindow.h index 235ecba18..a227674a7 100644 --- a/src/view/src/rocprofvis_appwindow.h +++ b/src/view/src/rocprofvis_appwindow.h @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -36,6 +37,7 @@ class MessageDialog; #ifdef ROCPROFVIS_ENABLE_PROFILER class ProfilerLauncherDialog; // TEMPORARY (profiler launch) #endif +class ProjectItem; class Project; class WelcomePage; @@ -82,16 +84,20 @@ class AppWindow : public RocWidget const std::string& initial_path, std::function callback); - Project* GetProject(const std::string& id); - Project* GetCurrentProject(); + ProjectItem* GetItem(const std::string& id); + ProjectItem* GetCurrentItem(); void OpenFile(std::string file_path); + // Opens a batch of files. When more than one file is opened together they are + // auto-grouped into a new project (Chrome-style tab group). + void OpenFiles(const std::vector& file_paths); + // Opens two trace files as a single compare project (combined timeline, A/B tags). void OpenCompare(const std::string& first_file, const std::string& second_file); // Stable, file-derived project id/key for a compare of the given source files. - // Used as the tab id and the m_projects key for both fresh and reopened compares. + // Used as the tab id and the m_items key for both fresh and reopened compares. static std::string MakeCompareId(const std::vector& files); void ShowCloseConfirm(); @@ -126,11 +132,50 @@ class AppWindow : public RocWidget void RenderDisableScreen(); void RenderShutdownState(); - void RenderFileMenu(Project* project); - void RenderEditMenu(Project* project); - void RenderViewMenu(Project* project); + void RenderFileMenu(ProjectItem* project); + void RenderEditMenu(ProjectItem* project); + void RenderViewMenu(ProjectItem* project); void RenderHelpMenu(); + // Project (Chrome-style tab group) management. A Project owns an ordered set of + // open ProjectItem tab-ids plus a memory of closed items for reopening. + Project* GetProjectById(const std::string& project_id); + Project* GetProjectForItem(const std::string& item_id); + // Creates a project with the lowest free "Project N" name and matching color. + Project* CreateProject(); + // Creates a project with an explicit name/color (used when loading a saved + // project-group file). + Project* CreateProjectNamed(const std::string& name, ImU32 color); + // Saves / loads a whole project group (name, color, member filelists) to disk. + // Group files reuse the .rpv extension; IsProjectGroupFile inspects the JSON to + // tell a group .rpv apart from a single-item .rpv. + bool IsProjectGroupFile(const std::string& file_path); + void HandleSaveProjectGroup(const std::string& project_id); + void SaveProjectGroup(const std::string& project_id, const std::string& save_path); + void OpenProjectGroupFile(const std::string& file_path); + void AssignItemToProject(const std::string& item_id, const std::string& project_id); + void RemoveItemFromProjectMembership(const std::string& item_id); + void UngroupProject(const std::string& project_id); + void CloseProjectTabs(const std::string& project_id); + void ReopenClosedItem(const std::string& project_id, size_t closed_index); + // Pushes each Project's color/label onto its tabs and reorders tabs so group + // members are contiguous. Must run outside the tab container's Render(). + void RefreshTabGroups(); + void ReorderTabsForGroups(); + // Reorders each Project's member list to match the current on-screen tab order + // (invoked after the user drag-reorders tabs). + void SyncProjectOrderToTabs(); + // Emits the tab right-click group menu (registered as the tab context callback). + void RenderTabGroupContextMenu(const std::string& item_id); + // Emits the shared per-project menu body (rename, color, add/open/closed items, + // save, ungroup, close) used by both File > Projects and the group chip menu. + void RenderProjectMenuBody(Project* project); + // Emits the project-level menu for the tab-strip group chip (registered as the + // chip context callback). + void RenderProjectChipContextMenu(const std::string& group_id); + // Emits the File > Projects submenu. + void RenderProjectsMenu(); + void RenderFileDialog(); void RenderAboutDialog(); void RenderStatusBar(); @@ -145,7 +190,7 @@ class AppWindow : public RocWidget void HandleSaveAsFile(); void ConfigureFileDialogBackend(); void BeginAppShutdown(); - void DetachProjectProviderCleanup(Project& project, ProviderCleanupReason reason); + void DetachItemProviderCleanup(ProjectItem& project, ProviderCleanupReason reason); void StartProviderCleanup(DataProviderCleanupWork cleanup_work, const std::string& label, ProviderCleanupReason reason); @@ -175,7 +220,16 @@ class AppWindow : public RocWidget ImVec2 m_default_padding; ImVec2 m_default_spacing; - std::unordered_map> m_projects; + std::unordered_map> m_items; + + // Chrome-style tab groups. Each Project references a subset of m_items by id. + std::vector> m_projects; + size_t m_next_project_color = 0; + int m_project_counter = 0; + // Project mutations (create/assign/ungroup/close/reopen) are queued here and + // applied at the end of Render(), because reordering tabs or editing m_projects + // while the tab bar / menus are being drawn is unsafe. + std::function m_pending_project_action; EventManager::SubscriptionToken m_tabclosed_event_token; EventManager::SubscriptionToken m_tabselected_event_token; diff --git a/src/view/src/rocprofvis_presets.cpp b/src/view/src/rocprofvis_presets.cpp index 5f6fdf627..4d662dc8b 100644 --- a/src/view/src/rocprofvis_presets.cpp +++ b/src/view/src/rocprofvis_presets.cpp @@ -3,7 +3,7 @@ #include "rocprofvis_presets.h" #include "rocprofvis_appwindow.h" -#include "rocprofvis_project.h" +#include "rocprofvis_project_item.h" #include "rocprofvis_settings_manager.h" #include "rocprofvis_utils.h" #include "icons/rocprovfis_icon_defines.h" @@ -365,8 +365,8 @@ PresetBrowser::Render() "Recall Preset")) { PresetManager::Result result = PresetManager::Error; - const Project* project = - AppWindow::GetInstance()->GetCurrentProject(); + const ProjectItem* project = + AppWindow::GetInstance()->GetCurrentItem(); if(project) { result = m_presets.LoadPreset(project->GetID(), @@ -390,8 +390,8 @@ PresetBrowser::Render() "Overwrite Preset")) { PresetManager::Result result = PresetManager::Error; - const Project* project = - AppWindow::GetInstance()->GetCurrentProject(); + const ProjectItem* project = + AppWindow::GetInstance()->GetCurrentItem(); if(project) { result = m_presets.SavePreset(project->GetID(), @@ -468,7 +468,7 @@ PresetBrowser::Render() ImGui::GetColorU32(ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)))) { PresetManager::Result result = PresetManager::Error; - const Project* project = AppWindow::GetInstance()->GetCurrentProject(); + const ProjectItem* project = AppWindow::GetInstance()->GetCurrentItem(); if(project) { result = m_presets.SavePreset(project->GetID(), m_text_input, false); @@ -517,7 +517,7 @@ PresetBrowser::Render() ImGui::GetColorU32(ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)))) { bool result = false; - const Project* project = AppWindow::GetInstance()->GetCurrentProject(); + const ProjectItem* project = AppWindow::GetInstance()->GetCurrentItem(); if(project) { m_presets.ResetComponents(project->GetID()); diff --git a/src/view/src/rocprofvis_project.cpp b/src/view/src/rocprofvis_project.cpp index c199ce18d..f4475d764 100644 --- a/src/view/src/rocprofvis_project.cpp +++ b/src/view/src/rocprofvis_project.cpp @@ -2,424 +2,167 @@ // SPDX-License-Identifier: MIT #include "rocprofvis_project.h" -#include "rocprofvis_appwindow.h" -#include "rocprofvis_presets.h" -#include "rocprofvis_trace_view.h" -#include "rocprofvis_version.h" -#include "rocprofvis_utils.h" -#include "compute/rocprofvis_compute_view.h" -#include "widgets/rocprofvis_notification_manager.h" -#include -constexpr const char* PROJECT_VERSION = "1.0"; +#include namespace RocProfVis { namespace View { -Project::Project() -: m_view(nullptr) -, m_trace_type(Undefined) +Project::Project(const std::string& id, const std::string& name, ImU32 color) +: m_id(id) +, m_name(name) +, m_color(color) +, m_collapsed(false) {} -Project::~Project() {} - -std::string +const std::string& Project::GetID() const { - return m_trace_file_path; + return m_id; } -std::string +const std::string& Project::GetName() const { return m_name; } -std::shared_ptr -Project::GetView() +void +Project::SetName(const std::string& name) { - return m_view; + m_name = name; } -Project::TraceType -Project::GetTraceType() const +ImU32 +Project::GetColor() const { - return m_trace_type; + return m_color; } -bool -Project::IsProject() const +void +Project::SetColor(ImU32 color) { - return !m_project_file_path.empty(); + m_color = color; } -Project::OpenResult -Project::Open(std::string& file_path) +bool +Project::IsCollapsed() const { - OpenResult result = Failed; - m_open_error_message.clear(); - if(std::filesystem::exists(file_path)) - { - std::string file_ext = std::filesystem::path(file_path).extension().string(); - if(file_ext == ".rpv") - { - result = OpenProject(file_path); - } - else - { - result = OpenTrace(file_path); - } - - if(result == Failed) - { - // Use the specific failure message if one was set, else a generic one. - AppWindow::GetInstance()->ShowMessageDialog( - "Error", - m_open_error_message.empty() - ? "The file could not be opened:\n\n" + file_path + - "\n\nPlease make sure the file is a valid trace or project file." - : m_open_error_message); - spdlog::error("Failed to open file: {}", file_path); - } - } - else - { - AppWindow::GetInstance()->ShowMessageDialog( - "Recent File Not Found", - "This recent file could not be found and was removed from the list:\n\n" + - file_path); - spdlog::error("Failed to open file: {}, file does not exist", file_path); - } - return result; + return m_collapsed; } void -Project::Save() +Project::SetCollapsed(bool collapsed) { - if(IsProject() && SaveSetttingsJson()) - { - SettingsManager::GetInstance().AddRecentFile(m_project_file_path); - NotificationManager::GetInstance().Show("Saved " + m_project_file_path + ".", - NotificationLevel::Success); - } - else - { - NotificationManager::GetInstance().Show("Failed to save project.", - NotificationLevel::Error); - } + m_collapsed = collapsed; } -void -Project::SaveAs(const std::string& file_path) +const std::vector& +Project::GetItemIds() const { - m_project_file_path = file_path; - m_name = std::filesystem::path(m_project_file_path).filename().string(); - AppWindow::GetInstance()->SetTabLabel(GetName(), GetID()); - Save(); + return m_item_ids; } void -Project::Close() +Project::SetItemOrder(const std::vector& ordered) { - PresetManager::GetInstance().UnregisterComponents(m_trace_file_path); -} - -Project::OpenResult -Project::OpenProject(std::string& file_path) -{ - OpenResult result = Failed; - std::ifstream file(file_path); - if(file.is_open()) + std::vector result; + result.reserve(m_item_ids.size()); + for(const std::string& id : ordered) { - std::string json_string; - std::string json_line; - while(std::getline(file, json_line)) - { - json_string += std::move(json_line); - } - std::pair json_parsed = jt::Json::parse(json_string); - if(json_parsed.first == jt::Json::success && JsonValidForLoad(json_parsed.second)) + if(ContainsItem(id)) { - m_project_file_path = file_path; - m_settings_json = json_parsed.second; - std::filesystem::path project_dir = - std::filesystem::path(m_project_file_path).parent_path(); - jt::Json& general = m_settings_json[JSON_KEY_GROUP_GENERAL]; - - if(general[JSON_KEY_GENERAL_COMPARE_FILES].isArray()) - { - std::vector files; - for(jt::Json& entry : general[JSON_KEY_GENERAL_COMPARE_FILES].getArray()) - { - files.push_back( - std::filesystem::weakly_canonical( - project_dir / std::filesystem::path(entry.getString())) - .string()); - } - std::string compare_id = AppWindow::MakeCompareId(files); - if(AppWindow::GetInstance()->GetProject(compare_id)) - { - file_path = compare_id; - result = Duplicate; - NotificationManager::GetInstance().Show( - "This comparison is already open.", NotificationLevel::Warning); - } - else - { - result = OpenCompare(compare_id, files); - } - } - else - { - std::string trace_path = - std::filesystem::weakly_canonical( - project_dir / std::filesystem::path( - general[JSON_KEY_GENERAL_TRACE_PATH].getString())) - .string(); - if(std::filesystem::exists(trace_path)) - { - result = OpenTrace(trace_path); - if(result == Duplicate) - { - file_path = trace_path; - } - } - else - { - // Referenced trace is gone: name it and don't open it, which would - // create an empty database at its original path. - m_open_error_message = - "The trace file referenced by this project could not be " - "found:\n\n" + - trace_path + - "\n\nIt may have been moved or deleted. Restore it and try " - "again."; - spdlog::error("Failed to open project {}: referenced trace file " - "does not exist: {}", - file_path, trace_path); - } - } - } - else - { - m_open_error_message = "Failed to load project:\n\n" + file_path + - "\n\nThe project file is invalid or corrupted."; + result.push_back(id); } - file.close(); - } - return result; -} - -Project::OpenResult -Project::OpenTrace(std::string& file_path) -{ - OpenResult open_result = Failed; - // canonicalize so a .db and the path stored in a .rpv resolve to the same trace - file_path = std::filesystem::weakly_canonical(file_path).string(); - // trace already open, return duplicate so we switch tabs instead of loading it twice - Project* duplicate = AppWindow::GetInstance()->GetProject(file_path); - if(duplicate) - { - file_path = duplicate->GetID(); - open_result = Duplicate; } - else if(!m_view) + for(const std::string& id : m_item_ids) { - bool trace_result = false; - TraceType trace_type = Undefined; - std::shared_ptr view = nullptr; - std::string config_path = get_application_config_path(true); - rocprofvis_controller_t* controller = - rocprofvis_controller_alloc(file_path.c_str(), config_path.c_str()); - if(controller) - { - rocprofvis_controller_object_type_t type = - kRPVControllerObjectTypeControllerSystem; - rocprofvis_result_t controller_result = - rocprofvis_controller_get_object_type(controller, &type); - if(controller_result == kRocProfVisResultSuccess) - { - if(type == kRPVControllerObjectTypeControllerSystem) - { - std::shared_ptr trace_view = std::make_shared(); - trace_result = trace_view->LoadTrace(controller, file_path); - trace_type = System; - view = trace_view; - } - else if(type == kRPVControllerObjectTypeControllerCompute) - { - std::shared_ptr compute_view = - std::make_shared(); - trace_result = compute_view->LoadTrace(controller, file_path); - trace_type = Compute; - view = compute_view; - } - } - } - if(trace_result && view) + if(std::find(result.begin(), result.end(), id) == result.end()) { - m_trace_file_path = file_path; - m_trace_type = trace_type; - m_view = view; - m_name = std::filesystem::path(IsProject() ? m_project_file_path - : m_trace_file_path) - .filename() - .string(); - open_result = Success; - } - else - { - rocprofvis_controller_free(controller); + result.push_back(id); } } - return open_result; + m_item_ids = result; } -Project::OpenResult -Project::OpenCompare(const std::string& project_id, - const std::vector& file_paths) +bool +Project::AddItem(const std::string& item_id) { - OpenResult result = Failed; - if(file_paths.size() < 2 || m_view) + bool added = false; + if(!ContainsItem(item_id)) { - return result; + m_item_ids.push_back(item_id); + added = true; } + return added; +} - for(const std::string& path : file_paths) +bool +Project::RemoveItem(const std::string& item_id) +{ + bool removed = false; + std::vector::iterator it = + std::find(m_item_ids.begin(), m_item_ids.end(), item_id); + if(it != m_item_ids.end()) { - if(!std::filesystem::exists(path)) - { - AppWindow::GetInstance()->ShowMessageDialog("Error", - "File does not exist: " + path); - spdlog::error("Failed to open compare file: {}, file does not exist", path); - return result; - } + m_item_ids.erase(it); + removed = true; } + return removed; +} - std::vector file_ptrs; - file_ptrs.reserve(file_paths.size()); - for(const std::string& path : file_paths) - { - file_ptrs.push_back(path.c_str()); - } +bool +Project::ContainsItem(const std::string& item_id) const +{ + return std::find(m_item_ids.begin(), m_item_ids.end(), item_id) != m_item_ids.end(); +} - rocprofvis_controller_t* controller = - rocprofvis_controller_alloc_compare(file_ptrs.data(), file_ptrs.size()); - if(controller) - { - std::shared_ptr trace_view = std::make_shared(); - if(trace_view->LoadTrace(controller, project_id)) - { - // Tag each source A, B, ... in selection order so the timeline and sidebar - // badges can resolve a track's instance index back to its file. - std::vector sources; - sources.reserve(file_paths.size()); - for(size_t i = 0; i < file_paths.size(); i++) - { - CompareSourceInfo info; - info.id = std::string(1, static_cast('A' + i)); - info.name = std::filesystem::path(file_paths[i]).stem().string(); - info.path = file_paths[i]; - sources.push_back(std::move(info)); - } - if(DataProvider* provider = trace_view->GetDataProvider()) - { - provider->DataModel().SetCompareSources(sources); - } +const std::vector& +Project::GetClosedItems() const +{ + return m_closed_items; +} - m_trace_file_path = project_id; - m_compare_files = file_paths; - m_trace_type = System; - m_view = trace_view; - m_name = "Compare: " + sources[0].name + " vs " + sources[1].name; - result = Success; - } - else - { - rocprofvis_controller_free(controller); - } - } +void +Project::AddClosedItem(const ClosedItem& item) +{ + m_closed_items.push_back(item); +} - if(result == Failed) +void +Project::RemoveClosedItemAt(size_t index) +{ + if(index < m_closed_items.size()) { - AppWindow::GetInstance()->ShowMessageDialog( - "Error", "The selected traces could not be opened for comparison."); + m_closed_items.erase(m_closed_items.begin() + index); } - return result; } bool -Project::JsonValidForLoad(jt::Json& json) +Project::Empty() const { - jt::Json& general = json[JSON_KEY_GROUP_GENERAL]; - return general[JSON_KEY_GENERAL_TRACE_PATH].isString() || - general[JSON_KEY_GENERAL_COMPARE_FILES].isArray(); + return m_item_ids.empty() && m_closed_items.empty(); } -void -Project::RegisterSetting(ProjectSetting* setting) +const std::string& +Project::GetFilePath() const { - m_settings.push_back(setting); + return m_file_path; } -jt::Json& -Project::GetSettingsJson() +void +Project::SetFilePath(const std::string& file_path) { - return m_settings_json; + m_file_path = file_path; } bool -Project::SaveSetttingsJson() -{ - bool result = false; - m_settings_json = ""; - m_settings_json[JSON_KEY_GROUP_GENERAL][JSON_KEY_GENERAL_VERSION] = PROJECT_VERSION; - std::filesystem::path project_dir = - std::filesystem::path(m_project_file_path).parent_path(); - if(!m_compare_files.empty()) - { - // Compare project: persist the source files (relative to the .rpv) so it can be - // reopened as a combined trace without a separate manifest on disk. - jt::Json& compare_files = - m_settings_json[JSON_KEY_GROUP_GENERAL][JSON_KEY_GENERAL_COMPARE_FILES]; - for(size_t i = 0; i < m_compare_files.size(); i++) - { - compare_files[i] = - std::filesystem::proximate(m_compare_files[i], project_dir).generic_string(); - } - } - else - { - m_settings_json[JSON_KEY_GROUP_GENERAL][JSON_KEY_GENERAL_TRACE_PATH] = - std::filesystem::proximate(m_trace_file_path, project_dir).generic_string(); - } - for(ProjectSetting* setting : m_settings) - { - setting->ToJson(); - } - if(!m_settings_json.isNull()) - { - std::ofstream file(m_project_file_path); - if(file.is_open()) - { - file << m_settings_json.toStringPretty() << "\n"; - file.close(); - result = true; - } - } - return result; -} - -ProjectSetting::ProjectSetting(const std::string project_id) -: m_project(*AppWindow::GetInstance()->GetProject(project_id)) -, m_settings_json(m_project.GetSettingsJson()) +Project::IsSaved() const { - m_project.RegisterSetting(this); + return !m_file_path.empty(); } -ProjectSetting::~ProjectSetting() {} - } // namespace View } // namespace RocProfVis diff --git a/src/view/src/rocprofvis_project.h b/src/view/src/rocprofvis_project.h index 7b81520b7..5aa5f3b3e 100644 --- a/src/view/src/rocprofvis_project.h +++ b/src/view/src/rocprofvis_project.h @@ -2,9 +2,9 @@ // SPDX-License-Identifier: MIT #pragma once -#include "json.h" -#include -#include + +#include "imgui.h" +#include #include #include @@ -13,193 +13,63 @@ namespace RocProfVis namespace View { -class RocWidget; -class ProjectSetting; - +// A Project is a Chrome-style "tab group": a named, colored, ordered collection +// of Items (open tabs) plus a memory of Items that were closed but still belong +// to the project so they can be reopened. A single tab is an ProjectItem +// (rocprofvis_project_item.h); a Project groups several Items together. class Project { public: - enum OpenResult - { - Success, - Duplicate, - Failed, - }; - - enum TraceType + // A member ProjectItem that was closed but is remembered so it can be reopened. + struct ClosedItem { - Undefined, - System, - Compute, + std::string name; // display label (file/tab name) + std::vector files; // filelist to reopen (1 = trace/compute, 2+ = compare) }; - Project(); - virtual ~Project(); - - /* - * Returns the ID of the project. - * (Internally this is the trace path) - */ - std::string GetID() const; - /* - * Returns the file name of the project/trace. - */ - std::string GetName() const; - /* - * Returns the RocWidget that renders the project. - */ - std::shared_ptr GetView(); - /* - * Returns the trace type of the project. - */ - TraceType GetTraceType() const; - /* - * Returns true if the project is saved as a project (as opposed to a trace). - */ - bool IsProject() const; - - /* - * Opens a project or trace file and returns Success/Duplicate/Failed. - * @param file_path: The path of the file to open. If Duplicate is returned, this will - * be set to the path of the duplicate which can be used to identify and open the - * duplicate's tab. - */ - OpenResult Open(std::string& file_path); - /* - * Opens two or more trace files as a single combined compare project. The traces - * overlay on one timeline and each track is tagged with its source (A, B, ...). - * @param project_id: Synthetic, stable id/key for the project (it has no single - * file path on disk). - * @param file_paths: The trace files to combine, tagged A, B, ... in order. - */ - OpenResult OpenCompare(const std::string& project_id, - const std::vector& file_paths); - /* - * Overwrites the project settings to the project file without further user input. - */ - void Save(); - /* - * Opens file dialog and saves the project settings to a specified file. - * @param file_path: The path of the file to save as. - */ - void SaveAs(const std::string& file_path); - /* - * Clean up tasks prior to being deleted. - */ - void Close(); - /* - * Adds a participant to the project settings serialize/deserialize process. - * @param setting: The settings object to include in the serialize/deserialize - * process. - */ - void RegisterSetting(ProjectSetting* setting); - /* - * Returns the project settings json. - */ - jt::Json& GetSettingsJson(); + Project(const std::string& id, const std::string& name, ImU32 color); + + const std::string& GetID() const; + const std::string& GetName() const; + void SetName(const std::string& name); + ImU32 GetColor() const; + void SetColor(ImU32 color); + // Reserved for the follow-up collapse/expand interaction. + bool IsCollapsed() const; + void SetCollapsed(bool collapsed); + + // Open member ProjectItem ids, in group order. + const std::vector& GetItemIds() const; + // Reorders the open members to match the given order (ids not currently members + // are ignored; any members omitted keep their relative order at the end). + void SetItemOrder(const std::vector& ordered); + // Adds an open member if not already present. Returns true if it was added. + bool AddItem(const std::string& item_id); + // Removes an open member if present. Returns true if it was removed. + bool RemoveItem(const std::string& item_id); + bool ContainsItem(const std::string& item_id) const; + + const std::vector& GetClosedItems() const; + void AddClosedItem(const ClosedItem& item); + void RemoveClosedItemAt(size_t index); + + // A project is empty (safe to delete) when it has neither open nor closed items. + bool Empty() const; + + // The .rpv file this project was loaded from / last saved to (empty if never + // saved). Lets "Save" re-save the whole project without a dialog. + const std::string& GetFilePath() const; + void SetFilePath(const std::string& file_path); + bool IsSaved() const; private: - /* - * Opens a project + attached trace file and returns Success/Duplicate/Failed. - * @param file_path: The path of the file to open. If Duplicate is returned, this will - * be set to the path of the duplicate which can be used to identify and open the - * duplicate's tab. - */ - OpenResult OpenProject(std::string& file_path); - /* - * Opens a trace file and returns Success/Duplicate/Failed. - * @param file_path: The path of the file to open. If Duplicate is returned, this will - * be set to the path of the duplicate which can be used to identify and open the - * duplicate's tab. - */ - OpenResult OpenTrace(std::string& file_path); - /* - * Performs basic validation on the project settings for fields required to open a - * trace. - */ - bool JsonValidForLoad(jt::Json& json); - /* - * Writes the project settings into m_project_file_path; - */ - bool SaveSetttingsJson(); - - std::string m_name; - std::string m_project_file_path; - std::string m_trace_file_path; - // Source trace files when this is a compare project (empty otherwise). Persisted to - // the .rpv so the compare can be reopened. - std::vector m_compare_files; - TraceType m_trace_type; - std::shared_ptr m_view; - std::list m_settings; - jt::Json m_settings_json; - // Specific open-failure message; empty falls back to the generic one. - std::string m_open_error_message; -}; - -constexpr const char* JSON_KEY_GROUP_GENERAL = "general"; -constexpr const char* JSON_KEY_GROUP_TIMELINE = "timeline"; - -constexpr const char* JSON_KEY_GENERAL_VERSION = "version"; -constexpr const char* JSON_KEY_GENERAL_TRACE_PATH = "trace_path"; -constexpr const char* JSON_KEY_GENERAL_COMPARE_FILES = "compare_files"; - -constexpr const char* JSON_KEY_TIMELINE_BOOKMARK = "bookmarks"; -constexpr const char* JSON_KEY_TIMELINE_BOOKMARK_KEY = "key"; -constexpr const char* JSON_KEY_TIMELINE_BOOKMARK_V_MIN_X = "view_start_ns "; -constexpr const char* JSON_KEY_TIMELINE_BOOKMARK_V_MAX_X = "view_end_ns"; -constexpr const char* JSON_KEY_TIMELINE_BOOKMARK_Y = "y"; -constexpr const char* JSON_KEY_TIMELINE_BOOKMARK_Z = "z"; - -constexpr const char* JSON_KEY_TIMELINE_TRACK = "tracks"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_ORDER = "order"; -constexpr const char* JSON_KEY_TIMELINE_SORT_MODE = "sort_mode"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_DISPLAY = "display"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_HEIGHT = "height"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_COMPACT_MODE = "compact_mode"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_COLOR = "color"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_COLOR_RANGE_MIN = "color_min"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_COLOR_RANGE_MAX = "color_max"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_BOX_PLOT = "box_plot"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_STRIPES = "box_plot_stripes"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_MIN = "min"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_MAX = "max"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_MEAN = "mean"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_STANDARD_DEVIATION = "standard_deviation"; -constexpr const char* JSON_KEY_TIMELINE_TRACK_QUEUE_UTILIZATION = "queue_utilization"; - -constexpr const char* JSON_KEY_ANNOTATIONS = "annotations"; -constexpr const char* JSON_KEY_ANNOTATION_TIME_NS = "time_ns"; -constexpr const char* JSON_KEY_ANNOTATION_Y_OFFSET = "y_offset"; -constexpr const char* JSON_KEY_ANNOTATION_SIZE_X = "size_x"; -constexpr const char* JSON_KEY_ANNOTATION_SIZE_Y = "size_y"; -constexpr const char* JSON_KEY_ANNOTATION_TEXT = "text"; -constexpr const char* JSON_KEY_ANNOTATION_TITLE = "title"; -constexpr const char* JSON_KEY_ANNOTATION_ID = "id"; -constexpr const char* JSON_KEY_ANNOTATION_TRACK_ID = "track_id"; -constexpr const char* JSON_KEY_TIMELINE_ANNOTATION_V_MIN_X = "view_start_ns"; -constexpr const char* JSON_KEY_TIMELINE_ANNOTATION_V_MAX_X = "view_end_ns"; -constexpr const char* JSON_KEY_ANNOTATION_IS_MINIMIZED = "is_minimized"; -constexpr const char* JSON_KEY_ANNOTATION_IS_LOCKED = "is_locked"; - -class ProjectSetting -{ -public: - ProjectSetting(const std::string project_id); - virtual ~ProjectSetting(); - /* - * Called by the owning project during serialization. Implementation should update its - * project settings into m_settings_json. - */ - virtual void ToJson() = 0; - /* - * Implementation should validate any fields it cares about before reading. - */ - virtual bool Valid() const = 0; - -protected: - Project& m_project; - jt::Json& m_settings_json; + std::string m_id; + std::string m_name; + ImU32 m_color; + bool m_collapsed; + std::vector m_item_ids; // open members (tab ids), ordered + std::vector m_closed_items; // closed-but-remembered members + std::string m_file_path; // associated .rpv (empty = unsaved) }; } // namespace View diff --git a/src/view/src/rocprofvis_project_item.cpp b/src/view/src/rocprofvis_project_item.cpp new file mode 100644 index 000000000..debc332a1 --- /dev/null +++ b/src/view/src/rocprofvis_project_item.cpp @@ -0,0 +1,498 @@ +// Copyright Advanced Micro Devices, Inc. +// SPDX-License-Identifier: MIT + +#include "rocprofvis_project_item.h" +#include "rocprofvis_appwindow.h" +#include "rocprofvis_presets.h" +#include "rocprofvis_trace_view.h" +#include "rocprofvis_version.h" +#include "rocprofvis_utils.h" +#include "compute/rocprofvis_compute_view.h" +#include "widgets/rocprofvis_notification_manager.h" +#include + +constexpr const char* PROJECT_VERSION = "1.0"; + +namespace RocProfVis +{ +namespace View +{ + +ProjectItem::ProjectItem() +: m_view(nullptr) +, m_trace_type(Undefined) +{} + +ProjectItem::~ProjectItem() {} + +std::string +ProjectItem::GetID() const +{ + return m_trace_file_path; +} + +std::string +ProjectItem::GetName() const +{ + return m_name; +} + +std::shared_ptr +ProjectItem::GetView() +{ + return m_view; +} + +ProjectItem::TraceType +ProjectItem::GetTraceType() const +{ + return m_trace_type; +} + +bool +ProjectItem::IsSaved() const +{ + return !m_project_file_path.empty(); +} + +std::vector +ProjectItem::GetFiles() const +{ + if(!m_compare_files.empty()) + { + return m_compare_files; + } + return std::vector{ m_trace_file_path }; +} + +ProjectItem::OpenResult +ProjectItem::Open(std::string& file_path) +{ + OpenResult result = Failed; + m_open_error_message.clear(); + if(std::filesystem::exists(file_path)) + { + std::string file_ext = std::filesystem::path(file_path).extension().string(); + if(file_ext == ".rpv") + { + result = OpenProject(file_path); + } + else + { + result = OpenTrace(file_path); + } + + if(result == Failed) + { + // Use the specific failure message if one was set, else a generic one. + AppWindow::GetInstance()->ShowMessageDialog( + "Error", + m_open_error_message.empty() + ? "The file could not be opened:\n\n" + file_path + + "\n\nPlease make sure the file is a valid trace or project file." + : m_open_error_message); + spdlog::error("Failed to open file: {}", file_path); + } + } + else + { + AppWindow::GetInstance()->ShowMessageDialog( + "Recent File Not Found", + "This recent file could not be found and was removed from the list:\n\n" + + file_path); + spdlog::error("Failed to open file: {}, file does not exist", file_path); + } + return result; +} + +void +ProjectItem::Save() +{ + if(IsSaved() && SaveSetttingsJson()) + { + SettingsManager::GetInstance().AddRecentFile(m_project_file_path); + NotificationManager::GetInstance().Show("Saved " + m_project_file_path + ".", + NotificationLevel::Success); + } + else + { + NotificationManager::GetInstance().Show("Failed to save project.", + NotificationLevel::Error); + } +} + +void +ProjectItem::SaveAs(const std::string& file_path) +{ + m_project_file_path = file_path; + m_name = std::filesystem::path(m_project_file_path).filename().string(); + AppWindow::GetInstance()->SetTabLabel(GetName(), GetID()); + Save(); +} + +void +ProjectItem::Close() +{ + PresetManager::GetInstance().UnregisterComponents(m_trace_file_path); +} + +ProjectItem::OpenResult +ProjectItem::OpenProject(std::string& file_path) +{ + OpenResult result = Failed; + std::ifstream file(file_path); + if(file.is_open()) + { + std::string json_string; + std::string json_line; + while(std::getline(file, json_line)) + { + json_string += std::move(json_line); + } + std::pair json_parsed = jt::Json::parse(json_string); + if(json_parsed.first == jt::Json::success && JsonValidForLoad(json_parsed.second)) + { + m_project_file_path = file_path; + m_settings_json = json_parsed.second; + std::filesystem::path project_dir = + std::filesystem::path(m_project_file_path).parent_path(); + jt::Json& general = m_settings_json[JSON_KEY_GROUP_GENERAL]; + + if(general[JSON_KEY_GENERAL_COMPARE_FILES].isArray()) + { + std::vector files; + for(jt::Json& entry : general[JSON_KEY_GENERAL_COMPARE_FILES].getArray()) + { + files.push_back( + std::filesystem::weakly_canonical( + project_dir / std::filesystem::path(entry.getString())) + .string()); + } + std::string compare_id = AppWindow::MakeCompareId(files); + if(AppWindow::GetInstance()->GetItem(compare_id)) + { + file_path = compare_id; + result = Duplicate; + NotificationManager::GetInstance().Show( + "This comparison is already open.", NotificationLevel::Warning); + } + else + { + result = OpenCompare(compare_id, files); + } + } + else + { + std::string trace_path = + std::filesystem::weakly_canonical( + project_dir / std::filesystem::path( + general[JSON_KEY_GENERAL_TRACE_PATH].getString())) + .string(); + if(std::filesystem::exists(trace_path)) + { + result = OpenTrace(trace_path); + if(result == Duplicate) + { + file_path = trace_path; + } + } + else + { + // Referenced trace is gone: name it and don't open it, which would + // create an empty database at its original path. + m_open_error_message = + "The trace file referenced by this project could not be " + "found:\n\n" + + trace_path + + "\n\nIt may have been moved or deleted. Restore it and try " + "again."; + spdlog::error("Failed to open project {}: referenced trace file " + "does not exist: {}", + file_path, trace_path); + } + } + } + else + { + m_open_error_message = "Failed to load project:\n\n" + file_path + + "\n\nThe project file is invalid or corrupted."; + } + file.close(); + } + return result; +} + +ProjectItem::OpenResult +ProjectItem::OpenTrace(std::string& file_path) +{ + OpenResult open_result = Failed; + // canonicalize so a .db and the path stored in a .rpv resolve to the same trace + file_path = std::filesystem::weakly_canonical(file_path).string(); + // trace already open, return duplicate so we switch tabs instead of loading it twice + ProjectItem* duplicate = AppWindow::GetInstance()->GetItem(file_path); + if(duplicate) + { + file_path = duplicate->GetID(); + open_result = Duplicate; + } + else if(!m_view) + { + bool trace_result = false; + TraceType trace_type = Undefined; + std::shared_ptr view = nullptr; + std::string config_path = get_application_config_path(true); + rocprofvis_controller_t* controller = + rocprofvis_controller_alloc(file_path.c_str(), config_path.c_str()); + if(controller) + { + rocprofvis_controller_object_type_t type = + kRPVControllerObjectTypeControllerSystem; + rocprofvis_result_t controller_result = + rocprofvis_controller_get_object_type(controller, &type); + if(controller_result == kRocProfVisResultSuccess) + { + if(type == kRPVControllerObjectTypeControllerSystem) + { + std::shared_ptr trace_view = std::make_shared(); + trace_result = trace_view->LoadTrace(controller, file_path); + trace_type = System; + view = trace_view; + } + else if(type == kRPVControllerObjectTypeControllerCompute) + { + std::shared_ptr compute_view = + std::make_shared(); + trace_result = compute_view->LoadTrace(controller, file_path); + trace_type = Compute; + view = compute_view; + } + } + } + if(trace_result && view) + { + m_trace_file_path = file_path; + m_trace_type = trace_type; + m_view = view; + m_name = std::filesystem::path(IsSaved() ? m_project_file_path + : m_trace_file_path) + .filename() + .string(); + open_result = Success; + } + else + { + rocprofvis_controller_free(controller); + } + } + return open_result; +} + +ProjectItem::OpenResult +ProjectItem::OpenCompare(const std::string& project_id, + const std::vector& file_paths) +{ + OpenResult result = Failed; + if(file_paths.size() < 2 || m_view) + { + return result; + } + + for(const std::string& path : file_paths) + { + if(!std::filesystem::exists(path)) + { + AppWindow::GetInstance()->ShowMessageDialog("Error", + "File does not exist: " + path); + spdlog::error("Failed to open compare file: {}, file does not exist", path); + return result; + } + } + + std::vector file_ptrs; + file_ptrs.reserve(file_paths.size()); + for(const std::string& path : file_paths) + { + file_ptrs.push_back(path.c_str()); + } + + rocprofvis_controller_t* controller = + rocprofvis_controller_alloc_compare(file_ptrs.data(), file_ptrs.size()); + if(controller) + { + std::shared_ptr trace_view = std::make_shared(); + if(trace_view->LoadTrace(controller, project_id)) + { + // Tag each source A, B, ... in selection order so the timeline and sidebar + // badges can resolve a track's instance index back to its file. + std::vector sources; + sources.reserve(file_paths.size()); + for(size_t i = 0; i < file_paths.size(); i++) + { + CompareSourceInfo info; + info.id = std::string(1, static_cast('A' + i)); + info.name = std::filesystem::path(file_paths[i]).stem().string(); + info.path = file_paths[i]; + sources.push_back(std::move(info)); + } + if(DataProvider* provider = trace_view->GetDataProvider()) + { + provider->DataModel().SetCompareSources(sources); + } + + m_trace_file_path = project_id; + m_compare_files = file_paths; + m_trace_type = System; + m_view = trace_view; + m_name = "Compare: " + sources[0].name + " vs " + sources[1].name; + result = Success; + } + else + { + rocprofvis_controller_free(controller); + } + } + + if(result == Failed) + { + AppWindow::GetInstance()->ShowMessageDialog( + "Error", "The selected traces could not be opened for comparison."); + } + return result; +} + +bool +ProjectItem::JsonValidForLoad(jt::Json& json) +{ + jt::Json& general = json[JSON_KEY_GROUP_GENERAL]; + return general[JSON_KEY_GENERAL_TRACE_PATH].isString() || + general[JSON_KEY_GENERAL_COMPARE_FILES].isArray(); +} + +void +ProjectItem::RegisterSetting(ProjectItemSetting* setting) +{ + m_settings.push_back(setting); +} + +jt::Json& +ProjectItem::GetSettingsJson() +{ + return m_settings_json; +} + +jt::Json +ProjectItem::ExportSettingsJson(const std::filesystem::path& base_dir) +{ + m_settings_json = ""; + m_settings_json[JSON_KEY_GROUP_GENERAL][JSON_KEY_GENERAL_VERSION] = PROJECT_VERSION; + if(!m_compare_files.empty()) + { + // Compare item: persist the source files (relative to base_dir) so it can be + // reopened as a combined trace without a separate manifest on disk. + jt::Json& compare_files = + m_settings_json[JSON_KEY_GROUP_GENERAL][JSON_KEY_GENERAL_COMPARE_FILES]; + for(size_t i = 0; i < m_compare_files.size(); i++) + { + compare_files[i] = + std::filesystem::proximate(m_compare_files[i], base_dir).generic_string(); + } + } + else + { + m_settings_json[JSON_KEY_GROUP_GENERAL][JSON_KEY_GENERAL_TRACE_PATH] = + std::filesystem::proximate(m_trace_file_path, base_dir).generic_string(); + } + // Each registered per-view setting writes its current state (track heights/order, + // bookmarks, annotations, ...) into m_settings_json. + for(ProjectItemSetting* setting : m_settings) + { + setting->ToJson(); + } + return m_settings_json; +} + +ProjectItem::OpenResult +ProjectItem::OpenFromSettingsJson(const jt::Json& settings, const std::filesystem::path& base_dir, + std::string& out_id) +{ + OpenResult result = Failed; + m_open_error_message.clear(); + // Inject the settings before the view is built so the per-view settings objects + // restore from it (same restore path as opening a single-item .rpv). + m_settings_json = settings; + if(!JsonValidForLoad(m_settings_json)) + { + return Failed; + } + jt::Json& general = m_settings_json[JSON_KEY_GROUP_GENERAL]; + if(general[JSON_KEY_GENERAL_COMPARE_FILES].isArray()) + { + std::vector files; + for(jt::Json& entry : general[JSON_KEY_GENERAL_COMPARE_FILES].getArray()) + { + files.push_back(std::filesystem::weakly_canonical( + base_dir / std::filesystem::path(entry.getString())) + .string()); + } + std::string compare_id = AppWindow::MakeCompareId(files); + if(AppWindow::GetInstance()->GetItem(compare_id)) + { + out_id = compare_id; + result = Duplicate; + } + else + { + result = OpenCompare(compare_id, files); + if(result == Success) + { + out_id = GetID(); + } + } + } + else + { + std::string trace_path = + std::filesystem::weakly_canonical( + base_dir / + std::filesystem::path(general[JSON_KEY_GENERAL_TRACE_PATH].getString())) + .string(); + if(std::filesystem::exists(trace_path)) + { + std::string resolved = trace_path; + result = OpenTrace(resolved); + out_id = resolved; + } + } + return result; +} + +bool +ProjectItem::SaveSetttingsJson() +{ + bool result = false; + jt::Json json = + ExportSettingsJson(std::filesystem::path(m_project_file_path).parent_path()); + if(!json.isNull()) + { + std::ofstream file(m_project_file_path); + if(file.is_open()) + { + file << json.toStringPretty() << "\n"; + file.close(); + result = true; + } + } + return result; +} + +ProjectItemSetting::ProjectItemSetting(const std::string project_id) +: m_item(*AppWindow::GetInstance()->GetItem(project_id)) +, m_settings_json(m_item.GetSettingsJson()) +{ + m_item.RegisterSetting(this); +} + +ProjectItemSetting::~ProjectItemSetting() {} + +} // namespace View +} // namespace RocProfVis diff --git a/src/view/src/rocprofvis_project_item.h b/src/view/src/rocprofvis_project_item.h new file mode 100644 index 000000000..c2402f4d4 --- /dev/null +++ b/src/view/src/rocprofvis_project_item.h @@ -0,0 +1,233 @@ +// Copyright Advanced Micro Devices, Inc. +// SPDX-License-Identifier: MIT + +#pragma once +#include "json.h" +#include +#include +#include +#include +#include + +namespace RocProfVis +{ +namespace View +{ + +class RocWidget; +class ProjectItemSetting; + +// An ProjectItem is a single opened tab: one trace / compute analysis (or a compare +// set) plus its per-tab view configuration. A collection of Items grouped +// together is a Project (see rocprofvis_project.h). +class ProjectItem +{ +public: + enum OpenResult + { + Success, + Duplicate, + Failed, + }; + + enum TraceType + { + Undefined, + System, + Compute, + }; + + ProjectItem(); + virtual ~ProjectItem(); + + /* + * Returns the ID of the item. + * (Internally this is the trace path) + */ + std::string GetID() const; + /* + * Returns the file name of the item/trace. + */ + std::string GetName() const; + /* + * Returns the RocWidget that renders the item. + */ + std::shared_ptr GetView(); + /* + * Returns the trace type of the item. + */ + TraceType GetTraceType() const; + /* + * Returns true if the item is saved to a project/item file (as opposed to a + * plain trace). + */ + bool IsSaved() const; + /* + * Returns the filelist that reconstructs this item: the single trace/compute + * path, or the compare source paths for a compare item. Used to remember a + * closed item so it can be reopened. + */ + std::vector GetFiles() const; + /* + * Builds and returns this item's full settings JSON (general trace/compare files + * plus the per-view sections: track order/heights/colors, bookmarks, annotations) + * with trace paths relative to base_dir. Used to embed the item, with its + * settings, inside a saved project group. + */ + jt::Json ExportSettingsJson(const std::filesystem::path& base_dir); + /* + * Opens this item from an in-memory settings JSON (as produced by + * ExportSettingsJson), resolving trace paths relative to base_dir and restoring + * the per-view settings. out_id receives the opened item's id (or the existing id + * on Duplicate). Used to reopen items that were embedded in a project group. + */ + OpenResult OpenFromSettingsJson(const jt::Json& settings, + const std::filesystem::path& base_dir, + std::string& out_id); + + /* + * Opens a project or trace file and returns Success/Duplicate/Failed. + * @param file_path: The path of the file to open. If Duplicate is returned, this will + * be set to the path of the duplicate which can be used to identify and open the + * duplicate's tab. + */ + OpenResult Open(std::string& file_path); + /* + * Opens two or more trace files as a single combined compare item. The traces + * overlay on one timeline and each track is tagged with its source (A, B, ...). + * @param item_id: Synthetic, stable id/key for the item (it has no single + * file path on disk). + * @param file_paths: The trace files to combine, tagged A, B, ... in order. + */ + OpenResult OpenCompare(const std::string& item_id, + const std::vector& file_paths); + /* + * Overwrites the item settings to the item file without further user input. + */ + void Save(); + /* + * Opens file dialog and saves the item settings to a specified file. + * @param file_path: The path of the file to save as. + */ + void SaveAs(const std::string& file_path); + /* + * Clean up tasks prior to being deleted. + */ + void Close(); + /* + * Adds a participant to the item settings serialize/deserialize process. + * @param setting: The settings object to include in the serialize/deserialize + * process. + */ + void RegisterSetting(ProjectItemSetting* setting); + /* + * Returns the item settings json. + */ + jt::Json& GetSettingsJson(); + +private: + /* + * Opens a project + attached trace file and returns Success/Duplicate/Failed. + * @param file_path: The path of the file to open. If Duplicate is returned, this will + * be set to the path of the duplicate which can be used to identify and open the + * duplicate's tab. + */ + OpenResult OpenProject(std::string& file_path); + /* + * Opens a trace file and returns Success/Duplicate/Failed. + * @param file_path: The path of the file to open. If Duplicate is returned, this will + * be set to the path of the duplicate which can be used to identify and open the + * duplicate's tab. + */ + OpenResult OpenTrace(std::string& file_path); + /* + * Performs basic validation on the item settings for fields required to open a + * trace. + */ + bool JsonValidForLoad(jt::Json& json); + /* + * Writes the item settings into m_project_file_path; + */ + bool SaveSetttingsJson(); + + std::string m_name; + std::string m_project_file_path; + std::string m_trace_file_path; + // Source trace files when this is a compare item (empty otherwise). Persisted to + // the .rpv so the compare can be reopened. + std::vector m_compare_files; + TraceType m_trace_type; + std::shared_ptr m_view; + std::list m_settings; + jt::Json m_settings_json; + // Specific open-failure message; empty falls back to the generic one. + std::string m_open_error_message; +}; + +constexpr const char* JSON_KEY_GROUP_GENERAL = "general"; +constexpr const char* JSON_KEY_GROUP_TIMELINE = "timeline"; + +constexpr const char* JSON_KEY_GENERAL_VERSION = "version"; +constexpr const char* JSON_KEY_GENERAL_TRACE_PATH = "trace_path"; +constexpr const char* JSON_KEY_GENERAL_COMPARE_FILES = "compare_files"; + +constexpr const char* JSON_KEY_TIMELINE_BOOKMARK = "bookmarks"; +constexpr const char* JSON_KEY_TIMELINE_BOOKMARK_KEY = "key"; +constexpr const char* JSON_KEY_TIMELINE_BOOKMARK_V_MIN_X = "view_start_ns "; +constexpr const char* JSON_KEY_TIMELINE_BOOKMARK_V_MAX_X = "view_end_ns"; +constexpr const char* JSON_KEY_TIMELINE_BOOKMARK_Y = "y"; +constexpr const char* JSON_KEY_TIMELINE_BOOKMARK_Z = "z"; + +constexpr const char* JSON_KEY_TIMELINE_TRACK = "tracks"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_ORDER = "order"; +constexpr const char* JSON_KEY_TIMELINE_SORT_MODE = "sort_mode"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_DISPLAY = "display"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_HEIGHT = "height"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_COMPACT_MODE = "compact_mode"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_COLOR = "color"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_COLOR_RANGE_MIN = "color_min"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_COLOR_RANGE_MAX = "color_max"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_BOX_PLOT = "box_plot"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_STRIPES = "box_plot_stripes"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_MIN = "min"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_MAX = "max"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_MEAN = "mean"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_STANDARD_DEVIATION = "standard_deviation"; +constexpr const char* JSON_KEY_TIMELINE_TRACK_QUEUE_UTILIZATION = "queue_utilization"; + +constexpr const char* JSON_KEY_ANNOTATIONS = "annotations"; +constexpr const char* JSON_KEY_ANNOTATION_TIME_NS = "time_ns"; +constexpr const char* JSON_KEY_ANNOTATION_Y_OFFSET = "y_offset"; +constexpr const char* JSON_KEY_ANNOTATION_SIZE_X = "size_x"; +constexpr const char* JSON_KEY_ANNOTATION_SIZE_Y = "size_y"; +constexpr const char* JSON_KEY_ANNOTATION_TEXT = "text"; +constexpr const char* JSON_KEY_ANNOTATION_TITLE = "title"; +constexpr const char* JSON_KEY_ANNOTATION_ID = "id"; +constexpr const char* JSON_KEY_ANNOTATION_TRACK_ID = "track_id"; +constexpr const char* JSON_KEY_TIMELINE_ANNOTATION_V_MIN_X = "view_start_ns"; +constexpr const char* JSON_KEY_TIMELINE_ANNOTATION_V_MAX_X = "view_end_ns"; +constexpr const char* JSON_KEY_ANNOTATION_IS_MINIMIZED = "is_minimized"; +constexpr const char* JSON_KEY_ANNOTATION_IS_LOCKED = "is_locked"; + +class ProjectItemSetting +{ +public: + ProjectItemSetting(const std::string item_id); + virtual ~ProjectItemSetting(); + /* + * Called by the owning item during serialization. Implementation should update its + * item settings into m_settings_json. + */ + virtual void ToJson() = 0; + /* + * Implementation should validate any fields it cares about before reading. + */ + virtual bool Valid() const = 0; + +protected: + ProjectItem& m_item; + jt::Json& m_settings_json; +}; + +} // namespace View +} // namespace RocProfVis diff --git a/src/view/src/rocprofvis_timeline_track_options.cpp b/src/view/src/rocprofvis_timeline_track_options.cpp index 8be6dcd1b..9bfbab4ab 100644 --- a/src/view/src/rocprofvis_timeline_track_options.cpp +++ b/src/view/src/rocprofvis_timeline_track_options.cpp @@ -41,7 +41,7 @@ TrackOptions::TrackOptions(const TrackItem& track, TimelineTrackOptions& ctx, , m_settings(SettingsManager::GetInstance()) , m_project_settings(project_id.empty() ? nullptr - : std::make_unique(project_id, *this)) + : std::make_unique(project_id, *this)) { m_type_mask.set(TrackOptions::kTrack); } @@ -53,7 +53,7 @@ TrackOptions::TrackOptions(const TrackOptions& other) , m_track_item(other.m_track_item) , m_ctx(other.m_ctx) , m_settings(other.m_settings) -, m_project_settings(other.m_project_settings ? std::make_unique( +, m_project_settings(other.m_project_settings ? std::make_unique( *other.m_project_settings) : nullptr) { @@ -161,26 +161,26 @@ TrackOptions::TypeMask() const return m_type_mask; } -TrackOptions::TrackProjectSetting::TrackProjectSetting(const std::string& project_id, +TrackOptions::TrackProjectItemSetting::TrackProjectItemSetting(const std::string& project_id, TrackOptions& options) -: ProjectSetting(project_id) +: ProjectItemSetting(project_id) , m_options(options) {} void -TrackOptions::TrackProjectSetting::ToJson() +TrackOptions::TrackProjectItemSetting::ToJson() { m_options.ToJson(); } bool -TrackOptions::TrackProjectSetting::Valid() const +TrackOptions::TrackProjectItemSetting::Valid() const { return m_options.Valid(); } jt::Json& -TrackOptions::TrackProjectSetting::GetJson() +TrackOptions::TrackProjectItemSetting::GetJson() { return m_settings_json; } diff --git a/src/view/src/rocprofvis_timeline_track_options.h b/src/view/src/rocprofvis_timeline_track_options.h index f7d82da8d..a2039adeb 100644 --- a/src/view/src/rocprofvis_timeline_track_options.h +++ b/src/view/src/rocprofvis_timeline_track_options.h @@ -3,7 +3,7 @@ #pragma once #include "model/rocprofvis_model_types.h" -#include "rocprofvis_project.h" +#include "rocprofvis_project_item.h" #include #include #include @@ -43,7 +43,7 @@ class TrackOptions // Part of aggregation, types dictate how to combine themselves virtual TrackOptions& operator&=(const TrackOptions& other); - // ProjectSetting interface... + // ProjectItemSetting interface... virtual void ToJson(); virtual bool Valid() const; virtual void FromJson(); @@ -68,10 +68,10 @@ class TrackOptions friend struct FlameTrackItemTestPeer; #endif protected: - class TrackProjectSetting : public ProjectSetting + class TrackProjectItemSetting : public ProjectItemSetting { public: - TrackProjectSetting(const std::string& project_id, TrackOptions& options); + TrackProjectItemSetting(const std::string& project_id, TrackOptions& options); void ToJson() override final; bool Valid() const override final; jt::Json& GetJson(); @@ -86,7 +86,7 @@ class TrackOptions const TrackItem& m_track_item; TimelineTrackOptions& m_ctx; const SettingsManager& m_settings; - std::unique_ptr m_project_settings; + std::unique_ptr m_project_settings; }; class CounterTrackOptions : public TrackOptions diff --git a/src/view/src/rocprofvis_timeline_view.cpp b/src/view/src/rocprofvis_timeline_view.cpp index 530c55e5b..266f18770 100644 --- a/src/view/src/rocprofvis_timeline_view.cpp +++ b/src/view/src/rocprofvis_timeline_view.cpp @@ -3845,16 +3845,16 @@ TimelineView::UpdateMaxMetaAreaSize(bool update_tracks) } } -TimelineViewProjectSettings::TimelineViewProjectSettings(const std::string& project_id, +TimelineViewProjectItemSettings::TimelineViewProjectItemSettings(const std::string& project_id, TimelineView& timeline_view) -: ProjectSetting(project_id) +: ProjectItemSetting(project_id) , m_timeline_view(timeline_view) {} -TimelineViewProjectSettings::~TimelineViewProjectSettings() {} +TimelineViewProjectItemSettings::~TimelineViewProjectItemSettings() {} void -TimelineViewProjectSettings::ToJson() +TimelineViewProjectItemSettings::ToJson() { // Persist the remembered custom order (reusing the track "order" key) and the // active sort mode. The custom order is the only ordering that can't be derived @@ -3870,7 +3870,7 @@ TimelineViewProjectSettings::ToJson() } bool -TimelineViewProjectSettings::Valid() const +TimelineViewProjectItemSettings::Valid() const { jt::Json& node = m_settings_json[JSON_KEY_GROUP_TIMELINE][JSON_KEY_TIMELINE_TRACK_ORDER]; @@ -3904,20 +3904,20 @@ TimelineViewProjectSettings::Valid() const } bool -TimelineViewProjectSettings::HasSortSettings() const +TimelineViewProjectItemSettings::HasSortSettings() const { return m_settings_json[JSON_KEY_GROUP_TIMELINE][JSON_KEY_TIMELINE_SORT_MODE].isLong(); } int -TimelineViewProjectSettings::SortMode() const +TimelineViewProjectItemSettings::SortMode() const { return static_cast( m_settings_json[JSON_KEY_GROUP_TIMELINE][JSON_KEY_TIMELINE_SORT_MODE].getLong()); } std::vector -TimelineViewProjectSettings::CustomOrder() const +TimelineViewProjectItemSettings::CustomOrder() const { std::vector order; jt::Json& node = diff --git a/src/view/src/rocprofvis_timeline_view.h b/src/view/src/rocprofvis_timeline_view.h index 26782b831..6c97d3ce4 100644 --- a/src/view/src/rocprofvis_timeline_view.h +++ b/src/view/src/rocprofvis_timeline_view.h @@ -8,7 +8,7 @@ #include "rocprofvis_controller_types.h" #include "rocprofvis_data_provider.h" #include "rocprofvis_event_manager.h" -#include "rocprofvis_project.h" +#include "rocprofvis_project_item.h" #include "rocprofvis_settings_manager.h" #include "rocprofvis_time_to_pixel.h" #include "rocprofvis_timeline_arrow.h" @@ -64,12 +64,12 @@ class LoadingTimer bool m_started; }; -class TimelineViewProjectSettings : public ProjectSetting +class TimelineViewProjectItemSettings : public ProjectItemSetting { public: - TimelineViewProjectSettings(const std::string& project_id, + TimelineViewProjectItemSettings(const std::string& project_id, TimelineView& timeline_view); - ~TimelineViewProjectSettings() override; + ~TimelineViewProjectItemSettings() override; void ToJson() override; // True when the persisted "order" list is a valid full permutation of the // current tracks (the remembered custom order). @@ -87,7 +87,7 @@ class TimelineViewProjectSettings : public ProjectSetting class TimelineView : public RocWidget { - friend TimelineViewProjectSettings; + friend TimelineViewProjectItemSettings; public: TimelineView(DataProvider& dp, std::shared_ptr timeline_selection, @@ -308,7 +308,7 @@ class TimelineView : public RocWidget ImVec2 m_context_menu_pos = ImVec2(0.0f, 0.0f); - TimelineViewProjectSettings m_project_settings; + TimelineViewProjectItemSettings m_project_settings; LoadingTimer m_loading_timer; TrackTypeCounts m_track_counts; diff --git a/src/view/src/rocprofvis_trace_view.cpp b/src/view/src/rocprofvis_trace_view.cpp index 67796971c..6933c1d85 100644 --- a/src/view/src/rocprofvis_trace_view.cpp +++ b/src/view/src/rocprofvis_trace_view.cpp @@ -240,7 +240,7 @@ TraceView::Update() { m_timeline_view->MakeGraphView(); } - m_project_settings = std::make_unique( + m_project_settings = std::make_unique( m_data_provider.GetTraceFilePath(), *this); if(m_project_settings && m_project_settings->Valid()) { @@ -1174,16 +1174,16 @@ TraceView::RenderEventSearch() } } -SystemTraceProjectSettings::SystemTraceProjectSettings(const std::string& project_id, +SystemTraceProjectItemSettings::SystemTraceProjectItemSettings(const std::string& project_id, TraceView& view) -: ProjectSetting(project_id) +: ProjectItemSetting(project_id) , m_view(view) {} -SystemTraceProjectSettings::~SystemTraceProjectSettings() {} +SystemTraceProjectItemSettings::~SystemTraceProjectItemSettings() {} void -SystemTraceProjectSettings::ToJson() +SystemTraceProjectItemSettings::ToJson() { int i = 0; for(const auto& it : m_view.m_bookmarks) @@ -1201,7 +1201,7 @@ SystemTraceProjectSettings::ToJson() } bool -SystemTraceProjectSettings::Valid() const +SystemTraceProjectItemSettings::Valid() const { bool valid = false; if(m_settings_json[JSON_KEY_GROUP_TIMELINE][JSON_KEY_TIMELINE_BOOKMARK].isArray()) @@ -1233,7 +1233,7 @@ SystemTraceProjectSettings::Valid() const } std::unordered_map -SystemTraceProjectSettings::Bookmarks() +SystemTraceProjectItemSettings::Bookmarks() { std::unordered_map bookmarks; for(jt::Json& bookmark : diff --git a/src/view/src/rocprofvis_trace_view.h b/src/view/src/rocprofvis_trace_view.h index 1c5926801..8811ee710 100644 --- a/src/view/src/rocprofvis_trace_view.h +++ b/src/view/src/rocprofvis_trace_view.h @@ -5,7 +5,7 @@ #include "rocprofvis_annotations.h" #include "rocprofvis_data_provider.h" #include "rocprofvis_event_manager.h" -#include "rocprofvis_project.h" +#include "rocprofvis_project_item.h" #include "rocprofvis_root_view.h" #include "rocprofvis_timeline_view.h" #include "widgets/rocprofvis_split_containers.h" @@ -29,11 +29,11 @@ class SummaryView; class Minimap; class MeasurementController; -class SystemTraceProjectSettings : public ProjectSetting +class SystemTraceProjectItemSettings : public ProjectItemSetting { public: - SystemTraceProjectSettings(const std::string& project_id, TraceView& view); - ~SystemTraceProjectSettings() override; + SystemTraceProjectItemSettings(const std::string& project_id, TraceView& view); + ~SystemTraceProjectItemSettings() override; void ToJson() override; bool Valid() const override; @@ -45,7 +45,7 @@ class SystemTraceProjectSettings : public ProjectSetting class TraceView : public RootView { - friend SystemTraceProjectSettings; + friend SystemTraceProjectItemSettings; public: TraceView(); @@ -123,7 +123,7 @@ class TraceView : public RootView std::string m_save_notification_id; - std::unique_ptr m_project_settings; + std::unique_ptr m_project_settings; }; } // namespace View diff --git a/src/view/src/rocprofvis_view_module.cpp b/src/view/src/rocprofvis_view_module.cpp index 31b1861dd..eaa5e72c3 100644 --- a/src/view/src/rocprofvis_view_module.cpp +++ b/src/view/src/rocprofvis_view_module.cpp @@ -49,10 +49,8 @@ rocprofvis_view_destroy() void rocprofvis_view_open_files(const std::vector& file_paths) { - for(const std::string& path : file_paths) - { - AppWindow::GetInstance()->OpenFile(path); - } + // Opening several files at once auto-groups them into a new project. + AppWindow::GetInstance()->OpenFiles(file_paths); } void diff --git a/src/view/src/widgets/rocprofvis_tab_container.cpp b/src/view/src/widgets/rocprofvis_tab_container.cpp index 063e5a690..eafdb4cf5 100644 --- a/src/view/src/widgets/rocprofvis_tab_container.cpp +++ b/src/view/src/widgets/rocprofvis_tab_container.cpp @@ -5,13 +5,26 @@ #include "rocprofvis_gui_helpers.h" #include "rocprofvis_settings_manager.h" #include "rocprofvis_event_manager.h" +#include "rocprofvis_render_scheduler.h" #include +#include namespace RocProfVis { namespace View { +// Tab-strip layout constants (pixels, or fractions of the font size where noted). +inline constexpr float TAB_HEIGHT_PADDING = 4.0f; // extra tab height over the frame height +inline constexpr float TAB_PADDING_X = 12.0f; // horizontal padding inside a tab / chip +inline constexpr float TAB_GAP = 2.0f; // gap between tabs +inline constexpr float TAB_STRIP_LEFT_MARGIN = 6.0f; // inset before the first tab +inline constexpr float TAB_MIN_WIDTH = 90.0f; // min tab width before eliding +inline constexpr float TAB_MAX_WIDTH = 240.0f; // max tab width +inline constexpr float TAB_CLOSE_SIZE_RATIO = 0.5f; // close glyph size as a fraction of the font +inline constexpr float TAB_CLOSE_HIT_PADDING = 10.0f; // extra width reserved for the close button +inline constexpr float TAB_CARET_SIZE_RATIO = 0.6f; // group caret size as a fraction of the font + TabContainer::TabContainer() : m_active_tab_index(s_invalid_index) , m_set_active_tab_index(s_invalid_index) @@ -62,8 +75,8 @@ TabContainer::ShowCloseTabConfirm(int removing_tab_index) m_confirmation_dialog->Show("Confirm Closing tab", "Are you sure you want to close the tab: " + - m_tabs[removing_tab_index].m_label + - "? Any unsaved data will be lost.", + m_tabs[removing_tab_index].m_label + + "? Any unsaved data will be lost.", confirm, cancel); } @@ -93,143 +106,587 @@ void TabContainer::Render() { SettingsManager& settings = SettingsManager::GetInstance(); - const ImGuiStyle& style = settings.GetDefaultStyle(); - - // Keep active tabs visually connected to the content panel. - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, - ImVec2(style.WindowPadding.x + 2.0f, style.WindowPadding.y)); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, - ImVec2(style.ItemSpacing.x * 0.6f, style.ItemSpacing.y * 0.5f)); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, - ImVec2(style.FramePadding.x + 2.0f, style.FramePadding.y + 1.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_TabRounding, style.FrameRounding); + ImGui::PushStyleColor(ImGuiCol_ChildBg, settings.GetColor(Colors::kBgFrame)); ImGui::BeginChild(m_widget_name.c_str(), ImVec2(0, 0), ImGuiChildFlags_None, - ImGuiWindowFlags_NoScrollWithMouse); - int new_selected_tab = m_active_tab_index; + ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoScrollbar); + if(!m_tabs.empty()) { - int index_to_remove = s_invalid_index; - // Distinguish unselected tabs from the surrounding panel. - ImGui::PushStyleColor(ImGuiCol_Tab, settings.GetColor(Colors::kButton)); - ImGui::PushStyleColor(ImGuiCol_TabHovered, - settings.GetColor(Colors::kButtonHovered)); - ImGui::PushStyleColor(ImGuiCol_TabActive, - settings.GetColor(Colors::kBgPanel)); - ImGui::PushStyleColor(ImGuiCol_TabUnfocused, - settings.GetColor(Colors::kButton)); - ImGui::PushStyleColor(ImGuiCol_TabUnfocusedActive, - settings.GetColor(Colors::kBgPanel)); - if(ImGui::BeginTabBar("Tabs", ImGuiTabBarFlags_NoTabListScrollingButtons | - ImGuiTabBarFlags_FittingPolicyResizeDown)) + // Honor a programmatic selection (SetActiveTab / ReorderTabs) and keep the + // active index valid. + if(m_set_active_tab_index >= 0 && m_set_active_tab_index < static_cast(m_tabs.size())) { - for(size_t i = 0; i < m_tabs.size(); ++i) + if(m_active_tab_index != m_set_active_tab_index) { - const TabItem& tab = m_tabs[i]; - ImGuiTabItemFlags flags = - (i == m_set_active_tab_index || i == m_pending_to_remove) - ? ImGuiTabItemFlags_SetSelected - : 0; + m_active_tab_index = m_set_active_tab_index; + if(m_enable_send_change_event) + { + SendEvent(RocEvents::kTabSelected, m_tabs[m_active_tab_index].m_id); + } + } + } + m_set_active_tab_index = s_invalid_index; + if(m_active_tab_index < 0 || m_active_tab_index >= static_cast(m_tabs.size())) + { + m_active_tab_index = 0; + if(m_enable_send_change_event) + { + SendEvent(RocEvents::kTabSelected, m_tabs[0].m_id); + } + } + + RenderTabStrip(); - // Prevent truncated tab names from showing tooltips. - if(!m_allow_tool_tips) - flags |= ImGuiTabItemFlags_NoTooltip; + // Render the active tab's content beneath the strip. + if(m_active_tab_index >= 0 && m_active_tab_index < static_cast(m_tabs.size())) + { + const TabItem& active = m_tabs[m_active_tab_index]; + std::shared_ptr widget = active.m_widget; + if(widget) + { + ImGui::PushID(active.m_id.c_str()); + widget->Render(); + ImGui::PopID(); + } + } + + // Deferred close confirmation (must run after interaction so a removal does + // not invalidate indices mid-frame). + m_confirmation_dialog->Render(); + if(m_pending_to_remove != s_invalid_index) + { + ShowCloseTabConfirm(m_pending_to_remove); + } + } + + ImGui::EndChild(); + ImGui::PopStyleColor(); +} + +void +TabContainer::RenderTabStrip() +{ + SettingsManager& settings = SettingsManager::GetInstance(); + ImDrawList* draw = ImGui::GetWindowDrawList(); + + const ImGuiStyle& style = settings.GetDefaultStyle(); + const float font_h = ImGui::GetFontSize(); + const float tab_h = ImGui::GetFrameHeight() + TAB_HEIGHT_PADDING; + const float rounding = style.FrameRounding; + const ImDrawFlags corner_flags = ImDrawFlags_RoundCornersTop; + const float pad_x = TAB_PADDING_X; + const float gap = TAB_GAP; + const float left_margin = TAB_STRIP_LEFT_MARGIN; + const float close_sz = font_h * TAB_CLOSE_SIZE_RATIO; + const float close_w = close_sz + TAB_CLOSE_HIT_PADDING; + const float min_w = TAB_MIN_WIDTH; + const float max_w = TAB_MAX_WIDTH; + const float arrow_w = font_h * TAB_CARET_SIZE_RATIO; + + const ImU32 col_text = settings.GetColor(Colors::kTextMain); + const ImU32 col_tab = settings.GetColor(Colors::kButton); + const ImU32 col_hover = settings.GetColor(Colors::kButtonHovered); + const ImU32 col_bgsel = settings.GetColor(Colors::kBgPanel); + + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const float y0 = origin.y; + const float y1 = origin.y + tab_h; + + const size_t n = m_tabs.size(); + std::vector tab_left(n, 0.0f); + std::vector tab_right(n, 0.0f); + + // A "slot" is a top-level draggable unit: a single ungrouped tab, or a whole + // group (chip + its member tabs). + struct Slot + { + std::string group_id; // empty for a single ungrouped tab + size_t first; + size_t last; + float x0; + float x1; + }; + std::vector slots; + + // Interaction results, applied after the layout loop (mutating m_tabs mid-loop + // is unsafe). + int want_select = s_invalid_index; + int want_close = s_invalid_index; + std::string pressed_tab; + std::string pressed_group; + bool pressed_group_collapsed = false; + float pressed_x0 = 0.0f; + + // Geometry of the currently dragged element, captured during layout so a floating + // "picked up" ghost can be drawn after the strip. + bool has_ghost = false; + float ghost_w = 0.0f; + ImU32 ghost_col = 0; + std::string ghost_label; + bool ghost_is_chip = false; + + // Draws one tab at cursor_x and returns the advanced cursor_x. + auto draw_tab = [&](size_t idx, float cx) -> float { + TabItem& tab = m_tabs[idx]; + bool active = (static_cast(idx) == m_active_tab_index); + + float text_w = ImGui::CalcTextSize(tab.m_label.c_str()).x; + float w = text_w + pad_x * 2.0f + (tab.m_can_close ? close_w : 0.0f); + w = std::max(min_w, std::min(w, max_w)); + float x0 = cx; + float x1 = cx + w; + tab_left[idx] = x0; + tab_right[idx] = x1; + + // Body hitbox (excludes the close-button region so they do not overlap). + float body_w = tab.m_can_close ? (w - close_w) : w; + ImGui::SetCursorScreenPos(ImVec2(x0, y0)); + ImGui::InvisibleButton(("##tab_" + tab.m_id).c_str(), ImVec2(body_w, tab_h)); + bool hovered = ImGui::IsItemHovered(); + if(ImGui::IsItemActivated()) + { + pressed_tab = tab.m_id; + want_select = static_cast(idx); + pressed_x0 = x0; + } + if(hovered && m_allow_tool_tips) + { + SetTooltipStyled("%s", tab.m_id.c_str()); + } + if(m_tab_context_menu_callback) + { + // Match the app's menu padding so the context menu is consistent with the + // rest of the UI (it is opened from inside the tab strip, so it does not + // otherwise inherit the menu-bar style). + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10.0f, 8.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 6.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(12.0f, 6.0f)); + if(ImGui::BeginPopupContextItem(("##tabctx_" + tab.m_id).c_str())) + { + m_tab_context_menu_callback(tab.m_id); + ImGui::EndPopup(); + } + ImGui::PopStyleVar(3); + } + + // The dragged tab renders as a faded placeholder gap; the floating ghost + // (drawn after the strip) is the visible one that follows the cursor. + bool is_dragged = m_drag_active && m_drag_kind == 1 && tab.m_id == m_drag_id; + if(is_dragged) + { + draw->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), + ApplyAlpha(col_tab, 0.35f), rounding, corner_flags); + has_ghost = true; + ghost_w = w; + ghost_label = tab.m_label; + ghost_is_chip = false; + ghost_col = tab.m_group_color; + return x1 + gap; + } + + // Background (+ group tint). + ImU32 bg = active ? col_bgsel : (hovered ? col_hover : col_tab); + draw->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), bg, rounding, corner_flags); + if(tab.m_group_color != 0) + { + float wash = active ? 0.30f : (hovered ? 0.24f : 0.16f); + draw->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), + ApplyAlpha(tab.m_group_color, wash), rounding, corner_flags); + } - bool is_open = true; - bool* p_open = &is_open; + // Label (clipped to the available area). + float label_avail = body_w - pad_x * 2.0f; + ImVec4 clip(x0 + pad_x, y0, x0 + pad_x + std::max(label_avail, 0.0f), y1); + draw->AddText(nullptr, 0.0f, ImVec2(x0 + pad_x, y0 + (tab_h - font_h) * 0.5f), + col_text, tab.m_label.c_str(), nullptr, 0.0f, &clip); - // Close button - if(!tab.m_can_close) + // Close button. + if(tab.m_can_close) + { + float cxb = x1 - pad_x - close_sz; + float cyb = y0 + (tab_h - close_sz) * 0.5f; + ImGui::SetCursorScreenPos(ImVec2(x0 + body_w, y0)); + ImGui::InvisibleButton(("##close_" + tab.m_id).c_str(), ImVec2(close_w, tab_h)); + bool x_hover = ImGui::IsItemHovered(); + if(ImGui::IsItemClicked(ImGuiMouseButton_Left)) + { + want_close = static_cast(idx); + } + ImU32 xcol = x_hover ? col_text : ApplyAlpha(col_text, 0.65f); + draw->AddLine(ImVec2(cxb, cyb), ImVec2(cxb + close_sz, cyb + close_sz), xcol, 1.5f); + draw->AddLine(ImVec2(cxb + close_sz, cyb), ImVec2(cxb, cyb + close_sz), xcol, 1.5f); + } + return x1 + gap; + }; + + float cursor_x = origin.x + left_margin; + size_t i = 0; + while(i < n) + { + TabItem& t0 = m_tabs[i]; + bool grouped = t0.m_group_color != 0 && !t0.m_group_id.empty(); + if(grouped) + { + size_t j = i; + while(j + 1 < n && m_tabs[j + 1].m_group_color != 0 && + m_tabs[j + 1].m_group_id == t0.m_group_id) + { + j++; + } + const std::string group_id = t0.m_group_id; + bool collapsed = m_collapsed_groups.find(group_id) != m_collapsed_groups.end(); + ImU32 chip_col = t0.m_group_color; + std::string chip_lbl = + t0.m_group_label.empty() ? std::string("Group") : t0.m_group_label; + float chip_text = ImGui::CalcTextSize(chip_lbl.c_str()).x; + float chip_w = chip_text + pad_x * 1.6f + arrow_w; + float chip_x0 = cursor_x; + + ImGui::SetCursorScreenPos(ImVec2(chip_x0, y0)); + ImGui::InvisibleButton(("##chip_" + group_id).c_str(), ImVec2(chip_w, tab_h)); + if(ImGui::IsItemActivated()) + { + pressed_group = group_id; + pressed_group_collapsed = collapsed; + pressed_x0 = chip_x0; + } + if(m_chip_context_menu_callback) + { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10.0f, 8.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 6.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(12.0f, 6.0f)); + if(ImGui::BeginPopupContextItem(("##chipctx_" + group_id).c_str())) + { + m_chip_context_menu_callback(group_id); + ImGui::EndPopup(); + } + ImGui::PopStyleVar(3); + } + + bool chip_dragged = m_drag_active && m_drag_kind == 2 && group_id == m_drag_id; + if(chip_dragged) + { + has_ghost = true; + ghost_w = chip_w; + ghost_label = chip_lbl; + ghost_is_chip = true; + ghost_col = chip_col; + } + draw->AddRectFilled(ImVec2(chip_x0, y0), ImVec2(chip_x0 + chip_w, y1), + chip_dragged ? ApplyAlpha(chip_col, 0.35f) : chip_col, rounding, + corner_flags); + // Collapse/expand caret. + float ax = chip_x0 + pad_x * 0.5f; + float ay = y0 + tab_h * 0.5f; + if(collapsed) + { + draw->AddTriangleFilled(ImVec2(ax, ay - arrow_w * 0.35f), + ImVec2(ax, ay + arrow_w * 0.35f), + ImVec2(ax + arrow_w * 0.5f, ay), IM_COL32_WHITE); + } + else + { + draw->AddTriangleFilled(ImVec2(ax, ay - arrow_w * 0.2f), + ImVec2(ax + arrow_w * 0.6f, ay - arrow_w * 0.2f), + ImVec2(ax + arrow_w * 0.3f, ay + arrow_w * 0.3f), + IM_COL32_WHITE); + } + draw->AddText(ImVec2(chip_x0 + pad_x * 0.5f + arrow_w, y0 + (tab_h - font_h) * 0.5f), + IM_COL32_WHITE, chip_lbl.c_str()); + cursor_x = chip_x0 + chip_w + gap; + + Slot slot; + slot.group_id = group_id; + slot.first = i; + slot.last = j; + slot.x0 = chip_x0; + if(collapsed) + { + slot.x1 = chip_x0 + chip_w; + draw->AddRectFilled(ImVec2(chip_x0, y1 - 2.0f), ImVec2(chip_x0 + chip_w, y1), + chip_col, 1.0f); + } + else + { + for(size_t k = i; k <= j; k++) { - p_open = nullptr; + cursor_x = draw_tab(k, cursor_x); } - ImGui::PushStyleColor(ImGuiCol_Text, - ImGui::ColorConvertU32ToFloat4( - settings.GetColor(Colors::kTextMain))); + slot.x1 = tab_right[j]; + draw->AddRectFilled(ImVec2(chip_x0, y1 - 2.0f), ImVec2(tab_right[j], y1), + chip_col, 1.0f); + } + slots.push_back(slot); + i = j + 1; + } + else + { + float x0 = cursor_x; + cursor_x = draw_tab(i, cursor_x); + Slot slot; + slot.first = i; + slot.last = i; + slot.x0 = x0; + slot.x1 = tab_right[i]; + slots.push_back(slot); + i++; + } + } + + // Reserve the strip height so the active content flows underneath. + ImGui::SetCursorScreenPos(origin); + ImGui::Dummy(ImVec2(0.0f, tab_h + 6.0f)); + + // Floating "picked up" copy of the dragged tab/chip so the drag is clearly + // visible (it follows the cursor, lifted with a shadow and outline). + if(m_drag_active && has_ghost) + { + float gx = ImGui::GetMousePos().x - m_drag_grab_dx; + float gy = y0 - 3.0f; + float gx1 = gx + ghost_w; + float gy1 = gy + tab_h; + draw->AddRectFilled(ImVec2(gx + 2.0f, gy + 3.0f), ImVec2(gx1 + 2.0f, gy1 + 3.0f), + IM_COL32(0, 0, 0, 70), rounding); + ImU32 body = ghost_is_chip ? ghost_col : col_bgsel; + draw->AddRectFilled(ImVec2(gx, gy), ImVec2(gx1, gy1), body, rounding); + if(!ghost_is_chip && ghost_col != 0) + { + draw->AddRectFilled(ImVec2(gx, gy), ImVec2(gx1, gy1), + ApplyAlpha(ghost_col, 0.30f), rounding); + } + draw->AddRect(ImVec2(gx, gy), ImVec2(gx1, gy1), col_text, rounding, 0, 1.5f); + ImU32 lcol = ghost_is_chip ? IM_COL32_WHITE : col_text; + draw->AddText(ImVec2(gx + pad_x, gy + (tab_h - font_h) * 0.5f), lcol, + ghost_label.c_str()); + } - bool tab_visible = false; - ImGui::PushID(tab.m_id.c_str()); - bool tab_selected = ImGui::BeginTabItem(tab.m_label.c_str(), p_open, flags); + // Apply close (takes precedence over select/drag). + if(want_close != s_invalid_index) + { + if(SettingsManager::GetInstance().GetUserSettings().dont_ask_before_tab_closing) + { + RemoveTab(want_close); + } + else + { + m_pending_to_remove = want_close; + } + pressed_tab.clear(); + want_select = s_invalid_index; + } + else if(want_select != s_invalid_index && m_active_tab_index != want_select) + { + m_active_tab_index = want_select; + if(m_enable_send_change_event) + { + SendEvent(RocEvents::kTabSelected, m_tabs[want_select].m_id); + } + RenderScheduler::GetInstance().RequestRender(); + } - ImGui::PopStyleColor(); + // Begin a drag on press. + if(m_drag_kind == 0) + { + if(!pressed_tab.empty()) + { + m_drag_kind = 1; + m_drag_id = pressed_tab; + m_drag_active = false; + m_drag_grab_dx = ImGui::GetMousePos().x - pressed_x0; + } + else if(!pressed_group.empty()) + { + m_drag_kind = 2; + m_drag_id = pressed_group; + m_drag_active = false; + m_drag_group_was_collapsed = pressed_group_collapsed; + m_drag_grab_dx = ImGui::GetMousePos().x - pressed_x0; + } + } - if(tab_selected) + if(m_drag_kind != 0) + { + if(!ImGui::IsMouseDown(ImGuiMouseButton_Left)) + { + // Released: finalize. + if(m_drag_kind == 2) + { + if(!m_drag_active) { - tab_visible = true; - // Show tooltip for the active tab if header is hovered - if(m_allow_tool_tips && ImGui::IsItemHovered()) + // A click without a drag toggles the group's collapsed state. + if(m_collapsed_groups.count(m_drag_id) > 0) { - SetTooltipStyled("%s", tab.m_id.c_str()); + m_collapsed_groups.erase(m_drag_id); } - - new_selected_tab = static_cast(i); - if(tab.m_widget) + else { - - ImGui::SetCursorPosY(ImGui::GetCursorPosY() - - ImGui::GetStyle().ItemSpacing.y); - tab.m_widget->Render(); + m_collapsed_groups.insert(m_drag_id); } - ImGui::EndTabItem(); } - ImGui::PopID(); + else if(!m_drag_group_was_collapsed) + { + m_collapsed_groups.erase(m_drag_id); // restore after a group drag + } + } + if(m_drag_active && m_tabs_reordered_callback) + { + m_tabs_reordered_callback(); + } + m_drag_kind = 0; + m_drag_active = false; + m_drag_id.clear(); + RenderScheduler::GetInstance().RequestRender(); + } + else if(ImGui::IsMouseDragging(ImGuiMouseButton_Left, 5.0f)) + { + m_drag_active = true; + float mouse_x = ImGui::GetMousePos().x; - // Show tooltip for inactive tabs if header is hovered - if(!tab_visible && ImGui::IsItemHovered()) + if(m_drag_kind == 2) + { + m_collapsed_groups.insert(m_drag_id); // collapse while dragging a group + int ds = -1; + for(int s = 0; s < static_cast(slots.size()); s++) { - if(m_allow_tool_tips) + if(slots[s].group_id == m_drag_id) { - SetTooltipStyled("%s", tab.m_id.c_str()); + ds = s; + break; } } - - if(p_open && !is_open) + if(ds >= 0) { - if(SettingsManager::GetInstance().GetUserSettings().dont_ask_before_tab_closing) + int target = ds; + if(mouse_x < slots[ds].x0 && ds > 0) { - index_to_remove = static_cast(i); + target = ds - 1; } - else + else if(mouse_x > slots[ds].x1 && ds < static_cast(slots.size()) - 1) + { + target = ds + 1; + } + if(target != ds) { - m_pending_to_remove = static_cast(i); + std::vector order(slots.size()); + for(int s = 0; s < static_cast(slots.size()); s++) + { + order[s] = s; + } + std::swap(order[ds], order[target]); + std::vector id_order; + for(int s : order) + { + for(size_t k = slots[s].first; k <= slots[s].last; k++) + { + id_order.push_back(m_tabs[k].m_id); + } + } + ReorderTabs(id_order); } } } - ImGui::EndTabBar(); - } - ImGui::PopStyleColor(5); - - // Check if the active tab has changed - if(m_active_tab_index != new_selected_tab) - { - m_active_tab_index = new_selected_tab; - if(new_selected_tab < m_tabs.size() && m_enable_send_change_event) + else // dragging a single tab { - SendEvent(RocEvents::kTabSelected, m_tabs[new_selected_tab].m_id); + int d = -1; + for(size_t k = 0; k < m_tabs.size(); k++) + { + if(m_tabs[k].m_id == m_drag_id) + { + d = static_cast(k); + break; + } + } + if(d >= 0) + { + bool grouped = + m_tabs[d].m_group_color != 0 && !m_tabs[d].m_group_id.empty(); + if(grouped) + { + // Reorder within the group's contiguous run (keeps groups intact). + int gi = d; + int gj = d; + while(gi > 0 && m_tabs[gi - 1].m_group_color != 0 && + m_tabs[gi - 1].m_group_id == m_tabs[d].m_group_id) + { + gi--; + } + while(gj + 1 < static_cast(m_tabs.size()) && + m_tabs[gj + 1].m_group_color != 0 && + m_tabs[gj + 1].m_group_id == m_tabs[d].m_group_id) + { + gj++; + } + int target = d; + if(mouse_x < tab_left[d] && d > gi) + { + target = d - 1; + } + else if(mouse_x > tab_right[d] && d < gj) + { + target = d + 1; + } + if(target != d) + { + std::vector id_order; + for(size_t k = 0; k < m_tabs.size(); k++) + { + id_order.push_back(m_tabs[k].m_id); + } + std::swap(id_order[d], id_order[target]); + ReorderTabs(id_order); + } + } + else + { + // Ungrouped tab: reorder its slot among the top-level slots. + int ds = -1; + for(int s = 0; s < static_cast(slots.size()); s++) + { + if(slots[s].group_id.empty() && + slots[s].first == static_cast(d)) + { + ds = s; + break; + } + } + if(ds >= 0) + { + int target = ds; + if(mouse_x < slots[ds].x0 && ds > 0) + { + target = ds - 1; + } + else if(mouse_x > slots[ds].x1 && + ds < static_cast(slots.size()) - 1) + { + target = ds + 1; + } + if(target != ds) + { + std::vector order(slots.size()); + for(int s = 0; s < static_cast(slots.size()); s++) + { + order[s] = s; + } + std::swap(order[ds], order[target]); + std::vector id_order; + for(int s : order) + { + for(size_t k = slots[s].first; k <= slots[s].last; k++) + { + id_order.push_back(m_tabs[k].m_id); + } + } + ReorderTabs(id_order); + } + } + } + } } - } - - // Clear the set active tab index - m_set_active_tab_index = s_invalid_index; - - // must be called after active tab changes check as this may remove a tab - m_confirmation_dialog->Render(); - - // Remove the tab if it was closed - if(index_to_remove != s_invalid_index) - { - RemoveTab(index_to_remove); - } - - // Show confirm dialog if user option set - if(m_pending_to_remove != s_invalid_index) - { - ShowCloseTabConfirm(m_pending_to_remove); + RenderScheduler::GetInstance().RequestRender(); } } - ImGui::EndChild(); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(4); } void @@ -356,6 +813,90 @@ TabContainer::GetAllowToolTips() const return m_allow_tool_tips; } +void +TabContainer::SetTabGroup(const std::string& id, ImU32 color, const std::string& group_id, + const std::string& group_label) +{ + for(TabItem& tab : m_tabs) + { + if(tab.m_id == id) + { + tab.m_group_color = color; + tab.m_group_id = (color != 0) ? group_id : std::string(); + tab.m_group_label = (color != 0) ? group_label : std::string(); + break; + } + } +} + +void +TabContainer::SetTabContextMenuCallback(std::function callback) +{ + m_tab_context_menu_callback = std::move(callback); +} + +void +TabContainer::SetChipContextMenuCallback(std::function callback) +{ + m_chip_context_menu_callback = std::move(callback); +} + +void +TabContainer::SetTabsReorderedCallback(std::function callback) +{ + m_tabs_reordered_callback = std::move(callback); +} + +void +TabContainer::ReorderTabs(const std::vector& ordered_ids) +{ + // Preserve the active tab across the reorder by id. + std::string active_id; + if(m_active_tab_index >= 0 && m_active_tab_index < static_cast(m_tabs.size())) + { + active_id = m_tabs[m_active_tab_index].m_id; + } + + std::vector reordered; + reordered.reserve(m_tabs.size()); + std::vector taken(m_tabs.size(), false); + for(const std::string& id : ordered_ids) + { + for(size_t i = 0; i < m_tabs.size(); i++) + { + if(!taken[i] && m_tabs[i].m_id == id) + { + reordered.push_back(std::move(m_tabs[i])); + taken[i] = true; + break; + } + } + } + // Append tabs not present in ordered_ids, preserving their relative order. + for(size_t i = 0; i < m_tabs.size(); i++) + { + if(!taken[i]) + { + reordered.push_back(std::move(m_tabs[i])); + } + } + m_tabs = std::move(reordered); + + // Re-resolve the active tab in its new position. + m_active_tab_index = s_invalid_index; + if(!active_id.empty()) + { + for(size_t i = 0; i < m_tabs.size(); i++) + { + if(m_tabs[i].m_id == active_id) + { + m_active_tab_index = static_cast(i); + break; + } + } + } +} + // Gets a read only list of tabs. const std::vector TabContainer::GetTabs() diff --git a/src/view/src/widgets/rocprofvis_tab_container.h b/src/view/src/widgets/rocprofvis_tab_container.h index 9dc77a395..8012f886c 100644 --- a/src/view/src/widgets/rocprofvis_tab_container.h +++ b/src/view/src/widgets/rocprofvis_tab_container.h @@ -5,6 +5,11 @@ #include "rocprofvis_widget.h" +#include +#include +#include +#include + namespace RocProfVis { namespace View @@ -30,6 +35,28 @@ class TabContainer : public RocWidget void SetTabLabel(const std::string& label, const std::string& id); + // Sets (or clears, when color == 0) the Chrome-style project-group decoration + // for a tab. group_label is shown on the group's inline chip. + void SetTabGroup(const std::string& id, ImU32 color, const std::string& group_id, + const std::string& group_label); + + // Reorders tabs to match the given full ordering of ids (ids not present are + // left in their relative order at the end). Preserves the active tab. + void ReorderTabs(const std::vector& ordered_ids); + + // Callback invoked inside a right-click context popup for the given tab id. + // Used by AppWindow to render the project-group menu. The callback should emit + // ImGui menu items only (Begin/EndPopup is handled by the container). + void SetTabContextMenuCallback(std::function callback); + + // Callback invoked inside a right-click context popup for a group chip, with the + // group id. Used by AppWindow to render the project-level menu. + void SetChipContextMenuCallback(std::function callback); + + // Callback invoked (after the strip renders) when the user drag-reorders tabs, + // so the owner can sync any dependent order (e.g. project group membership). + void SetTabsReorderedCallback(std::function callback); + const TabItem* GetActiveTab() const; friend struct TabContainerTestPeer; @@ -46,10 +73,14 @@ class TabContainer : public RocWidget void EnableSendChangeEvent(bool enable); private: - void ShowCloseTabConfirm(int tab_index); - void SendEvent(RocEvents event, const std::string& tab_id); + void ShowCloseTabConfirm(int tab_index); + void SendEvent(RocEvents event, const std::string& tab_id); + // Draws the custom tab strip (tabs + inline group chips) and handles selection, + // close, collapse, and drag-reordering of both individual tabs and whole groups. + void RenderTabStrip(); + std::vector m_tabs; - int m_active_tab_index; // index of the currently active tab + int m_active_tab_index; // index of the currently active tab int m_set_active_tab_index; // used to programmatically set the active tab int m_pending_to_remove; bool m_allow_tool_tips; // whether to show tooltips for tabs @@ -60,8 +91,20 @@ class TabContainer : public RocWidget // source name will be the m_widget_name std::string m_event_source_name; - std::unique_ptr m_confirmation_dialog; - static constexpr int s_invalid_index = -1; + std::unique_ptr m_confirmation_dialog; + std::function m_tab_context_menu_callback; + std::function m_chip_context_menu_callback; + std::function m_tabs_reordered_callback; + + // Custom strip state: collapsed group ids + active drag operation. + std::unordered_set m_collapsed_groups; + int m_drag_kind = 0; // 0 none, 1 tab, 2 group + std::string m_drag_id; // dragged tab id or group id + bool m_drag_active = false; // moved past the drag threshold + bool m_drag_group_was_collapsed = false; // restore state after a group drag + float m_drag_grab_dx = 0.0f; // cursor offset within the grabbed item + + static constexpr int s_invalid_index = -1; }; } // namespace View diff --git a/src/view/src/widgets/rocprofvis_widget.h b/src/view/src/widgets/rocprofvis_widget.h index 0f6661d00..dea6ce397 100644 --- a/src/view/src/widgets/rocprofvis_widget.h +++ b/src/view/src/widgets/rocprofvis_widget.h @@ -78,6 +78,14 @@ struct TabItem std::string m_id; std::shared_ptr m_widget; bool m_can_close; + + // Chrome-style tab-group decoration. When m_group_color is non-zero this tab + // is drawn as part of a project group: tinted, underlined, and (for a run of + // adjacent tabs sharing m_group_id) covered by a spanning colored bar labeled + // with m_group_label. Zeroed for ungrouped tabs and generic sub-tab bars. + ImU32 m_group_color = 0; + std::string m_group_id; + std::string m_group_label; }; class PopUpStyle From 3ab85e56673657d3026d8a72c56c929fca3fa0e7 Mon Sep 17 00:00:00 2001 From: Dipal Date: Thu, 10 Sep 2026 11:11:18 -0400 Subject: [PATCH 2/4] View: document project tab groups + ProjectItem rename in UI.md --- .agents/UI.md | 126 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 84 insertions(+), 42 deletions(-) diff --git a/.agents/UI.md b/.agents/UI.md index a00daac2b..17dc79599 100644 --- a/.agents/UI.md +++ b/.agents/UI.md @@ -219,15 +219,15 @@ Key invariants: the main thread inside the per-frame `Render()` traversal. Heavy work (queries, remote I/O, profiler runs, save, cleanup) is offloaded to controller futures, `AppMonitor`, or owned `std::future<...>` jobs. -4. **Ownership is explicit.** Every `Project` owns one `DataProvider`. Every +4. **Ownership is explicit.** Every `ProjectItem` owns one `DataProvider`. Every widget that subscribes to events stores its `EventManager::SubscriptionToken` and unsubscribes in its destructor. 5. **Normal project state is keyed by trace path.** Compare projects are the exception: `AppWindow::MakeCompareId()` creates a synthetic ID - and `Project::OpenCompare()` persists both source paths. + and `ProjectItem::OpenCompare()` persists both source paths. 6. **The app supports both system traces (`TraceView`) and compute traces (`ComputeView`)**, both deriving from `RootView` and selected by - `Project::TraceType`. + `ProjectItem::TraceType`. 7. **Long-running UI operations are non-blocking.** SSH and profiler sessions register with `AppMonitor`; status changes become typed `RocEvent`s and teardown is deferred until controller futures resolve. @@ -381,7 +381,7 @@ AppWindow (singleton, RocWidget) | +-- [0] toolbar slot (RootView::GetToolbar(), per active project) | +-- [1] main_area_item : RocCustomWidget | | renders m_tab_container OR WelcomePage if no tabs -| | Per-tab widget = Project::GetView() = RootView +| | Per-tab widget = ProjectItem::GetView() = RootView | | | | | +-- TraceView : RootView (system trace) | | | +-- m_tool_bar : RocCustomWidget (slotted into [0]) @@ -431,7 +431,7 @@ File: `src/view/src/rocprofvis_appwindow.{h,cpp}`. Owns global UI state: helpers). Called by `rocprofvis_view_init`. - `void Render()` / `void Update()` - per-frame entry points. - `void OpenFile(std::string file_path)` - opens a trace or `.rpv` - project. Routes via `Project::Open()` and adds a tab. A duplicate + project. Routes via `ProjectItem::Open()` and adds a tab. A duplicate open (`OpenResult::Duplicate`) focuses the existing tab and shows a "Trace Already Open" message rather than opening a second tab. While the Compare dialog is open, dropped/opened files fill its slots @@ -450,7 +450,7 @@ File: `src/view/src/rocprofvis_appwindow.{h,cpp}`. Owns global UI state: such as profiler output selection. - `void ShowProfilerLauncher()` - lazy-opens the optional profiler launcher (`ROCPROFVIS_ENABLE_PROFILER`). -- `Project* GetCurrentProject() / GetProject(id)` - lookup helpers. +- `ProjectItem* GetCurrentItem() / GetItem(id)` - lookup helpers. - `BeginAppShutdown()` - graceful shutdown that drains async `DataProvider` cleanup jobs and `AppMonitor` operations. Use this rather than terminating the process. @@ -463,7 +463,7 @@ File: `src/view/src/rocprofvis_appwindow.{h,cpp}`. Owns global UI state: (which itself covers the `LoadingTimer`, an in-progress sticky-note drag, and reorder auto-scroll). - `SetTabLabel(label, id)` - pushed to the `TabContainer` via the - `Project` system; useful when a child view wants to indicate dirty + `ProjectItem` system; useful when a child view wants to indicate dirty state. State of note: @@ -473,10 +473,12 @@ State of note: index), `[1]` `RocCustomWidget` that renders `m_tab_container` or `WelcomePage`, `[2]` status bar. The ImGui menu bar is rendered inline by `AppWindow::Render()` before this view, not slotted in. -- `m_tab_container` (`shared_ptr`) - the project tabs; +- `m_tab_container` (`shared_ptr`) - the open-tab strip; source name is `TAB_CONTAINER_SRC_NAME` (`"MainTabContainer"`). -- `m_projects` (`unordered_map>`) - one - entry per normal trace path or synthetic compare ID. +- `m_items` (`unordered_map>`) - one + entry per open tab (normal trace path or synthetic compare ID). +- `m_projects` (`vector>`) - the Chrome-style tab + groups; each `Project` references a subset of `m_items` by id. - `m_provider_cleanup_jobs` - async cleanup of in-flight `DataProvider` requests when a tab closes or the app exits. - `m_status_message`, `m_status_show_busy_indicator` - @@ -490,7 +492,7 @@ polls `LogViewer`. Keep controller polling and ImGui rendering on the main thread; callbacks may enqueue events from worker threads, but widgets consume them during normal frame dispatch. -### `Project` - per-trace bundle +### `ProjectItem` - per-tab bundle File: `rocprofvis_project.{h,cpp}`. Wraps everything tied to a single trace file: @@ -503,18 +505,46 @@ trace file: `rocprofvis_controller_alloc_compare(file_ptrs.data(), count)`, then attaches compare-source metadata and the supplied synthetic ID. - `void Save()` / `void SaveAs(file_path)` - serializes registered - `ProjectSetting`s into a `.rpv`. -- `void RegisterSetting(ProjectSetting*)` - any per-project state that - must persist participates by deriving from `ProjectSetting` and + `ProjectItemSetting`s into a single-item `.rpv`. +- `jt::Json ExportSettingsJson(base_dir)` / `OpenFromSettingsJson(json, + base_dir, out_id)` - export/restore this item's full settings JSON so it + can be embedded in, and reopened from, a saved project group. +- `void RegisterSetting(ProjectItemSetting*)` - any per-item state that + must persist participates by deriving from `ProjectItemSetting` and registering itself in its constructor. Examples: - `TimelineViewProjectSettings`, `SystemTraceProjectSettings`, - `AnnotationsManagerProjectSettings`, and the per-track `TrackOptions` - family (persisted via the nested `TrackOptions::TrackProjectSetting`). + `TimelineViewProjectItemSettings`, `SystemTraceProjectItemSettings`, + `AnnotationsManagerProjectItemSettings`, and the per-track `TrackOptions` + family (persisted via the nested `TrackOptions::TrackProjectItemSetting`). - `jt::Json& GetSettingsJson()` - the in-memory JSON tree. JSON keys used in `.rpv` files are the `JSON_KEY_*` constants in this header. - `TraceType GetTraceType()` - `Undefined | System | Compute`. -- `GetID()` - the trace path for normal projects or synthetic compare - ID; used as event source ID and the `PresetManager` registration key. +- `GetID()` - the trace path (or synthetic compare ID); used as event + source ID and the `PresetManager` registration key. + +### `Project` - tab group (Chrome-style) + +File: `rocprofvis_project.{h,cpp}`. A named, colored, ordered group of +`ProjectItem`s (like a browser tab group), plus a memory of closed items +for reopening. `AppWindow::m_projects` owns these; each references its +members in `m_items` by id. + +- Members: `GetItemIds()` / `AddItem` / `RemoveItem` / `SetItemOrder`, + `GetName`/`SetName`, `GetColor`/`SetColor` (a color from + `SettingsManager::GetColorWheel()`), `IsCollapsed`/`SetCollapsed`, + `GetClosedItems`/`AddClosedItem`, and `GetFilePath`/`SetFilePath`/ + `IsSaved` (the associated `.rpv`). +- **A `.rpv` is always a project.** New files store `name`, `color`, and an + `items` array where each entry embeds a `ProjectItem`'s full settings + JSON (so track order/heights, bookmarks, annotations restore). Old + single-item `.rpv`s (no `items` array) open as a one-tab project. + `AppWindow::SaveProjectGroup` / `OpenProjectGroupFile` handle both, and + `Save` / `Save As` operate on the whole project when the active tab is + grouped. +- `AppWindow` group ops: `CreateProject`, `AssignItemToProject`, + `RemoveItemFromProjectMembership`, `UngroupProject`, `CloseProjectTabs`, + `ReopenClosedItem`, and `RefreshTabGroups` (pushes color/label onto tabs + and keeps group members contiguous). `RenderProjectMenuBody` is shared by + the `File > Projects` submenu and the tab-strip chip context menu. ### `RootView` - polymorphic per-trace view @@ -538,7 +568,7 @@ protected: Implementations: `TraceView` (system) and `ComputeView` (compute). When you add a new project type, you derive from `RootView`, fill `GetToolbar`, `RenderEditMenuOptions`, and -`DetachProviderCleanup`, then teach `Project::Open` to instantiate it. +`DetachProviderCleanup`, then teach `ProjectItem::Open` to instantiate it. ### `WelcomePage` and compare projects @@ -548,7 +578,7 @@ from `RootView`, fill `GetToolbar`, `RenderEditMenuOptions`, and in `AppWindow`. - `CompareFilesDialog` (`rocprofvis_compare_files_dialog.{h,cpp}`) collects base and target traces. `AppWindow::OpenCompare()` routes - them through `Project::OpenCompare()`, + them through `ProjectItem::OpenCompare()`, `TraceDataModel::SetCompareSources()`, and per-track `CompareSourceInfo` badges/colors. `FileSlot {kFirst, kSecond}` identifies the two drop targets; while the dialog `IsOpen()`, @@ -577,8 +607,9 @@ reusable types they expose. `std::function` callback. Used to host inline custom rendering inside a layout slot (toolbars, ad-hoc panels). Prefer this over defining a one-off `RocWidget` subclass for trivial content. -- `struct TabItem` - `{ label, id, widget, can_close }`, the shape of a - tab. +- `struct TabItem` - `{ label, id, widget, can_close }` plus optional + group fields (`group_color`, `group_id`, `group_label`) that drive the + inline group chip/tint when the tab belongs to a `Project`. - `class PopUpStyle` - RAII helper that pushes consistent popup colors, borders, and centering. Use this around `BeginPopupModal` instead of hand-rolling style pushes. @@ -618,8 +649,17 @@ splitter dragging. - `class TabContainer : public RocWidget` - drives tabs and emits `kTabClosed` / `kTabSelected` `RocEvent`s. Set the event source name with `SetEventSourceName(...)`. Toggle close/change events via - `EnableSendCloseEvent` / `EnableSendChangeEvent`. Used in `AppWindow` - for the project tabs and in `ComputeView` for sub-tabs. + `EnableSendCloseEvent` / `EnableSendChangeEvent`. +- It renders a **custom tab strip** (not ImGui's `BeginTabBar`) so it can + draw Chrome-style `Project` groups: an inline colored **chip** per group + (click to collapse/expand), tinted + underlined member tabs, + **drag-to-reorder** tabs, and **dragging a whole group** via its chip + (collapses while dragging, restores on drop) with a floating drag ghost. + `SetTabGroup`, `ReorderTabs`, and the `SetTabContextMenuCallback` / + `SetChipContextMenuCallback` / `SetTabsReorderedCallback` hooks wire it + to `AppWindow`'s project logic. Group decorations only draw when tabs + carry group info, so the plain sub-tab bar (e.g. `ComputeView`) is + unaffected. ### 7.6 `rocprofvis_gui_helpers.{h,cpp}` - low-level UI helpers @@ -900,8 +940,8 @@ When you add a new track type: 2. Implement `RenderChart`, `RenderMetaAreaScale`, `ExtractPointsFromData`, `ReleaseData`. 3. Add a `TrackOptions` subclass if any per-track state must persist; - it self-registers a `ProjectSetting` via - `TrackOptions::TrackProjectSetting`. + it self-registers a `ProjectItemSetting` via + `TrackOptions::TrackProjectItemSetting`. 4. Construct from `TimelineView::MakeGraphView()` based on `TrackInfo::TrackType` / `rocprofvis_controller_track_type_t`. @@ -939,7 +979,7 @@ Composition (members): controls. - `m_bookmarks` - 10 saved view positions; `RenderBookmarkControls()`, `HandleHotKeys()` keyed via `HotkeyManager`. -- `SystemTraceProjectSettings` - persists bookmarks via `Project`. +- `SystemTraceProjectItemSettings` - persists bookmarks via `ProjectItem`. Public surface: - `LoadTrace(controller, file_path)` / `CreateView()` / `DestroyView()`. @@ -1133,7 +1173,7 @@ fetched through the normal `DataProvider` + `TablesModel` pipeline. list of `StickyNote`s and the visibility flag, creates new notes inline via `CreateStickyNote`, and removes user-deleted notes via `RemoveNotesPendingDelete`. Persisted via - `AnnotationsManagerProjectSettings`. + `AnnotationsManagerProjectItemSettings`. - `StickyNote` (`rocprofvis_stickynote.{h,cpp}`) - one note. Carries position (time + track-relative y offset), optional track binding, lock state, size, text/title, and view-range metadata. @@ -1148,7 +1188,7 @@ fetched through the normal `DataProvider` + `TablesModel` pipeline. lane. A note can be locked (`m_locked`), request "go to anchor" navigation (`WantsNavigate()` -> a `NavigationEvent`), and cross-highlight when its timeline marker is hovered. Persisted fields - (under `JSON_KEY_ANNOTATION_*` in `rocprofvis_project.h`): `time_ns`, + (under `JSON_KEY_ANNOTATION_*` in `rocprofvis_project_item.h`): `time_ns`, `y_offset`, `size_x/y`, `text`, `title`, `id`, `track_id`, `view_start_ns`/`view_end_ns`, `is_minimized`, `is_locked` (the expanded window's screen position is not persisted). @@ -1681,13 +1721,13 @@ in `profiles.json`. - `profiles.json` currently stores SSH passwords/passphrases in plaintext. Treat it as sensitive and never log credentials. -### `Project` settings serialization +### `ProjectItem` settings serialization -`Project::RegisterSetting(ProjectSetting*)` participates in `.rpv` +`ProjectItem::RegisterSetting(ProjectItemSetting*)` participates in `.rpv` save/load. Implement `ToJson()` and `Valid()` on your subclass, construct passing `project_id`, and the base ctor registers with -the owning `Project`. JSON keys for the system trace are listed in -`rocprofvis_project.h` (`JSON_KEY_GENERAL_*`, +the owning `ProjectItem`. JSON keys for the system trace are listed in +`rocprofvis_project_item.h` (`JSON_KEY_GENERAL_*`, `JSON_KEY_TIMELINE_*`, `JSON_KEY_ANNOTATION_*`). ### `rocprofvis_utils.{h,cpp}` - shared utilities @@ -1801,7 +1841,7 @@ renders) -> `ProfilerLaunchOrchestrator` (run engine, normalizes local vs remote) -> `ProfilerSession` / `RemoteProfilerSession` (both derive from `ProfilerSessionBase`) -> controller profiler C API (`rocprofvis_profiler.h`) -> `AppMonitor` -> status events. Profiler -sessions are **not** `Project`s until a produced trace is handed to +sessions are **not** `ProjectItem`s until a produced trace is handed to `AppWindow::OpenFile()`. **Backends (`IProfilerBackend`, `rocprofvis_profiler_backend.h`).** The @@ -2238,7 +2278,7 @@ int GetId() const; ### Plain `/* ... */` blocks -Used for internal helpers and on `Project::Open` / controller C +Used for internal helpers and on `ProjectItem::Open` / controller C APIs. Example: ```cpp @@ -2313,7 +2353,7 @@ adding **anything** new, check this list and reuse if at all possible. | Drive a virtualized table | Subclass `InfiniteScrollTable` | | Pick a metric (Compute) | `QueryBuilder` + `KernelMetricTable::SetExternalQuery` | | Display SOL / pinned compute metrics | `MetricTable` / `PinnedMetricTable` / `MetricTableWidget` | -| Add a bookmark or save view state | The `Project` + `ProjectSetting` system, JSON keys in `rocprofvis_project.h` | +| Add a bookmark or save view state | The `ProjectItem` + `ProjectItemSetting` system, JSON keys in `rocprofvis_project_item.h` | | Save user-customizable layouts (Compute) | `PresetComponent` + `PresetManager` + `PresetBrowser` | | Save profiler launch profiles | `LaunchPresetManager` + `ProfilesDocument` | | Convert ns to a display string | `nanosecond_to_formatted_str(ns, settings_time_format, include_units)` | @@ -2341,7 +2381,7 @@ adding **anything** new, check this list and reuse if at all possible. | Get a selection sentinel | `TimelineSelection::INVALID_SELECTION_ID` / `ComputeSelection::INVALID_SELECTION_ID` | | Reuse a glyph | One of the `ICON_*` macros in `icons/rocprovfis_icon_defines.h` | | Add a keyboard-driven label edit | `EditableTextField` (used in `LineTrackItem::VerticalLimits`) | -| Persist a per-project flag/value | Subclass `ProjectSetting`, register in ctor | +| Persist a per-project flag/value | Subclass `ProjectItemSetting`, register in ctor | | Read/write shared UI JSON safely | `JsonUtils` | | Manage SSH connection profiles | `SshConnectionStore` + `SshSettingsDialog` + `ProfilesDocument` | | Run SSH connect/auth/download/browse phases | `SshSession` + `AppMonitor` | @@ -2431,8 +2471,10 @@ For fast lookup. Each entry: class -> file -> one-line role. - `AppWindow` -> `rocprofvis_appwindow.h` -> Singleton; menus, tabs, dialogs, status bar, project lifecycle. -- `Project` / `ProjectSetting` -> `rocprofvis_project.h` -> Per-trace - bundle and serialization protocol. +- `ProjectItem` / `ProjectItemSetting` -> `rocprofvis_project_item.h` -> + Per-tab bundle (one trace/compute/compare) and its settings protocol. +- `Project` -> `rocprofvis_project.h` -> Chrome-style tab group: a named, + colored, ordered set of `ProjectItem`s (+ remembered closed items). - `RootView` -> `rocprofvis_root_view.h` -> Base for `TraceView` / `ComputeView`. - `FileFilter` (struct) -> `rocprofvis_appwindow.h` -> File-dialog @@ -2449,7 +2491,7 @@ For fast lookup. Each entry: class -> file -> one-line role. ### Top-level views - `TraceView` -> `rocprofvis_trace_view.h` -> System-profile workspace. -- `SystemTraceProjectSettings` -> same -> Persists bookmarks. +- `SystemTraceProjectItemSettings` -> same -> Persists bookmarks. - `ComputeView` -> `compute/rocprofvis_compute_view.h` -> Compute workspace. @@ -2457,7 +2499,7 @@ For fast lookup. Each entry: class -> file -> one-line role. - `TimelineView` -> `rocprofvis_timeline_view.h` -> Timeline grid + tracks + scrubber + interaction. -- `TimelineViewProjectSettings` -> same -> Persists per-track display +- `TimelineViewProjectItemSettings` -> same -> Persists per-track display and order. - `LoadingTimer` -> same -> Debounce for the loading indicator. - `ViewCoords` (struct) -> same -> `{ y, z, v_min_x, v_max_x }` for From 48063341b851f7484c36a06985614478a80bb1cf Mon Sep 17 00:00:00 2001 From: Dipal Date: Thu, 10 Sep 2026 11:19:32 -0400 Subject: [PATCH 3/4] View: restore open projects/tabs across restarts (session persistence) Snapshot the whole workspace (all tab groups + ungrouped tabs, each with its full per-view settings) to /last_session.json on graceful shutdown, and reopen it on the next plain launch (no file argument). Missing traces are skipped silently. Factor the per-item open path out of OpenProjectGroupFile into a shared OpenItemFromSettings helper reused by both the project loader and session restore. --- src/app/src/main.cpp | 6 + src/view/inc/rocprofvis_view_module.h | 5 + src/view/src/rocprofvis_appwindow.cpp | 276 +++++++++++++++++++++--- src/view/src/rocprofvis_appwindow.h | 18 ++ src/view/src/rocprofvis_view_module.cpp | 6 + 5 files changed, 279 insertions(+), 32 deletions(-) diff --git a/src/app/src/main.cpp b/src/app/src/main.cpp index 51647461a..d2f5eb65c 100644 --- a/src/app/src/main.cpp +++ b/src/app/src/main.cpp @@ -495,6 +495,12 @@ main(int argc, char** argv) // If the user inputted a filepath open it here. rocprofvis_view_open_files({ cli_parser.GetOptionValue("file") }); } + else + { + // Plain launch (no file argument): reopen the previous session's + // tab groups and tabs. No-op when there is no saved session. + rocprofvis_view_restore_session(); + } ImVec4 clear_color = ImVec4(0.45f, 0.55f, 0.60f, 1.00f); diff --git a/src/view/inc/rocprofvis_view_module.h b/src/view/inc/rocprofvis_view_module.h index 00ec2b730..26cc5b1d3 100644 --- a/src/view/inc/rocprofvis_view_module.h +++ b/src/view/inc/rocprofvis_view_module.h @@ -46,6 +46,11 @@ rocprofvis_view_destroy(); void rocprofvis_view_open_files(const std::vector& file_paths); +// Restores the previous session (open tab groups + tabs) saved on last shutdown. +// Call on a plain launch (no file argument); a no-op when there is no saved session. +void +rocprofvis_view_restore_session(); + void rocprofvis_view_set_fullscreen_state(bool is_fullscreen); diff --git a/src/view/src/rocprofvis_appwindow.cpp b/src/view/src/rocprofvis_appwindow.cpp index 11c06463d..02821306e 100644 --- a/src/view/src/rocprofvis_appwindow.cpp +++ b/src/view/src/rocprofvis_appwindow.cpp @@ -78,6 +78,12 @@ const std::vector COMPARE_EXTENSIONS = { "db" }; const std::vector PROJECT_GROUP_EXTENSIONS = { "rpv" }; constexpr const char* PROJECT_GROUP_EXTENSION = ".rpv"; +// The previous session (all open tab groups + ungrouped tabs, with per-item +// settings) is snapshotted here in the app config dir on graceful shutdown, and +// restored on the next plain launch (no file argument). A distinct name/extension +// keeps it out of the user-facing .rpv project space. +constexpr const char* SESSION_FILE_NAME = "last_session.json"; + constexpr const char* CLEANUP_MESSAGE = "Waiting for requests to finish cleanup..."; constexpr const char* CLOSING_MESSAGE = "Closing..."; @@ -665,6 +671,31 @@ AppWindow::SaveProjectGroup(const std::string& project_id, const std::string& sa } } +std::string +AppWindow::OpenItemFromSettings(const jt::Json& settings, + const std::filesystem::path& base_dir) +{ + // Restore a single tab from its embedded settings JSON (trace paths resolved + // relative to base_dir) and hand it a tab. Returns the opened/duplicate item id, + // or an empty string on failure (e.g. the trace was moved or deleted). + std::unique_ptr new_item = std::make_unique(); + std::string out_id; + ProjectItem::OpenResult open_result = new_item->OpenFromSettingsJson(settings, base_dir, out_id); + if(open_result == ProjectItem::OpenResult::Success) + { + TabItem tab{ new_item->GetName(), new_item->GetID(), new_item->GetView(), true }; + m_tab_container->AddTab(std::move(tab)); + std::string opened_id = new_item->GetID(); + m_items[opened_id] = std::move(new_item); + return opened_id; + } + if(open_result == ProjectItem::OpenResult::Duplicate) + { + return out_id; + } + return std::string(); +} + void AppWindow::OpenProjectGroupFile(const std::string& file_path) { @@ -723,22 +754,7 @@ AppWindow::OpenProjectGroupFile(const std::string& file_path) // New format: the item carries its full settings; restore it (with its // track heights/order, bookmarks, annotations) through the settings // path, then hand it a tab. - std::unique_ptr new_item = std::make_unique(); - std::string out_id; - ProjectItem::OpenResult open_result = - new_item->OpenFromSettingsJson(item["settings"], dir, out_id); - if(open_result == ProjectItem::OpenResult::Success) - { - TabItem tab{ new_item->GetName(), new_item->GetID(), - new_item->GetView(), true }; - m_tab_container->AddTab(std::move(tab)); - opened_id = new_item->GetID(); - m_items[opened_id] = std::move(new_item); - } - else if(open_result == ProjectItem::OpenResult::Duplicate) - { - opened_id = out_id; - } + opened_id = OpenItemFromSettings(item["settings"], dir); } else if(item["files"].isArray()) { @@ -825,22 +841,7 @@ AppWindow::OpenProjectGroupFile(const std::string& file_path) { // Old single-item .rpv: the whole file is one item's settings; open it as the // project's single tab (settings restored). - std::unique_ptr new_item = std::make_unique(); - std::string out_id; - ProjectItem::OpenResult open_result = - new_item->OpenFromSettingsJson(root, dir, out_id); - std::string opened_id; - if(open_result == ProjectItem::OpenResult::Success) - { - TabItem tab{ new_item->GetName(), new_item->GetID(), new_item->GetView(), true }; - m_tab_container->AddTab(std::move(tab)); - opened_id = new_item->GetID(); - m_items[opened_id] = std::move(new_item); - } - else if(open_result == ProjectItem::OpenResult::Duplicate) - { - opened_id = out_id; - } + std::string opened_id = OpenItemFromSettings(root, dir); if(!opened_id.empty()) { Project* group = GetProjectById(project_id); @@ -870,6 +871,213 @@ AppWindow::OpenProjectGroupFile(const std::string& file_path) RefreshTabGroups(); } +void +AppWindow::SaveSession() +{ + // Snapshot the whole workspace (tab groups + ungrouped tabs, each with its full + // per-view settings) so the next plain launch can reopen it exactly. Paths are + // stored relative to the config dir. Called from BeginAppShutdown before the + // items are torn down. + std::filesystem::path config_dir = get_application_config_path(true); + std::filesystem::path session_path = config_dir / SESSION_FILE_NAME; + + jt::Json root; + root = ""; + root["version"] = "1.0"; + + size_t project_index = 0; + for(const std::unique_ptr& project : m_projects) + { + char color_buf[16]; + std::snprintf(color_buf, sizeof(color_buf), "%08X", + static_cast(project->GetColor())); + root["projects"][project_index]["name"] = project->GetName(); + root["projects"][project_index]["color"] = std::string(color_buf); + size_t item_index = 0; + for(const std::string& member_id : project->GetItemIds()) + { + ProjectItem* member = GetItem(member_id); + if(!member) + { + continue; + } + root["projects"][project_index]["items"][item_index]["settings"] = + member->ExportSettingsJson(config_dir); + item_index++; + } + size_t closed_index = 0; + for(const Project::ClosedItem& closed : project->GetClosedItems()) + { + root["projects"][project_index]["closed"][closed_index]["name"] = closed.name; + for(size_t j = 0; j < closed.files.size(); j++) + { + root["projects"][project_index]["closed"][closed_index]["files"][j] = + std::filesystem::proximate(closed.files[j], config_dir).generic_string(); + } + closed_index++; + } + project_index++; + } + + // Ungrouped tabs, kept in strip order. + size_t ungrouped_index = 0; + const std::vector tabs = m_tab_container->GetTabs(); + for(const TabItem* tab : tabs) + { + if(GetProjectForItem(tab->m_id)) + { + continue; // grouped items are saved under their project above + } + ProjectItem* item = GetItem(tab->m_id); + if(!item) + { + continue; + } + root["ungrouped"][ungrouped_index]["settings"] = item->ExportSettingsJson(config_dir); + ungrouped_index++; + } + + std::error_code ec; + if(project_index == 0 && ungrouped_index == 0) + { + // Nothing open: clear any stale session so the next launch starts clean. + std::filesystem::remove(session_path, ec); + return; + } + + std::ofstream file(session_path); + if(file.is_open()) + { + file << root.toStringPretty() << "\n"; + file.close(); + } +} + +void +AppWindow::RestoreSession() +{ + // Reopen the previous session (see SaveSession). Missing traces are skipped + // silently so a moved/deleted file never blocks startup. + std::filesystem::path config_dir = get_application_config_path(true); + std::filesystem::path session_path = config_dir / SESSION_FILE_NAME; + if(!std::filesystem::exists(session_path)) + { + return; + } + std::ifstream file(session_path); + if(!file.is_open()) + { + return; + } + std::string json_string; + std::string line; + while(std::getline(file, line)) + { + json_string += line; + } + file.close(); + + std::pair parsed = jt::Json::parse(json_string); + if(parsed.first != jt::Json::success) + { + return; + } + jt::Json& root = parsed.second; + const std::vector& palette = SettingsManager::GetInstance().GetColorWheel(); + + if(root["projects"].isArray()) + { + for(jt::Json& project_json : root["projects"].getArray()) + { + std::string name = + project_json["name"].isString() ? project_json["name"].getString() : "Project"; + ImU32 color = palette.empty() ? 0 : palette[0]; + if(project_json["color"].isString()) + { + color = static_cast(std::stoul(project_json["color"].getString(), nullptr, 16)); + } + Project* project = CreateProjectNamed(name, color); + std::string project_id = project->GetID(); + + if(project_json["items"].isArray()) + { + for(jt::Json& item_json : project_json["items"].getArray()) + { + if(item_json["settings"].isNull()) + { + continue; + } + std::string opened_id = OpenItemFromSettings(item_json["settings"], config_dir); + if(!opened_id.empty()) + { + Project* group = GetProjectById(project_id); + if(group) + { + group->AddItem(opened_id); + } + } + } + } + if(project_json["closed"].isArray()) + { + for(jt::Json& closed_json : project_json["closed"].getArray()) + { + Project::ClosedItem closed; + closed.name = closed_json["name"].isString() ? closed_json["name"].getString() + : std::string(); + if(closed_json["files"].isArray()) + { + for(jt::Json& entry : closed_json["files"].getArray()) + { + if(entry.isString()) + { + closed.files.push_back( + std::filesystem::weakly_canonical(config_dir / entry.getString()) + .string()); + } + } + } + if(!closed.files.empty()) + { + Project* group = GetProjectById(project_id); + if(group) + { + group->AddClosedItem(closed); + } + } + } + } + + // Drop a project that restored nothing (all traces missing / already open). + Project* group = GetProjectById(project_id); + if(group && group->GetItemIds().empty() && group->GetClosedItems().empty()) + { + for(size_t idx = 0; idx < m_projects.size(); idx++) + { + if(m_projects[idx]->GetID() == project_id) + { + m_projects.erase(m_projects.begin() + idx); + break; + } + } + } + } + } + + if(root["ungrouped"].isArray()) + { + for(jt::Json& item_json : root["ungrouped"].getArray()) + { + if(!item_json["settings"].isNull()) + { + OpenItemFromSettings(item_json["settings"], config_dir); + } + } + } + + RefreshTabGroups(); +} + void AppWindow::AssignItemToProject(const std::string& item_id, const std::string& project_id) { @@ -1386,6 +1594,10 @@ AppWindow::BeginAppShutdown() m_shutdown_start = std::chrono::steady_clock::now(); m_disable_app_interaction = true; + // Snapshot the session while the items/views are still alive (SaveSession reads + // each item's live settings), so the next plain launch can restore it. + SaveSession(); + NotificationManager::GetInstance().ShowPersistent( APP_SHUTDOWN_NOTIFICATION_ID, "Closing traces... " + std::to_string(m_provider_cleanup_jobs.size()) + diff --git a/src/view/src/rocprofvis_appwindow.h b/src/view/src/rocprofvis_appwindow.h index a227674a7..b5581e205 100644 --- a/src/view/src/rocprofvis_appwindow.h +++ b/src/view/src/rocprofvis_appwindow.h @@ -21,12 +21,18 @@ #include #include +#include #include #include #include #include #include +namespace jt +{ +class Json; +} + namespace RocProfVis { namespace View @@ -96,6 +102,11 @@ class AppWindow : public RocWidget // Opens two trace files as a single compare project (combined timeline, A/B tags). void OpenCompare(const std::string& first_file, const std::string& second_file); + // Restores the previous session (all tab groups + ungrouped tabs, with per-item + // settings) saved on the last graceful shutdown. Called on a plain launch (no + // file argument); a no-op when no session file exists. + void RestoreSession(); + // Stable, file-derived project id/key for a compare of the given source files. // Used as the tab id and the m_items key for both fresh and reopened compares. static std::string MakeCompareId(const std::vector& files); @@ -153,6 +164,13 @@ class AppWindow : public RocWidget void HandleSaveProjectGroup(const std::string& project_id); void SaveProjectGroup(const std::string& project_id, const std::string& save_path); void OpenProjectGroupFile(const std::string& file_path); + // Opens one tab from its embedded settings JSON (paths relative to base_dir) and + // returns the opened/duplicate item id, or "" on failure. Shared by the project + // loader and session restore. + std::string OpenItemFromSettings(const jt::Json& settings, const std::filesystem::path& base_dir); + // Whole-workspace session snapshot/restore (see RestoreSession). SaveSession runs + // during BeginAppShutdown while items are still alive. + void SaveSession(); void AssignItemToProject(const std::string& item_id, const std::string& project_id); void RemoveItemFromProjectMembership(const std::string& item_id); void UngroupProject(const std::string& project_id); diff --git a/src/view/src/rocprofvis_view_module.cpp b/src/view/src/rocprofvis_view_module.cpp index eaa5e72c3..4bb72b9d6 100644 --- a/src/view/src/rocprofvis_view_module.cpp +++ b/src/view/src/rocprofvis_view_module.cpp @@ -53,6 +53,12 @@ rocprofvis_view_open_files(const std::vector& file_paths) AppWindow::GetInstance()->OpenFiles(file_paths); } +void +rocprofvis_view_restore_session() +{ + AppWindow::GetInstance()->RestoreSession(); +} + void rocprofvis_view_set_fullscreen_state(bool is_fullscreen) { From 3f3cd4f6ceb09af2525748d9e3d4d511c5508215 Mon Sep 17 00:00:00 2001 From: Dipal Date: Thu, 10 Sep 2026 11:45:50 -0400 Subject: [PATCH 4/4] View: tidy session-persistence comments Drop comments that restate the code or duplicate the header docs, and condense the rest to one line. --- src/app/src/main.cpp | 3 +-- src/view/inc/rocprofvis_view_module.h | 3 +-- src/view/src/rocprofvis_appwindow.cpp | 30 ++++++++------------------- src/view/src/rocprofvis_appwindow.h | 10 ++++----- 4 files changed, 15 insertions(+), 31 deletions(-) diff --git a/src/app/src/main.cpp b/src/app/src/main.cpp index d2f5eb65c..e1a6f559e 100644 --- a/src/app/src/main.cpp +++ b/src/app/src/main.cpp @@ -497,8 +497,7 @@ main(int argc, char** argv) } else { - // Plain launch (no file argument): reopen the previous session's - // tab groups and tabs. No-op when there is no saved session. + // No file argument: reopen the previous session. rocprofvis_view_restore_session(); } diff --git a/src/view/inc/rocprofvis_view_module.h b/src/view/inc/rocprofvis_view_module.h index 26cc5b1d3..0d46b5bad 100644 --- a/src/view/inc/rocprofvis_view_module.h +++ b/src/view/inc/rocprofvis_view_module.h @@ -46,8 +46,7 @@ rocprofvis_view_destroy(); void rocprofvis_view_open_files(const std::vector& file_paths); -// Restores the previous session (open tab groups + tabs) saved on last shutdown. -// Call on a plain launch (no file argument); a no-op when there is no saved session. +// Reopens the previous session (saved on last shutdown). No-op if none was saved. void rocprofvis_view_restore_session(); diff --git a/src/view/src/rocprofvis_appwindow.cpp b/src/view/src/rocprofvis_appwindow.cpp index 02821306e..0e5b6fc17 100644 --- a/src/view/src/rocprofvis_appwindow.cpp +++ b/src/view/src/rocprofvis_appwindow.cpp @@ -78,10 +78,8 @@ const std::vector COMPARE_EXTENSIONS = { "db" }; const std::vector PROJECT_GROUP_EXTENSIONS = { "rpv" }; constexpr const char* PROJECT_GROUP_EXTENSION = ".rpv"; -// The previous session (all open tab groups + ungrouped tabs, with per-item -// settings) is snapshotted here in the app config dir on graceful shutdown, and -// restored on the next plain launch (no file argument). A distinct name/extension -// keeps it out of the user-facing .rpv project space. +// Session snapshot file, in the app config dir. Not a .rpv, to keep it out of the +// user-facing project space. constexpr const char* SESSION_FILE_NAME = "last_session.json"; constexpr const char* CLEANUP_MESSAGE = "Waiting for requests to finish cleanup..."; @@ -675,9 +673,6 @@ std::string AppWindow::OpenItemFromSettings(const jt::Json& settings, const std::filesystem::path& base_dir) { - // Restore a single tab from its embedded settings JSON (trace paths resolved - // relative to base_dir) and hand it a tab. Returns the opened/duplicate item id, - // or an empty string on failure (e.g. the trace was moved or deleted). std::unique_ptr new_item = std::make_unique(); std::string out_id; ProjectItem::OpenResult open_result = new_item->OpenFromSettingsJson(settings, base_dir, out_id); @@ -751,9 +746,7 @@ AppWindow::OpenProjectGroupFile(const std::string& file_path) std::string opened_id; if(!item["settings"].isNull()) { - // New format: the item carries its full settings; restore it (with its - // track heights/order, bookmarks, annotations) through the settings - // path, then hand it a tab. + // New format: the item carries its full per-view settings. opened_id = OpenItemFromSettings(item["settings"], dir); } else if(item["files"].isArray()) @@ -874,10 +867,7 @@ AppWindow::OpenProjectGroupFile(const std::string& file_path) void AppWindow::SaveSession() { - // Snapshot the whole workspace (tab groups + ungrouped tabs, each with its full - // per-view settings) so the next plain launch can reopen it exactly. Paths are - // stored relative to the config dir. Called from BeginAppShutdown before the - // items are torn down. + // Paths are stored relative to the config dir so the snapshot is relocatable. std::filesystem::path config_dir = get_application_config_path(true); std::filesystem::path session_path = config_dir / SESSION_FILE_NAME; @@ -926,7 +916,7 @@ AppWindow::SaveSession() { if(GetProjectForItem(tab->m_id)) { - continue; // grouped items are saved under their project above + continue; // grouped items are saved under their project } ProjectItem* item = GetItem(tab->m_id); if(!item) @@ -940,7 +930,7 @@ AppWindow::SaveSession() std::error_code ec; if(project_index == 0 && ungrouped_index == 0) { - // Nothing open: clear any stale session so the next launch starts clean. + // Nothing open: drop any stale session. std::filesystem::remove(session_path, ec); return; } @@ -956,8 +946,7 @@ AppWindow::SaveSession() void AppWindow::RestoreSession() { - // Reopen the previous session (see SaveSession). Missing traces are skipped - // silently so a moved/deleted file never blocks startup. + // Missing traces are skipped silently so a moved/deleted file never blocks startup. std::filesystem::path config_dir = get_application_config_path(true); std::filesystem::path session_path = config_dir / SESSION_FILE_NAME; if(!std::filesystem::exists(session_path)) @@ -1048,7 +1037,7 @@ AppWindow::RestoreSession() } } - // Drop a project that restored nothing (all traces missing / already open). + // Drop a project that restored nothing. Project* group = GetProjectById(project_id); if(group && group->GetItemIds().empty() && group->GetClosedItems().empty()) { @@ -1594,8 +1583,7 @@ AppWindow::BeginAppShutdown() m_shutdown_start = std::chrono::steady_clock::now(); m_disable_app_interaction = true; - // Snapshot the session while the items/views are still alive (SaveSession reads - // each item's live settings), so the next plain launch can restore it. + // Snapshot the session while items/views are still alive. SaveSession(); NotificationManager::GetInstance().ShowPersistent( diff --git a/src/view/src/rocprofvis_appwindow.h b/src/view/src/rocprofvis_appwindow.h index b5581e205..5ed47e5b6 100644 --- a/src/view/src/rocprofvis_appwindow.h +++ b/src/view/src/rocprofvis_appwindow.h @@ -102,9 +102,8 @@ class AppWindow : public RocWidget // Opens two trace files as a single compare project (combined timeline, A/B tags). void OpenCompare(const std::string& first_file, const std::string& second_file); - // Restores the previous session (all tab groups + ungrouped tabs, with per-item - // settings) saved on the last graceful shutdown. Called on a plain launch (no - // file argument); a no-op when no session file exists. + // Reopens the workspace (tab groups + tabs, with per-item settings) saved on the + // last shutdown. Called on a plain launch; a no-op when no session was saved. void RestoreSession(); // Stable, file-derived project id/key for a compare of the given source files. @@ -164,12 +163,11 @@ class AppWindow : public RocWidget void HandleSaveProjectGroup(const std::string& project_id); void SaveProjectGroup(const std::string& project_id, const std::string& save_path); void OpenProjectGroupFile(const std::string& file_path); - // Opens one tab from its embedded settings JSON (paths relative to base_dir) and + // Opens one tab from its embedded settings JSON (paths relative to base_dir); // returns the opened/duplicate item id, or "" on failure. Shared by the project // loader and session restore. std::string OpenItemFromSettings(const jt::Json& settings, const std::filesystem::path& base_dir); - // Whole-workspace session snapshot/restore (see RestoreSession). SaveSession runs - // during BeginAppShutdown while items are still alive. + // Snapshots the whole workspace to the session file; run during BeginAppShutdown. void SaveSession(); void AssignItemToProject(const std::string& item_id, const std::string& project_id); void RemoveItemFromProjectMembership(const std::string& item_id);