From 463b0fb27fc7ef1a118a76f2676d660ee4556849 Mon Sep 17 00:00:00 2001 From: Yuval Tassa Date: Thu, 25 Jun 2026 01:44:27 -0700 Subject: [PATCH] Add ImGui overlay utility and PAUSE/realtime overlays to Studio. PiperOrigin-RevId: 937834952 Change-Id: I3f3a0500a89b34e2e03b2fe88344b928edeafe8f --- src/experimental/platform/ux/imgui_widgets.cc | 114 ++++++++++++++++++ src/experimental/platform/ux/imgui_widgets.h | 30 +++++ src/experimental/studio/app.cc | 47 +++++--- 3 files changed, 175 insertions(+), 16 deletions(-) diff --git a/src/experimental/platform/ux/imgui_widgets.cc b/src/experimental/platform/ux/imgui_widgets.cc index c306e4b8..673a811d 100644 --- a/src/experimental/platform/ux/imgui_widgets.cc +++ b/src/experimental/platform/ux/imgui_widgets.cc @@ -15,6 +15,7 @@ #include "experimental/platform/ux/imgui_widgets.h" #include +#include #include #include #include @@ -502,5 +503,118 @@ void DrawTextAt(const char* text, float x, float y, float z) { ImGui::EndChild(); ImGui::End(); } +namespace { + +float MeasureTextWidth(const char* text) { + float max_width = 0.0f; + const char* start = text; + while (*start) { + const char* end = start; + while (*end && *end != '\n') ++end; + max_width = std::max(max_width, ImGui::CalcTextSize(start, end).x); + start = *end ? end + 1 : end; + } + return max_width; +} + +void SetNextWindowPosInside(OverlayPos pos, ImVec4 rect) { + // compute anchor point and pivot from position enum + ImVec2 anchor, pivot; + float x = rect.x, y = rect.y, w = rect.z, h = rect.w; + float scale = ImGui::GetWindowDpiScale(); + float margin = 8.0f * scale; + switch (pos) { + case OverlayPos::kTopLeft: + anchor = {x + margin, y + margin}; + pivot = {0.0f, 0.0f}; + break; + case OverlayPos::kTop: + anchor = {x + w * 0.5f, y + margin}; + pivot = {0.5f, 0.0f}; + break; + case OverlayPos::kTopRight: + anchor = {x + w - margin, y + margin}; + pivot = {1.0f, 0.0f}; + break; + case OverlayPos::kBottomLeft: + anchor = {x + margin, y + h - margin}; + pivot = {0.0f, 1.0f}; + break; + case OverlayPos::kBottom: + anchor = {x + w * 0.5f, y + h - margin}; + pivot = {0.5f, 1.0f}; + break; + case OverlayPos::kBottomRight: + anchor = {x + w - margin, y + h - margin}; + pivot = {1.0f, 1.0f}; + break; + } + + ImGui::SetNextWindowPos(anchor, ImGuiCond_Always, pivot); +} + +} // namespace + +bool BeginOverlay(const char* id, OverlayPos pos, ImVec4 rect, + float min_width, float alpha) { + SetNextWindowPosInside(pos, rect); + + if (min_width > 0.0f) { + ImGui::SetNextWindowSizeConstraints(ImVec2(min_width, -1.0f), + ImVec2(FLT_MAX, -1.0f)); + } + + constexpr ImGuiWindowFlags kFlags = + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings; + + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, alpha); + return ImGui::Begin(id, nullptr, kFlags); +} + +void EndOverlay() { + ImGui::End(); + ImGui::PopStyleVar(); +} + +void TextOverlay(const char* id, OverlayPos pos, ImVec4 workspace_rect, + const char* text, ImVec4 color, float font_scale, + float alpha, float min_width) { + float scale = ImGui::GetWindowDpiScale(); + float padding = 30.0f * scale; + float max_width = std::max(0.0f, workspace_rect.z - 20.0f); + float text_width = MeasureTextWidth(text) * font_scale; + float min_target = std::min(min_width * scale, max_width); + float target = 0.0f; + if (text_width + padding > max_width || min_width > 0.0f) { + target = std::clamp(text_width + padding, min_target, max_width); + } + + if (BeginOverlay(id, pos, workspace_rect, target, alpha)) { + if (font_scale != 1.0f) { + ImGui::SetWindowFontScale(font_scale); + } + bool has_color = color.x != 0 || color.y != 0 || color.z != 0 || + color.w != 0; + if (has_color) { + ImGui::PushStyleColor(ImGuiCol_Text, color); + } + if (text_width + padding > max_width) { + ImGui::PushTextWrapPos(max_width - padding); + ImGui::TextUnformatted(text); + ImGui::PopTextWrapPos(); + } else { + ImGui::TextUnformatted(text); + } + if (has_color) { + ImGui::PopStyleColor(); + } + if (font_scale != 1.0f) { + ImGui::SetWindowFontScale(1.0f); + } + } + EndOverlay(); +} } // namespace mujoco::platform diff --git a/src/experimental/platform/ux/imgui_widgets.h b/src/experimental/platform/ux/imgui_widgets.h index 34722f0e..e7e42d35 100644 --- a/src/experimental/platform/ux/imgui_widgets.h +++ b/src/experimental/platform/ux/imgui_widgets.h @@ -692,6 +692,36 @@ ImPlotPairLayout ImPlot_ComputePairLayout(); // [1,1,1]). void DrawTextAt(const char* text, float x, float y, float z); +// Grid position for overlays within a viewport rectangle. +enum class OverlayPos { + kTopLeft, + kTop, + kTopRight, + kBottomLeft, + kBottom, + kBottomRight, +}; + +// Begin an overlay window at the given grid position within a workspace rect. +// workspace_rect: (x, y, width, height) of the viewport area. +// min_width: minimum window width in scaled pixels (0 = auto). +// alpha: window transparency (0.0-1.0). +// Returns true if the overlay is visible (like ImGui::Begin). +// Must be paired with EndOverlay(), even if this returns false. +bool BeginOverlay(const char* id, OverlayPos pos, ImVec4 workspace_rect, + float min_width = 0.0f, float alpha = 0.8f); +void EndOverlay(); + +// One-liner: display a text overlay with automatic width sizing. +// Measures the longest line in `text`, clamps to workspace width, wraps text +// if needed. +// color: text color. If all components are 0, uses the theme default. +// font_scale: scale factor for the default font (1.0 = normal). +void TextOverlay(const char* id, OverlayPos pos, ImVec4 workspace_rect, + const char* text, ImVec4 color = ImVec4(0, 0, 0, 0), + float font_scale = 1.0f, float alpha = 0.8f, + float min_width = 0.0f); + } // namespace mujoco::platform #endif // MUJOCO_SRC_EXPERIMENTAL_PLATFORM_UX_IMGUI_WIDGETS_H_ diff --git a/src/experimental/studio/app.cc b/src/experimental/studio/app.cc index eab4dcdd..bd328b41 100644 --- a/src/experimental/studio/app.cc +++ b/src/experimental/studio/app.cc @@ -978,28 +978,43 @@ void App::BuildGui() { } if (tmp_.info) { - platform::ScopedStyle style; - style.Var(ImGuiStyleVar_Alpha, 0.8f); const float scale = ImGui::GetWindowDpiScale(); - ImGui::SetNextWindowPos( - ImVec2(workspace_rect.x, - workspace_rect.y + workspace_rect.w), - ImGuiCond_Always, ImVec2(0.0f, 1.0f)); - ImGui::SetNextWindowSizeConstraints(ImVec2(240.0f * scale, -1.0f), - ImVec2(FLT_MAX, -1.0f)); - const ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_AlwaysAutoResize | - ImGuiWindowFlags_NoSavedSettings; - if (ImGui::Begin("Info", nullptr, flags)) { + if (platform::BeginOverlay("Info", platform::OverlayPos::kBottomLeft, + workspace_rect, 180.0f * scale)) { const float fps = renderer_->GetFps(); platform::InfoGui( model(), data(), step_control_.GetPauseState() == PauseState::kNormalPaused, fps); } - ImGui::End(); + platform::EndOverlay(); + } + + // pause overlay + if (has_model() && + step_control_.GetPauseState() == PauseState::kNormalPaused) { + platform::TextOverlay("Pause", platform::OverlayPos::kTop, + workspace_rect, "PAUSE", ImVec4(0, 0, 0, 0), 3.0f); + } + + // realtime factor overlay + if (has_model() && + step_control_.GetPauseState() == PauseState::kUnpaused) { + const float desired = step_control_.GetSpeed(); + const float measured = step_control_.GetSpeedMeasured(); + if (desired != 100.0f || + mju_abs(measured - desired) > 0.1f * desired) { + char rtlabel[30]; + bool misaligned = mju_abs(measured - desired) > 0.1f * desired; + if (misaligned) { + std::snprintf(rtlabel, sizeof(rtlabel), "%g%% (%-4.1f%%)", + desired, measured); + } else { + std::snprintf(rtlabel, sizeof(rtlabel), "%g%%", desired); + } + platform::TextOverlay("Realtime", platform::OverlayPos::kTopLeft, + workspace_rect, rtlabel, ImVec4(0, 0, 0, 0), + 2.0f); + } } // Display a drag-and-drop message if no model is loaded.