From c0b8aa9decba72e65cd97840951e23a9f5855915 Mon Sep 17 00:00:00 2001 From: Matija Kecman Date: Wed, 27 May 2026 06:42:17 -0700 Subject: [PATCH] Make studio python more like studio C++ - C++ Platform UX: Added a "Copy Camera" button to copy XML camera definitions to clipboard. - C++ Platform UX: Extracted `SetSpeedIndex` and `GetExpectedLabelWidth` into reusable platform helpers. - C++ Platform UX: Fixed combo box widths and removed auto-hide tab bar flag from dockspace. - C++ Studio App: Deduplicated label width and speed index logic by adopting platform helpers. - C++ Studio App: Updated the toolbar layout to use a 2-column table. - Python Bindings: Added pybind definitions for `set_camera_index`, `set_speed_index`, and `camera_to_string`. - Python Studio App: Aligned toolbar UI with C++ using a 2-column table and fixed immediate theme switching. - Python Studio App: Added a Help menu displaying the MuJoCo version and Stats toggle. - Python Events: Added `-`/`=` shortcuts for simulation speed and `Esc`/`[`/`]` shortcuts for camera selection. PiperOrigin-RevId: 922097319 Change-Id: Iec74afe37bf5c7a1ebf1cab4141e47fff5fe9274 --- src/experimental/platform/ux/gui.cc | 33 ++++++++++-- src/experimental/platform/ux/gui.h | 4 ++ src/experimental/platform/ux/imgui_widgets.cc | 17 ++++++ src/experimental/platform/ux/imgui_widgets.h | 3 ++ src/experimental/studio/app.cc | 54 +++---------------- src/experimental/studio/app.h | 2 - 6 files changed, 59 insertions(+), 54 deletions(-) diff --git a/src/experimental/platform/ux/gui.cc b/src/experimental/platform/ux/gui.cc index 2892b185..9a155613 100644 --- a/src/experimental/platform/ux/gui.cc +++ b/src/experimental/platform/ux/gui.cc @@ -316,8 +316,7 @@ ImVec4 ConfigureDockingLayout() { ImGui::Begin("Dockspace", nullptr, kWorkspaceFlags); const ImGuiDockNodeFlags kDockSpaceFlags = ImGuiDockNodeFlags_PassthruCentralNode | - ImGuiDockNodeFlags_NoDockingOverCentralNode | - ImGuiDockNodeFlags_AutoHideTabBar; + ImGuiDockNodeFlags_NoDockingOverCentralNode; ImGui::DockSpace(root, ImVec2(0.0f, 0.0f), kDockSpaceFlags); ImGui::End(); } @@ -426,9 +425,7 @@ void StepControlGui(const mjModel* model, StepControl* step_control, ImGuiComboFlags_NoArrowButton)) { for (int n = 0; n < kPercentRealTime.size(); n++) { if (ImGui::Selectable(kPercentRealTime[n], (speed_index == n))) { - speed_index = std::clamp(n, 0, kPercentRealTime.size() - 1); - float speed = std::stof(kPercentRealTime[speed_index]); - step_control->SetSpeed(speed); + SetSpeedIndex(step_control, speed_index, n); } } ImGui::EndCombo(); @@ -442,6 +439,17 @@ void StepControlGui(const mjModel* model, StepControl* step_control, } } +void SetSpeedIndex(StepControl* step_control, int& speed_index, + int request_idx) { + if (!step_control || request_idx == speed_index || kPercentRealTime.empty()) { + return; + } + + speed_index = std::clamp(request_idx, 0, kPercentRealTime.size() - 1); + float speed = std::stof(kPercentRealTime[speed_index]); + step_control->SetSpeed(speed); +} + bool ThemeSelectGui(GuiTheme* theme, const ImVec2& size) { static constexpr const char* ICON_DARKMODE = ICON_FA_CIRCLE; static constexpr const char* ICON_LIGHTMODE = ICON_FA_CIRCLE_O; @@ -471,6 +479,7 @@ bool LabelSelectionGui(mjvOption* opts) { const std::string label_preview = opts->label == 0 ? std::string(ICON_LABEL) + " Label" : std::string(ICON_LABEL) + " " + kLabelNames[opts->label]; + ImGui::SetNextItemWidth(GetExpectedLabelWidth()); if (ImGui::BeginCombo("##Label", label_preview.c_str(), ImGuiComboFlags_NoArrowButton)) { for (int n = 0; n < IM_ARRAYSIZE(kLabelNames); n++) { @@ -494,6 +503,7 @@ bool FrameSelectionGui(mjvOption* opts) { const std::string frame_preview = opts->frame == 0 ? std::string(ICON_FRAME) + " Frame" : std::string(ICON_FRAME) + " " + kFrameNames[opts->frame]; + ImGui::SetNextItemWidth(GetExpectedLabelWidth()); if (ImGui::BeginCombo("##Frame", frame_preview.c_str(), ImGuiComboFlags_NoArrowButton)) { for (int n = 0; n < IM_ARRAYSIZE(kFrameNames); n++) { @@ -530,6 +540,19 @@ static std::string GetCameraName(const mjModel* model, const mjvCamera& camera, bool CameraSelectionGui(const mjModel* model, mjData* data, mjvCamera& camera, int& index) { static constexpr const char* ICON_CAMERA = ICON_FA_CAMERA; + static constexpr const char* ICON_COPY_CAMERA = ICON_FA_COPY; + + // Copy camera button. + const float btn_size = ImGui::GetFrameHeight(); + const ImVec2 square_size(btn_size, btn_size); + if (ImGui::Button(ICON_COPY_CAMERA, square_size)) { + std::string camera_string = CameraToString(data, &camera); + MaybeSaveToClipboard(camera_string); + } + ImGui::SetItemTooltip("%s", "Copy Camera"); + ImGui::SameLine(0, 0); + + ImGui::SetNextItemWidth(GetExpectedLabelWidth()); auto select = [&](int type, int idx) { if (ImGui::Selectable(GetCameraName(model, camera, type).c_str(), diff --git a/src/experimental/platform/ux/gui.h b/src/experimental/platform/ux/gui.h index 3599a93d..3edd0425 100644 --- a/src/experimental/platform/ux/gui.h +++ b/src/experimental/platform/ux/gui.h @@ -84,6 +84,10 @@ static constexpr std::array kPercentRealTime = { void StepControlGui(const mjModel* model, StepControl* step_control, int& speed_index); +// Sets the simulation speed index and updates the StepControl object. +void SetSpeedIndex(StepControl* step_control, int& speed_index, + int request_idx); + // UX for selecting the GUI theme. bool ThemeSelectGui(GuiTheme* theme, const ImVec2& size = ImVec2(0, 0)); diff --git a/src/experimental/platform/ux/imgui_widgets.cc b/src/experimental/platform/ux/imgui_widgets.cc index a928a6cf..c21e3a99 100644 --- a/src/experimental/platform/ux/imgui_widgets.cc +++ b/src/experimental/platform/ux/imgui_widgets.cc @@ -386,6 +386,23 @@ void MaybeSaveToClipboard(const std::string& contents) { ImGui::SetClipboardText(contents.c_str()); } +float GetExpectedLabelWidth() { + static float expected_label_width = 0; + if (expected_label_width == 0) { + int longest = 0; + const char* longest_label = ""; + for (int i = 0; i < mjNVISFLAG; ++i) { + int length = static_cast(std::strlen(mjVISSTRING[i][0])); + if (length > longest) { + longest_label = mjVISSTRING[i][0]; + longest = length; + } + } + expected_label_width = ImGui::CalcTextSize(longest_label).x + 16; + } + return expected_label_width; +} + ImPlotFlags ImPlot_SetupPlotFlags(ImVec2 plot_size) { ImPlotFlags flags = ImPlotFlags_None; if (plot_size.x > 0 && plot_size.y > 0) { diff --git a/src/experimental/platform/ux/imgui_widgets.h b/src/experimental/platform/ux/imgui_widgets.h index 7ea7d584..53ea5bbc 100644 --- a/src/experimental/platform/ux/imgui_widgets.h +++ b/src/experimental/platform/ux/imgui_widgets.h @@ -617,6 +617,9 @@ inline void EndBoxSection() { ImGui::EndTable(); } // Saves the given contents to the clipboard if the clipboard is available. void MaybeSaveToClipboard(const std::string& contents); +// Returns the expected width of a UI label based on MuJoCo visualization flags. +float GetExpectedLabelWidth(); + // Returns plot flags with title/legend conditionally hidden when the plot // area is too small. `plot_size` is the final rendered size of the plot. ImPlotFlags ImPlot_SetupPlotFlags(ImVec2 plot_size); diff --git a/src/experimental/studio/app.cc b/src/experimental/studio/app.cc index b221fa61..d24df9a8 100644 --- a/src/experimental/studio/app.cc +++ b/src/experimental/studio/app.cc @@ -826,14 +826,7 @@ void App::SaveSettings() { } void App::SetSpeedIndex(int idx) { - if (idx == tmp_.speed_index || platform::kPercentRealTime.empty()) { - return; - } - - tmp_.speed_index = - std::clamp(idx, 0, platform::kPercentRealTime.size() - 1); - float speed = std::stof(platform::kPercentRealTime[tmp_.speed_index]); - step_control_.SetSpeed(speed); + platform::SetSpeedIndex(&step_control_, tmp_.speed_index, idx); } void App::MoveCamera(platform::CameraMotion motion, mjtNum reldx, @@ -1022,7 +1015,7 @@ void App::BuildGui() { } void App::ModelOptionsGui() { - const float min_width = GetExpectedLabelWidth(); + const float min_width = platform::GetExpectedLabelWidth(); const ImGuiChildFlags child_flags = ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysAutoResize; const ImGuiTreeNodeFlags node_flags = @@ -1064,7 +1057,7 @@ void App::DataInspectorGui() { return; } - const float min_width = GetExpectedLabelWidth(); + const float min_width = platform::GetExpectedLabelWidth(); const ImGuiChildFlags child_flags = ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysAutoResize; const ImGuiTreeNodeFlags node_flags = @@ -1424,15 +1417,14 @@ void App::ToolBarGui() { ImVec2(ImGui::GetStyle().ItemSpacing.x * 2.0f, ImGui::GetStyle().ItemSpacing.y)); - const float label_width = GetExpectedLabelWidth(); - const float copy_btn_width = ImGui::CalcTextSize(ICON_COPY_CAMERA).x + - ImGui::GetStyle().FramePadding.x * 2; + const float label_width = platform::GetExpectedLabelWidth(); + const float copy_btn_width = ImGui::GetFrameHeight(); const float theme_width = ImGui::CalcTextSize(platform::ICON_FA_CIRCLE_O).x + ImGui::GetStyle().FramePadding.x * 2; const float sp = ImGui::GetStyle().ItemSpacing.x; - const float right_width = label_width + sp + label_width + sp + - label_width + sp + copy_btn_width + sp + + const float right_width = copy_btn_width + label_width + sp + + label_width + sp + label_width + sp + theme_width; const float separator_width = ImGui::GetFrameHeight() * .6f; @@ -1467,26 +1459,15 @@ void App::ToolBarGui() { ImGui::TableNextColumn(); - if (ImGui::Button(ICON_COPY_CAMERA, square_size)) { - std::string camera_string = platform::CameraToString(data(), &camera_); - platform::MaybeSaveToClipboard(camera_string); - } - ImGui::SetItemTooltip("%s", "Copy Camera"); - - ImGui::SameLine(0, 0); - ImGui::SetNextItemWidth(GetExpectedLabelWidth()); platform::CameraSelectionGui(model(), data(), camera_, ui_.camera_idx); ImGui::SameLine(); - ImGui::SetNextItemWidth(GetExpectedLabelWidth()); platform::LabelSelectionGui(&vis_options_); ImGui::SameLine(); - ImGui::SetNextItemWidth(GetExpectedLabelWidth()); platform::FrameSelectionGui(&vis_options_); ImGui::SameLine(); - ImGui::SetNextItemWidth(GetExpectedLabelWidth()); if (platform::ThemeSelectGui(&ui_.theme, square_size)) { platform::SetupTheme(ui_.theme); ImGui::GetIO().WantSaveIniSettings = true; @@ -1836,27 +1817,6 @@ void App::FileDialogGui() { } } -float App::GetExpectedLabelWidth() { - // Find the longest label which we'll use to set the minimum toggle button - // width. This isn't perfect because we may have labels that are longer, but - // it's a good enough approximation. - if (tmp_.expected_label_width == 0) { - int longest = 0; - const char* longest_label = ""; - for (int i = 0; i < mjNVISFLAG; ++i) { - int length = static_cast(strlen(mjVISSTRING[i][0])); - if (length > longest) { - longest_label = mjVISSTRING[i][0]; - longest = length; - } - } - // Pad the width a bit to account for how the labels will be displayed - // (e.g. as button labels or besides checkboxes). - tmp_.expected_label_width = ImGui::CalcTextSize(longest_label).x + 16; - } - return tmp_.expected_label_width; -} - App::UiState::Dict App::UiState::ToDict() const { return { {"theme", std::to_string(static_cast(theme))}, diff --git a/src/experimental/studio/app.h b/src/experimental/studio/app.h index 175b3fee..9819c059 100644 --- a/src/experimental/studio/app.h +++ b/src/experimental/studio/app.h @@ -224,8 +224,6 @@ class App { void SpecExplorerGui(); void SpecEditorGui(); - float GetExpectedLabelWidth(); - mjSpec* spec() { return model_holder_->spec(); } mjModel* model() { return model_holder_->model(); } mjData* data() { return model_holder_->data(); }