Studio: extract TimelineScrubberGui and add it to the toolbar.

The code for the timeline scrubber widget has been moved from `App::ModelOptionsGui` into a new method `App::TimelineScrubberGui`. This new method is now called in both `App::ModelOptionsGui` and `App::ToolBarGui`, replacing the thread count slider in the toolbar.

PiperOrigin-RevId: 945565640
Change-Id: I6d5cab0b2397e8b32a2b335e8a709bb9e19bdb48
This commit is contained in:
Yuval Tassa
2026-07-10 01:26:04 -07:00
committed by Copybara-Service
parent dc7581acfa
commit 2f650a7beb
2 changed files with 234 additions and 236 deletions
+233 -236
View File
@@ -1233,6 +1233,236 @@ static std::string FormatTimelineTime(double time_in_s) {
return buf;
}
void App::TimelineScrubberGui() {
// Timeline scrubber: spine + sliding knob widget.
const double max_time = tmp_.sim_head_time_;
const int hist_size = sim_history_.Size();
const int hist_min = 1 - hist_size; // most negative index (oldest)
const int hist_max = 0; // index 0 = most recent
int current_index = sim_history_.GetIndex();
const bool locked = (hist_size <= 1);
// Compute current timestamp for LH label.
double curr_time =
(has_data() && current_index == sim_history_.GetIndex())
? data()->time
: (max_time +
current_index *
(has_model() ? model()->opt.timestep : 0.002));
if (curr_time < 0.0) curr_time = 0.0;
const int curr_step = (has_model() && model()->opt.timestep > 0)
? static_cast<int>(std::round(
curr_time / model()->opt.timestep))
: 0;
const int max_step =
(has_model() && model()->opt.timestep > 0)
? static_cast<int>(std::round(max_time / model()->opt.timestep))
: 0;
// Both LH and RH labels aim to be at 3 digits, switching units
// independently.
std::string lh_top_str = FormatTimelineTime(curr_time);
std::string rh_top_str = FormatTimelineTime(max_time);
std::string lh_bot_str = "(" + std::to_string(curr_step) + ")";
std::string rh_bot_str = "(" + std::to_string(max_step) + ")";
const char* lh_top_label = lh_top_str.c_str();
const char* rh_top_label = rh_top_str.c_str();
const char* lh_bot_label = lh_bot_str.c_str();
const char* rh_bot_label = rh_bot_str.c_str();
// Use slightly smaller text for timeline labels (e.g. 90% scale).
ImGui::SetWindowFontScale(0.9f);
const ImVec2 cursor = ImGui::GetCursorScreenPos();
const float spacing = ImGui::GetStyle().ItemSpacing.x;
const float base_label_w =
std::max(ImGui::CalcTextSize("88.8 ms").x,
ImGui::CalcTextSize("88.8 \xC2\xB5s").x);
const float curr_lh_w =
std::max({base_label_w, ImGui::CalcTextSize(lh_top_label).x,
ImGui::CalcTextSize(lh_bot_label).x});
if (curr_lh_w > tmp_.timeline_lh_width) {
tmp_.timeline_lh_width = curr_lh_w;
}
const float curr_rh_w =
std::max({base_label_w, ImGui::CalcTextSize(rh_top_label).x,
ImGui::CalcTextSize(rh_bot_label).x});
if (curr_rh_w > tmp_.timeline_rh_width) {
tmp_.timeline_rh_width = curr_rh_w;
}
const float lh_box_w = tmp_.timeline_lh_width;
const float rh_box_w = tmp_.timeline_rh_width;
const float track_w =
std::max(10.0f, ImGui::GetContentRegionAvail().x - lh_box_w -
rh_box_w - spacing * 2.0f);
const float spine_h = 3.0f;
const float knob_w = 12.0f;
const float knob_h = 27.0f; // 1.5x taller than 18.0f
const float text_line_h = ImGui::GetTextLineHeight();
const float custom_line_spacing =
1.0f; // Decreased spacing between the two lines
const float text_2lines_h = text_line_h * 2.0f + custom_line_spacing;
const float total_h = std::max(knob_h, text_2lines_h);
// Vertical center of the track row.
const float row_center_y = cursor.y + total_h * 0.5f;
const float track_x0 = cursor.x + lh_box_w + spacing;
const float spine_y0 = row_center_y - spine_h * 0.5f;
const float spine_y1 = row_center_y + spine_h * 0.5f;
const float spine_x0 = track_x0;
const float spine_x1 = track_x0 + track_w;
// Compute knob position [0,1] along the spine.
float t = 1.0f; // Default: pinned to right end.
if (!locked) {
t = static_cast<float>(current_index - hist_min) /
static_cast<float>(hist_max - hist_min);
}
const float knob_cx = spine_x0 + t * (track_w - knob_w) + knob_w * 0.5f;
// Invisible interaction button over the full track area.
ImGui::SetCursorScreenPos(
ImVec2(track_x0, row_center_y - total_h * 0.5f));
ImGui::InvisibleButton("##Scrubber", ImVec2(track_w, total_h));
const bool hovered = ImGui::IsItemHovered();
const bool active = ImGui::IsItemActive();
// Handle dragging.
if (!locked && (active || (hovered && ImGui::IsMouseDown(
ImGuiMouseButton_Left)))) {
const float mouse_x = ImGui::GetIO().MousePos.x;
if (!tmp_.scrubber_active) {
tmp_.scrubber_active = true;
if (std::abs(mouse_x - knob_cx) <= knob_w) {
tmp_.scrubber_grab_offset = mouse_x - knob_cx;
} else {
tmp_.scrubber_grab_offset = 0.0f;
}
}
const float effective_mouse_x = mouse_x - tmp_.scrubber_grab_offset;
const float raw_t = (effective_mouse_x - spine_x0 - knob_w * 0.5f) /
std::max(1.0f, track_w - knob_w);
const float clamped_t = std::clamp(raw_t, 0.0f, 1.0f);
int new_index = current_index;
const float snap_eps = std::max(
0.02f, std::min(0.05f, 8.0f / std::max(1.0f, track_w - knob_w)));
if (clamped_t <= snap_eps ||
effective_mouse_x <= spine_x0 + knob_w * 0.5f + 6.0f) {
new_index = hist_min;
} else if (clamped_t >= 1.0f - snap_eps ||
effective_mouse_x >= spine_x1 - knob_w * 0.5f - 6.0f) {
new_index = hist_max;
} else {
const float inner_t =
(clamped_t - snap_eps) / (1.0f - 2.0f * snap_eps);
new_index =
hist_min +
static_cast<int>(std::round(inner_t * (hist_max - hist_min)));
}
if (new_index != current_index) {
LoadHistory(new_index);
current_index = new_index;
t = static_cast<float>(current_index - hist_min) /
static_cast<float>(hist_max - hist_min);
if (has_data() && current_index == sim_history_.GetIndex()) {
curr_time = data()->time;
if (curr_time < 0.0) curr_time = 0.0;
lh_top_str = FormatTimelineTime(curr_time);
lh_top_label = lh_top_str.c_str();
const int updated_curr_step =
(has_model() && model()->opt.timestep > 0)
? static_cast<int>(
std::round(curr_time / model()->opt.timestep))
: 0;
lh_bot_str = "(" + std::to_string(updated_curr_step) + ")";
lh_bot_label = lh_bot_str.c_str();
const float updated_lh_w =
std::max({base_label_w, ImGui::CalcTextSize(lh_top_label).x,
ImGui::CalcTextSize(lh_bot_label).x});
if (updated_lh_w > tmp_.timeline_lh_width) {
tmp_.timeline_lh_width = updated_lh_w;
}
}
}
} else {
tmp_.scrubber_active = false;
}
// Draw LH labels (two lines, both right-aligned).
const float text_y0 = row_center_y - text_2lines_h * 0.5f;
const float text_y1 = text_y0 + text_line_h + custom_line_spacing;
const float lh_top_x =
cursor.x + lh_box_w - ImGui::CalcTextSize(lh_top_label).x;
ImGui::SetCursorScreenPos(ImVec2(lh_top_x, text_y0));
ImGui::TextUnformatted(lh_top_label);
const float lh_bot_x =
cursor.x + lh_box_w - ImGui::CalcTextSize(lh_bot_label).x;
ImGui::SetCursorScreenPos(ImVec2(lh_bot_x, text_y1));
ImGui::TextUnformatted(lh_bot_label);
// Draw RH labels (two lines, both left-aligned).
const float rh_x0 = track_x0 + track_w + spacing;
ImGui::SetCursorScreenPos(ImVec2(rh_x0, text_y0));
ImGui::TextUnformatted(rh_top_label);
ImGui::SetCursorScreenPos(ImVec2(rh_x0, text_y1));
ImGui::TextUnformatted(rh_bot_label);
// Restore window font scale.
ImGui::SetWindowFontScale(1.0f);
// Draw spine and knob on the draw list.
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImGuiStyle& style = ImGui::GetStyle();
// Spine.
const ImVec4 scrollbar_bg =
ImGui::GetStyle().Colors[ImGuiCol_ScrollbarBg];
ImGuiCol bg_col_idx;
if (active) {
bg_col_idx = ImGuiCol_FrameBgActive;
} else if (hovered) {
bg_col_idx = ImGuiCol_FrameBgHovered;
} else {
bg_col_idx = (scrollbar_bg.w > 0.01f) ? ImGuiCol_ScrollbarBg
: ImGuiCol_FrameBg;
}
const ImU32 spine_col = ImGui::GetColorU32(bg_col_idx);
dl->AddRectFilled(ImVec2(spine_x0, spine_y0),
ImVec2(spine_x1, spine_y1), spine_col,
spine_h * 0.5f);
// Knob rectangle.
const float knob_x0 = knob_cx - knob_w * 0.5f;
const float knob_x1 = knob_cx + knob_w * 0.5f;
const float knob_y0 = row_center_y - knob_h * 0.5f;
const float knob_y1 = row_center_y + knob_h * 0.5f;
ImU32 knob_col;
if (active) {
knob_col = ImGui::GetColorU32(ImGuiCol_SliderGrabActive);
} else if (hovered) {
knob_col = ImGui::GetColorU32(ImGuiCol_SliderGrab);
} else {
knob_col = ImGui::GetColorU32(ImGuiCol_SliderGrab);
}
dl->AddRectFilled(ImVec2(knob_x0, knob_y0), ImVec2(knob_x1, knob_y1),
knob_col, style.GrabRounding);
// Knob border.
dl->AddRect(ImVec2(knob_x0, knob_y0), ImVec2(knob_x1, knob_y1),
ImGui::GetColorU32(ImGuiCol_Border), style.GrabRounding);
// Advance layout cursor past this row.
ImGui::SetCursorScreenPos(ImVec2(cursor.x, cursor.y + total_h));
}
void App::ModelOptionsGui() {
const float min_width = platform::GetExpectedLabelWidth();
const ImGuiChildFlags child_flags =
@@ -1378,235 +1608,8 @@ void App::ModelOptionsGui() {
}
ImGui::SetItemTooltip("%s", "Load next frame from history / Single step");
// Timeline scrubber: spine + sliding knob widget.
{
const double max_time = tmp_.sim_head_time_;
const int hist_size = sim_history_.Size();
const int hist_min = 1 - hist_size; // most negative index (oldest)
const int hist_max = 0; // index 0 = most recent
int current_index = sim_history_.GetIndex();
const bool locked = (hist_size <= 1);
// Compute current timestamp for LH label.
double curr_time =
(has_data() && current_index == sim_history_.GetIndex())
? data()->time
: (max_time +
current_index *
(has_model() ? model()->opt.timestep : 0.002));
if (curr_time < 0.0) curr_time = 0.0;
const int curr_step = (has_model() && model()->opt.timestep > 0)
? static_cast<int>(std::round(
curr_time / model()->opt.timestep))
: 0;
const int max_step =
(has_model() && model()->opt.timestep > 0)
? static_cast<int>(std::round(max_time / model()->opt.timestep))
: 0;
// Both LH and RH labels aim to be at 3 digits, switching units
// independently.
std::string lh_top_str = FormatTimelineTime(curr_time);
std::string rh_top_str = FormatTimelineTime(max_time);
std::string lh_bot_str = "(" + std::to_string(curr_step) + ")";
std::string rh_bot_str = "(" + std::to_string(max_step) + ")";
const char* lh_top_label = lh_top_str.c_str();
const char* rh_top_label = rh_top_str.c_str();
const char* lh_bot_label = lh_bot_str.c_str();
const char* rh_bot_label = rh_bot_str.c_str();
// Use slightly smaller text for timeline labels (e.g. 90% scale).
ImGui::SetWindowFontScale(0.9f);
const ImVec2 cursor = ImGui::GetCursorScreenPos();
const float spacing = ImGui::GetStyle().ItemSpacing.x;
const float base_label_w =
std::max(ImGui::CalcTextSize("88.8 ms").x,
ImGui::CalcTextSize("88.8 \xC2\xB5s").x);
const float curr_lh_w =
std::max({base_label_w, ImGui::CalcTextSize(lh_top_label).x,
ImGui::CalcTextSize(lh_bot_label).x});
if (curr_lh_w > tmp_.timeline_lh_width) {
tmp_.timeline_lh_width = curr_lh_w;
}
const float curr_rh_w =
std::max({base_label_w, ImGui::CalcTextSize(rh_top_label).x,
ImGui::CalcTextSize(rh_bot_label).x});
if (curr_rh_w > tmp_.timeline_rh_width) {
tmp_.timeline_rh_width = curr_rh_w;
}
const float lh_box_w = tmp_.timeline_lh_width;
const float rh_box_w = tmp_.timeline_rh_width;
const float track_w =
std::max(10.0f, ImGui::GetContentRegionAvail().x - lh_box_w -
rh_box_w - spacing * 2.0f);
const float spine_h = 3.0f;
const float knob_w = 12.0f;
const float knob_h = 27.0f; // 1.5x taller than 18.0f
const float text_line_h = ImGui::GetTextLineHeight();
const float custom_line_spacing =
1.0f; // Decreased spacing between the two lines
const float text_2lines_h = text_line_h * 2.0f + custom_line_spacing;
const float total_h = std::max(knob_h, text_2lines_h);
// Vertical center of the track row.
const float row_center_y = cursor.y + total_h * 0.5f;
const float track_x0 = cursor.x + lh_box_w + spacing;
const float spine_y0 = row_center_y - spine_h * 0.5f;
const float spine_y1 = row_center_y + spine_h * 0.5f;
const float spine_x0 = track_x0;
const float spine_x1 = track_x0 + track_w;
// Compute knob position [0,1] along the spine.
float t = 1.0f; // Default: pinned to right end.
if (!locked) {
t = static_cast<float>(current_index - hist_min) /
static_cast<float>(hist_max - hist_min);
}
const float knob_cx = spine_x0 + t * (track_w - knob_w) + knob_w * 0.5f;
// Invisible interaction button over the full track area.
ImGui::SetCursorScreenPos(
ImVec2(track_x0, row_center_y - total_h * 0.5f));
ImGui::InvisibleButton("##TimelineScrubber", ImVec2(track_w, total_h));
const bool hovered = ImGui::IsItemHovered();
const bool active = ImGui::IsItemActive();
// Handle dragging.
if (!locked && (active || (hovered && ImGui::IsMouseDown(
ImGuiMouseButton_Left)))) {
const float mouse_x = ImGui::GetIO().MousePos.x;
if (!tmp_.scrubber_active) {
tmp_.scrubber_active = true;
if (std::abs(mouse_x - knob_cx) <= knob_w) {
tmp_.scrubber_grab_offset = mouse_x - knob_cx;
} else {
tmp_.scrubber_grab_offset = 0.0f;
}
}
const float effective_mouse_x = mouse_x - tmp_.scrubber_grab_offset;
const float raw_t = (effective_mouse_x - spine_x0 - knob_w * 0.5f) /
std::max(1.0f, track_w - knob_w);
const float clamped_t = std::clamp(raw_t, 0.0f, 1.0f);
int new_index = current_index;
const float snap_eps = std::max(
0.02f, std::min(0.05f, 8.0f / std::max(1.0f, track_w - knob_w)));
if (clamped_t <= snap_eps ||
effective_mouse_x <= spine_x0 + knob_w * 0.5f + 6.0f) {
new_index = hist_min;
} else if (clamped_t >= 1.0f - snap_eps ||
effective_mouse_x >= spine_x1 - knob_w * 0.5f - 6.0f) {
new_index = hist_max;
} else {
const float inner_t =
(clamped_t - snap_eps) / (1.0f - 2.0f * snap_eps);
new_index =
hist_min +
static_cast<int>(std::round(inner_t * (hist_max - hist_min)));
}
if (new_index != current_index) {
LoadHistory(new_index);
current_index = new_index;
t = static_cast<float>(current_index - hist_min) /
static_cast<float>(hist_max - hist_min);
if (has_data() && current_index == sim_history_.GetIndex()) {
curr_time = data()->time;
if (curr_time < 0.0) curr_time = 0.0;
lh_top_str = FormatTimelineTime(curr_time);
lh_top_label = lh_top_str.c_str();
const int updated_curr_step =
(has_model() && model()->opt.timestep > 0)
? static_cast<int>(
std::round(curr_time / model()->opt.timestep))
: 0;
lh_bot_str = "(" + std::to_string(updated_curr_step) + ")";
lh_bot_label = lh_bot_str.c_str();
const float updated_lh_w =
std::max({base_label_w, ImGui::CalcTextSize(lh_top_label).x,
ImGui::CalcTextSize(lh_bot_label).x});
if (updated_lh_w > tmp_.timeline_lh_width) {
tmp_.timeline_lh_width = updated_lh_w;
}
}
}
} else {
tmp_.scrubber_active = false;
}
// Draw LH labels (two lines, both right-aligned).
const float text_y0 = row_center_y - text_2lines_h * 0.5f;
const float text_y1 = text_y0 + text_line_h + custom_line_spacing;
const float lh_top_x =
cursor.x + lh_box_w - ImGui::CalcTextSize(lh_top_label).x;
ImGui::SetCursorScreenPos(ImVec2(lh_top_x, text_y0));
ImGui::TextUnformatted(lh_top_label);
const float lh_bot_x =
cursor.x + lh_box_w - ImGui::CalcTextSize(lh_bot_label).x;
ImGui::SetCursorScreenPos(ImVec2(lh_bot_x, text_y1));
ImGui::TextUnformatted(lh_bot_label);
// Draw RH labels (two lines, both left-aligned).
const float rh_x0 = track_x0 + track_w + spacing;
ImGui::SetCursorScreenPos(ImVec2(rh_x0, text_y0));
ImGui::TextUnformatted(rh_top_label);
ImGui::SetCursorScreenPos(ImVec2(rh_x0, text_y1));
ImGui::TextUnformatted(rh_bot_label);
// Restore window font scale.
ImGui::SetWindowFontScale(1.0f);
// Draw spine and knob on the draw list.
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImGuiStyle& style = ImGui::GetStyle();
// Spine.
const ImVec4 scrollbar_bg =
ImGui::GetStyle().Colors[ImGuiCol_ScrollbarBg];
ImGuiCol bg_col_idx;
if (active) {
bg_col_idx = ImGuiCol_FrameBgActive;
} else if (hovered) {
bg_col_idx = ImGuiCol_FrameBgHovered;
} else {
bg_col_idx = (scrollbar_bg.w > 0.01f) ? ImGuiCol_ScrollbarBg
: ImGuiCol_FrameBg;
}
const ImU32 spine_col = ImGui::GetColorU32(bg_col_idx);
dl->AddRectFilled(ImVec2(spine_x0, spine_y0),
ImVec2(spine_x1, spine_y1), spine_col,
spine_h * 0.5f);
// Knob rectangle.
const float knob_x0 = knob_cx - knob_w * 0.5f;
const float knob_x1 = knob_cx + knob_w * 0.5f;
const float knob_y0 = row_center_y - knob_h * 0.5f;
const float knob_y1 = row_center_y + knob_h * 0.5f;
ImU32 knob_col;
if (active) {
knob_col = ImGui::GetColorU32(ImGuiCol_SliderGrabActive);
} else if (hovered) {
knob_col = ImGui::GetColorU32(ImGuiCol_SliderGrab);
} else {
knob_col = ImGui::GetColorU32(ImGuiCol_SliderGrab);
}
dl->AddRectFilled(ImVec2(knob_x0, knob_y0), ImVec2(knob_x1, knob_y1),
knob_col, style.GrabRounding);
// Knob border.
dl->AddRect(ImVec2(knob_x0, knob_y0), ImVec2(knob_x1, knob_y1),
ImGui::GetColorU32(ImGuiCol_Border), style.GrabRounding);
// Advance layout cursor past this row.
ImGui::SetCursorScreenPos(ImVec2(cursor.x, cursor.y + total_h));
}
// Timeline scrubber.
TimelineScrubberGui();
}
// Keyframe controls.
@@ -2202,13 +2205,7 @@ void App::ToolBarGui() {
platform::StepControlGui(&step_control_, tmp_.speed_index);
ImGui::SameLine(0, separator_width);
ImGui::SetNextItemWidth(120);
ImGui::BeginDisabled(std::thread::hardware_concurrency() <= 1);
if (ImGui::SliderInt("##NumThreads", &ui_.nthread, 0, 8, "%d threads")) {
tmp_.update_threadpool = true;
}
ImGui::EndDisabled();
ImGui::SetItemTooltip("%s", "Number of threads in threadpool");
TimelineScrubberGui();
ImGui::TableNextColumn();
+1
View File
@@ -232,6 +232,7 @@ class App {
void MainMenuGui();
void ToolBarGui();
void TimelineScrubberGui();
void StatusBarGui();
void HelpGui();
void FileDialogGui();