platform/ux: general C++ platform UX updates & spec adjustments.

* Enhances ImGui dialogs to support keyboard shortcuts (Enter/Escape) and filters.
* Adjusts GUI layouts for simulation speed and compiler settings.
* Exposes additional model fields in the XML spec generator.

PiperOrigin-RevId: 951988061
Change-Id: I4927c12a8882ba4e91e6098355ec2f5db35c2747
This commit is contained in:
Matija Kecman
2026-07-22 02:45:52 -07:00
committed by Copybara-Service
parent 782c00bc49
commit 0b1ed5b356
6 changed files with 121 additions and 71 deletions
+6 -3
View File
@@ -34,15 +34,18 @@ static DialogResult ImGui_FileDialog(std::string_view path) {
ImGui::Text("Filename");
ImGui::SameLine();
ImGui::SetNextItemWidth(600);
if (ImGui::InputText("##Filename", buf, kBufferSize, ImGuiInputTextFlags_EnterReturnsTrue)) {
if (ImGui::InputText("##Filename", buf, kBufferSize,
ImGuiInputTextFlags_EnterReturnsTrue)) {
status = DialogResult::kAccepted;
}
if (ImGui::Button("OK", ImVec2(120, 0)) || ImGui::IsKeyChordPressed(ImGuiKey_Enter)) {
if (ImGui::Button("OK", ImVec2(120, 0)) ||
ImGui::IsKeyChordPressed(ImGuiKey_Enter)) {
status = DialogResult::kAccepted;
}
ImGui::SetItemDefaultFocus();
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(120, 0)) || ImGui::IsKeyChordPressed(ImGuiKey_Escape)) {
if (ImGui::Button("Cancel", ImVec2(120, 0)) ||
ImGui::IsKeyChordPressed(ImGuiKey_Escape)) {
status = DialogResult::kCancelled;
}
@@ -55,8 +55,7 @@ static DialogResult RunZenity(std::vector<std::string>& args) {
.path = std::string(buffer)};
}
static void UpdateArgs(std::vector<std::string>& args,
std::string_view path,
static void UpdateArgs(std::vector<std::string>& args, std::string_view path,
std::span<std::string_view> filters = {}) {
if (!path.empty() && path.front() != 0) {
args.push_back("--filename=" + std::string(path.data()));
+93 -50
View File
@@ -40,20 +40,40 @@ struct SpeedStatus {
float measured;
};
// Reports whether the measured step speed has drifted from the desired speed
// enough for the toolbar to surface it, plus the measured value itself.
static SpeedStatus IsSpeedMisaligned(const StepControl& step_control) {
static bool misaligned = false;
const float desired = step_control.GetSpeed();
const float measured = step_control.GetSpeedMeasured();
return {std::abs(measured - desired) > 0.1f * desired, measured};
const float deviation = std::abs(measured - desired);
// Hysteresis: a raw threshold flips at exactly the 10% band edge, and
// measurement noise straddling it would toggle the preview text (and with it
// the combo width) every few frames causing layout flicker. Require the state
// to leave a wider band before switching back: become misaligned past 12%,
// return to aligned only under 8%, and follow an extreme deviation (>20%)
// immediately.
const bool misaligned_now = deviation > 0.1f * desired;
misaligned = misaligned ? (deviation > 0.08f * desired)
: (deviation > 0.12f * desired);
if (misaligned_now != misaligned && deviation > 0.2f * desired) {
misaligned = misaligned_now; // far outside both bands: follow immediately
}
return {misaligned, measured};
}
} // namespace
static ImVec2 GetFlexElementSize(int num_cols) {
const float width = (GetStableAvailWidth() / num_cols) -
ImGui::GetStyle().FramePadding.x * 2;
const float width =
(GetStableAvailWidth() / num_cols) - ImGui::GetStyle().FramePadding.x * 2;
return ImVec2(width, 0);
}
bool SectionHeader(const char* label, ImGuiTreeNodeFlags flags, float arrow_scale) {
bool SectionHeader(const char* label, ImGuiTreeNodeFlags flags,
float arrow_scale) {
const bool is_framed = (flags & ImGuiTreeNodeFlags_Framed) != 0;
ImGuiContext& g = *GImGui;
@@ -64,7 +84,8 @@ bool SectionHeader(const char* label, ImGuiTreeNodeFlags flags, float arrow_scal
const ImGuiID id = window->GetID(label);
// Control the bar height via FramePadding.y.
// Main (framed): a bit of padding. Sub (unframed): less padding to be visually thinner.
// Main (framed): a bit of padding. Sub (unframed): less padding to be
// visually thinner.
const float pad_y = (is_framed ? 3.0f : 2.0f) * style.FontScaleDpi;
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding,
ImVec2(style.FramePadding.x, pad_y));
@@ -73,25 +94,27 @@ bool SectionHeader(const char* label, ImGuiTreeNodeFlags flags, float arrow_scal
// TreeNodeBehavior.
ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(0, 0, 0, 0));
// Record cursor position before the widget so we can compute the arrow position.
// Record cursor position before the widget so we can compute the arrow
// position.
const ImVec2 cursor_before = ImGui::GetCursorScreenPos();
// Use TreeNodeBehavior (the same function CollapsingHeader uses internally).
// This gives us proper click-to-toggle and open/close state management.
// We do NOT add _Leaf here so toggling works, and we don't add _NoTreePushOnOpen
// so the callers' TreePop() still works.
// We do NOT add _Leaf here so toggling works, and we don't add
// _NoTreePushOnOpen so the callers' TreePop() still works.
//
// For unframed nodes, add _FramePadding so TreeNodeBehavior uses FramePadding.y
// for layout (making arrow Y position predictable = cursor.y + pad_y).
// Also add _SpanAvailWidth for consistent full-width hit testing.
// For unframed nodes, add _FramePadding so TreeNodeBehavior uses
// FramePadding.y for layout (making arrow Y position predictable = cursor.y +
// pad_y). Also add _SpanAvailWidth for consistent full-width hit testing.
ImGuiTreeNodeFlags effective_flags = flags;
if (!is_framed) {
effective_flags |= ImGuiTreeNodeFlags_FramePadding |
ImGuiTreeNodeFlags_SpanAvailWidth;
effective_flags |=
ImGuiTreeNodeFlags_FramePadding | ImGuiTreeNodeFlags_SpanAvailWidth;
}
bool is_open = ImGui::TreeNodeBehavior(id, effective_flags, label);
// Restore style state before manual rendering (so we get the correct text color).
// Restore style state before manual rendering (so we get the correct text
// color).
ImGui::PopStyleColor();
ImGui::PopStyleVar();
@@ -100,7 +123,8 @@ bool SectionHeader(const char* label, ImGuiTreeNodeFlags flags, float arrow_scal
const ImU32 text_col = ImGui::GetColorU32(ImGuiCol_Text);
// Match position offsets used by TreeNodeBehavior.
// Arrow coordinate: (cursor_before.x + FramePadding.x, cursor_before.y + pad_y)
// Arrow coordinate: (cursor_before.x + FramePadding.x, cursor_before.y +
// pad_y)
const float pad_x = style.FramePadding.x;
const float arrow_x = cursor_before.x + pad_x;
const float arrow_y = cursor_before.y + pad_y;
@@ -110,13 +134,15 @@ bool SectionHeader(const char* label, ImGuiTreeNodeFlags flags, float arrow_scal
const float custom_size = full_arrow_size * arrow_scale;
const float offset = (full_arrow_size - custom_size) * 0.5f;
const ImGuiDir arrow_dir = is_open ? ImGuiDir_Down : ImGuiDir_Right;
ImGui::RenderArrow(dl, ImVec2(arrow_x + offset, arrow_y + offset),
text_col, arrow_dir, arrow_scale);
ImGui::RenderArrow(dl, ImVec2(arrow_x + offset, arrow_y + offset), text_col,
arrow_dir, arrow_scale);
// Render text manually.
// text_offset_x = FontSize + padding.x * (display_frame ? 3 : 2)
const float text_offset_x = full_arrow_size + pad_x * (is_framed ? 3.0f : 2.0f);
ImGui::RenderText(ImVec2(cursor_before.x + text_offset_x, cursor_before.y + pad_y), label);
const float text_offset_x =
full_arrow_size + pad_x * (is_framed ? 3.0f : 2.0f);
ImGui::RenderText(
ImVec2(cursor_before.x + text_offset_x, cursor_before.y + pad_y), label);
return is_open;
}
@@ -158,7 +184,8 @@ void SetupTheme(GuiTheme theme) {
c[ImGuiCol_SliderGrab] = ImVec4(0.28, 0.40, 0.58, 1.00);
c[ImGuiCol_SliderGrabActive] = ImVec4(0.35, 0.50, 0.72, 1.00);
// Buttons: slightly lighter than frame background (0.22) to provide subtle but visible contrast.
// Buttons: slightly lighter than frame background (0.22) to provide subtle
// but visible contrast.
c[ImGuiCol_Button] = ImVec4(0.28, 0.29, 0.33, 1.00);
c[ImGuiCol_ButtonHovered] = ImVec4(0.34, 0.36, 0.40, 1.00);
c[ImGuiCol_ButtonActive] = ImVec4(0.32, 0.45, 0.66, 1.00);
@@ -238,7 +265,8 @@ void SetupTheme(GuiTheme theme) {
c[ImGuiCol_SliderGrab] = accent;
c[ImGuiCol_SliderGrabActive] = ImVec4(0.30, 0.52, 0.82, 1.00);
// Buttons: slightly darker than frame background (0.93) to provide subtle but visible contrast.
// Buttons: slightly darker than frame background (0.93) to provide subtle
// but visible contrast.
c[ImGuiCol_Button] = ImVec4(0.86, 0.87, 0.89, 1.00);
c[ImGuiCol_ButtonHovered] = accent_hover;
c[ImGuiCol_ButtonActive] = accent;
@@ -360,16 +388,23 @@ ImVec4 ConfigureDockingLayout(bool show_toolbar, bool show_status_bar) {
const float kOptionsRelWidth = 0.15f;
const float kInspectorRelWidth = 0.22f;
const float kPropertiesRelHeight = 0.3f;
const float kToolsBarHeight =
show_toolbar ? 36.f * scale * font_scale : 0.0f;
const float kToolsBarHeight = show_toolbar ? 36.f * scale * font_scale : 0.0f;
const float kStatusBarHeight =
show_status_bar ? 32.f * scale * font_scale : 0.0f;
const ImVec2 dockspace_pos{viewport->WorkPos.x,
viewport->WorkPos.y + kToolsBarHeight};
// Lay out from the absolute viewport origin with an explicitly computed
// menu bar height, NOT from WorkPos/WorkSize: the work area only reflects
// the menu bar's reservation from the previous frame, so any hiccup in
// that reservation shifts the whole dock layout by a menu-bar height for
// a frame and every docked window re-clips (visible as UI-wide flicker).
// The menu bar's height equals the frame height by definition.
const float menu_bar_height = ImGui::GetFrameHeight();
const ImVec2 toolbar_pos{viewport->Pos.x, viewport->Pos.y + menu_bar_height};
const ImVec2 dockspace_pos{
viewport->Pos.x, viewport->Pos.y + menu_bar_height + kToolsBarHeight};
const ImVec2 dockspace_size{
viewport->WorkSize.x,
viewport->WorkSize.y - kToolsBarHeight - kStatusBarHeight};
viewport->Size.x,
viewport->Size.y - menu_bar_height - kToolsBarHeight - kStatusBarHeight};
ImGuiID root = ImGui::GetID("Root");
const bool first_time = (ImGui::DockBuilderGetNode(root) == nullptr);
@@ -444,12 +479,12 @@ ImVec4 ConfigureDockingLayout(bool show_toolbar, bool show_status_bar) {
style.Var(ImGuiStyleVar_WindowBorderSize, 1.0f);
style.Var(ImGuiStyleVar_WindowRounding, 0.0f);
style.Var(ImGuiStyleVar_WindowMinSize, ImVec2(1, 1));
const float toolbar_vpad =
std::max(0.f, (36.f * scale * font_scale - ImGui::GetFrameHeight()) * 0.5f);
const float toolbar_vpad = std::max(
0.f, (36.f * scale * font_scale - ImGui::GetFrameHeight()) * 0.5f);
style.Var(ImGuiStyleVar_WindowPadding, ImVec2(4 * scale, toolbar_vpad));
ImGui::SetNextWindowPos(viewport->WorkPos, ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(viewport->Size.x, 36.f * scale * font_scale),
ImGuiCond_Always);
ImGui::SetNextWindowPos(toolbar_pos, ImGuiCond_Always);
ImGui::SetNextWindowSize(
ImVec2(viewport->Size.x, 36.f * scale * font_scale), ImGuiCond_Always);
ImGui::Begin("ToolBar", nullptr, kFixedFlags);
ImGui::End();
}
@@ -470,8 +505,8 @@ ImVec4 ConfigureDockingLayout(bool show_toolbar, bool show_status_bar) {
ImGuiDockNode* central = ImGui::DockBuilderGetCentralNode(root);
if (central) {
return ImVec4(central->Pos.x, central->Pos.y,
central->Size.x, central->Size.y);
return ImVec4(central->Pos.x, central->Pos.y, central->Size.x,
central->Size.y);
}
const int settings_width = dockspace_size.x * kOptionsRelWidth;
const int inspector_width = dockspace_size.x * kInspectorRelWidth;
@@ -486,13 +521,15 @@ void StepControlGui(StepControl* step_control, int& speed_index) {
platform::ScopedStyle style;
bool is_dark = ImGui::GetStyle().Colors[ImGuiCol_WindowBg].x < 0.5f;
const ImColor yellow = is_dark ? ImColor(158, 115, 18, 255) : ImColor(255, 215, 0, 255);
const ImColor green = is_dark ? ImColor(40, 125, 60, 255) : ImColor(40, 180, 40, 255);
const ImColor yellow =
is_dark ? ImColor(158, 115, 18, 255) : ImColor(255, 215, 0, 255);
const ImColor green =
is_dark ? ImColor(40, 125, 60, 255) : ImColor(40, 180, 40, 255);
auto make_button = [&](const char* icon, StepControl::PauseState target_state,
ImColor color, ImDrawFlags corners,
const char* tooltip = "",
float hover_alpha = 1.f, float width_scale = 1.f) {
const char* tooltip = "", float hover_alpha = 1.f,
float width_scale = 1.f) {
ImVec2 size(0, 0);
if (width_scale != 1.f) {
const ImGuiStyle& s = ImGui::GetStyle();
@@ -523,6 +560,7 @@ void StepControlGui(StepControl* step_control, int& speed_index) {
ImGui::GetStyle().FramePadding.y));
const auto [misaligned, measured] = IsSpeedMisaligned(*step_control);
char speed_preview[64];
if (misaligned) {
snprintf(speed_preview, sizeof(speed_preview), "%s %s (%-4.1f%%)",
@@ -532,7 +570,12 @@ void StepControlGui(StepControl* step_control, int& speed_index) {
kPercentRealTime[speed_index]);
}
ImGui::SetNextItemWidth(ImGui::CalcTextSize(speed_preview).x +
// Size the combo for the worst-case text so toggling the measured-speed
// suffix (or its digit count changing) never shifts the toolbar layout.
char speed_sizing[64];
snprintf(speed_sizing, sizeof(speed_sizing), "%s %s (-99.9%%)",
ICON_FA_TACHOMETER, kPercentRealTime[speed_index]);
ImGui::SetNextItemWidth(ImGui::CalcTextSize(speed_sizing).x +
ImGui::GetStyle().FramePadding.x * 2.f);
if (ImGui::BeginCombo("##Speed", speed_preview,
ImGuiComboFlags_NoArrowButton)) {
@@ -589,8 +632,9 @@ bool LabelSelectionGui(mjvOption* opts) {
bool changed = false;
const std::string label_preview =
opts->label == 0 ? std::string(ICON_LABEL) + " Label"
: std::string(ICON_LABEL) + " " + kLabelNames[opts->label];
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)) {
@@ -613,8 +657,9 @@ bool FrameSelectionGui(mjvOption* opts) {
bool changed = false;
const std::string frame_preview =
opts->frame == 0 ? std::string(ICON_FRAME) + " Frame"
: std::string(ICON_FRAME) + " " + kFrameNames[opts->frame];
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)) {
@@ -631,7 +676,7 @@ bool FrameSelectionGui(mjvOption* opts) {
}
std::string GetCameraName(const mjModel* model, const mjvCamera& camera,
int index) {
int index) {
static constexpr char kCameraTumbleName[] = "Free: tumble";
static constexpr char kCameraWasdName[] = "Free: wasd";
static constexpr char kCameraUnnamedName[] = "Unnamed";
@@ -961,9 +1006,7 @@ void PhysicsGui(mjModel* model, float min_width) {
const char* opts3[] = {"PGS", "CG", "Newton"};
ImGui::Combo("Solver", &opt.solver, opts3, IM_ARRAYSIZE(opts3));
if (SectionHeader("Algorithmic Parameters",
ImGuiTreeNodeFlags_DefaultOpen)) {
if (SectionHeader("Algorithmic Parameters", ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui_Input("Timestep", &opt.timestep, {0, 1, 0.01, 0.1});
ImGui_Input("Iterations", &opt.iterations, {0, 1000, 1, 10});
ImGui_Input("Tolerance", &opt.tolerance, {0, 1, 1e-7, 1e-6});
@@ -1525,8 +1568,7 @@ void CountsGui(const mjModel* model, mjData* data, ImVec2 plot_size) {
}
}
void InfoGui(const mjModel* model, const mjData* data, bool paused,
float fps) {
void InfoGui(const mjModel* model, const mjData* data, bool paused, float fps) {
const int num_islands = std::clamp(data->nisland, 1, mjNISLAND);
// compute solver error (maximum over islands)
@@ -1594,7 +1636,8 @@ void InfoGui(const mjModel* model, const mjData* data, bool paused,
ImGui::Columns();
}
void ProfilerGui(const mjModel* model, mjData* data, SimProfiler* profiler, bool show_iter) {
void ProfilerGui(const mjModel* model, mjData* data, SimProfiler* profiler,
bool show_iter) {
ImVec2 avail = ImGui::GetContentRegionAvail();
const float pad = ImGui::GetStyle().ItemSpacing.x;
const float aspect = avail.y > 0 ? avail.x / avail.y : 1.0f;
+10 -6
View File
@@ -279,8 +279,9 @@ void ElementSpecGui(mjsElement* element, SpecEditor* editor) {
// if editor, assert that element == editor->GetActiveElement();
mjsElement* ref_element = editor ? editor->GetRefElement() : element;
#define FIELD(NAME, TIP) table(#NAME, elem->NAME, ref->NAME, TIP);
#define QFIELD(NAME, ALT, TIP) table(#NAME, #ALT, elem->NAME, ref->NAME, elem->ALT, ref->ALT, TIP);
#define FIELD(NAME, TIP) table(#NAME, elem->NAME, ref->NAME, TIP);
#define QFIELD(NAME, ALT, TIP) \
table(#NAME, #ALT, elem->NAME, ref->NAME, elem->ALT, ref->ALT, TIP);
ImGui_SpecElementTable table(editor == nullptr);
switch (element->elemtype) {
@@ -297,7 +298,8 @@ void ElementSpecGui(mjsElement* element, SpecEditor* editor) {
FIELD(fullinertia, "non-axis-aligned inertia matrix");
FIELD(mocap, "is this a mocap body");
FIELD(gravcomp, "gravity compensation");
FIELD(explicitinertial, "whether to save the body with explicit inertial clause");
FIELD(explicitinertial,
"whether to save the body with explicit inertial clause");
FIELD(sleep, "sleep policy");
FIELD(info, "message appended to compiler errors");
break;
@@ -347,7 +349,8 @@ void ElementSpecGui(mjsElement* element, SpecEditor* editor) {
FIELD(slidersite, "site defining cylinder, for slider-crank");
FIELD(cranklength, "crank length, for slider-crank");
FIELD(lengthrange, "transmission length range");
FIELD(inheritrange, "automatic range setting for position and intvelocity");
FIELD(inheritrange,
"automatic range setting for position and intvelocity");
FIELD(ctrllimited, "are control limits defined (mjtLimited)");
FIELD(ctrlrange, "control range");
FIELD(forcelimited, "are force limits defined (mjtLimited)");
@@ -549,7 +552,8 @@ void ElementSpecGui(mjsElement* element, SpecEditor* editor) {
FIELD(poisson, "Poisson's ratio");
FIELD(damping, "Rayleigh's damping");
FIELD(thickness, "thickness (2D only)");
FIELD(elastic2d, "2D passive forces; 0: none, 1: bending, 2: stretching, 3: both");
FIELD(elastic2d,
"2D passive forces; 0: none, 1: bending, 2: stretching, 3: both");
FIELD(info, "message appended to compiler errors");
break;
}
@@ -705,7 +709,7 @@ void ElementSpecGui(mjsElement* element, SpecEditor* editor) {
break;
}
#undef FIELD
#undef FIELD
if (editor && table.WasModified()) {
editor->CommitChanges(element);
@@ -226,8 +226,7 @@ void ImGui_SpecElementTable::operator()(const char* label, mjtByte& val,
}
void ImGui_SpecElementTable::operator()(const char* label, bool& val,
const bool& ref,
const char* tooltip) {
const bool& ref, const char* tooltip) {
Label(label, tooltip);
Input(val, ref);
}
@@ -550,8 +549,8 @@ void SetNextWindowPosInside(OverlayPos pos, ImVec4 rect) {
} // namespace
bool BeginOverlay(const char* id, OverlayPos pos, ImVec4 rect,
float min_width, float alpha) {
bool BeginOverlay(const char* id, OverlayPos pos, ImVec4 rect, float min_width,
float alpha) {
SetNextWindowPosInside(pos, rect);
if (min_width > 0.0f) {
@@ -574,8 +573,8 @@ void EndOverlay() {
}
void TextOverlay(const char* id, OverlayPos pos, ImVec4 workspace_rect,
const char* text, ImVec4 color, float font_scale,
float alpha, float min_width) {
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);
@@ -590,8 +589,8 @@ void TextOverlay(const char* id, OverlayPos pos, ImVec4 workspace_rect,
if (font_scale != 1.0f) {
ImGui::SetWindowFontScale(font_scale);
}
bool has_color = color.x != 0 || color.y != 0 || color.z != 0 ||
color.w != 0;
bool has_color =
color.x != 0 || color.y != 0 || color.z != 0 || color.w != 0;
if (has_color) {
ImGui::PushStyleColor(ImGuiCol_Text, color);
}
+4 -2
View File
@@ -69,5 +69,7 @@ void ForEachPlugin(const std::function<void(T*)>& fn) {
MUJOCO_SPECIALIZE_PLUGIN(mujoco::platform::GuiPlugin, "gui plugin");
MUJOCO_SPECIALIZE_PLUGIN(mujoco::platform::ModelPlugin, "model plugin");
MUJOCO_SPECIALIZE_PLUGIN(mujoco::platform::ScenePlugin, "scene plugin");
MUJOCO_SPECIALIZE_PLUGIN(mujoco::platform::KeyHandlerPlugin, "key handler plugin");
MUJOCO_SPECIALIZE_PLUGIN(mujoco::platform::SpecEditorPlugin, "spec editor plugin");
MUJOCO_SPECIALIZE_PLUGIN(mujoco::platform::KeyHandlerPlugin,
"key handler plugin");
MUJOCO_SPECIALIZE_PLUGIN(mujoco::platform::SpecEditorPlugin,
"spec editor plugin");