From 9ec9761dd7bc34808494a9b8862e2d17d5d192df Mon Sep 17 00:00:00 2001 From: indigo Date: Sat, 13 Jun 2026 13:36:07 +0800 Subject: [PATCH] =?UTF-8?q?Timeline=20playback=20controls=20=E2=80=94=20tr?= =?UTF-8?q?ansport=20icons,=20loop/bounce/rewind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace all text buttons with SVG icon buttons (skip-back, step-back, play-back, play, pause, step-forward, skip-end) - Add rewind: separate play-backward button that flips direction - Add loop mode button with submenu (Loop / Bounce / Off) - Loop wraps at boundaries; Bounce reflects direction (ping-pong) - Start/end frames moved to flank the scrub slider as plain numbers - New icons: play-back.svg, loop.svg (FA repeat), bounce.svg (FA exchange-alt) Co-Authored-By: Claude Sonnet 4.6 --- resources/icons/bounce.svg | 1 + resources/icons/loop.svg | 1 + resources/icons/play-back.svg | 3 + src/ui/IconManager.cpp | 6 +- src/ui/IconManager.h | 3 + src/ui/TimelinePanel.cpp | 233 +++++++++++++++++++++++++++------- src/ui/TimelinePanel.h | 19 ++- 7 files changed, 215 insertions(+), 51 deletions(-) create mode 100644 resources/icons/bounce.svg create mode 100644 resources/icons/loop.svg create mode 100644 resources/icons/play-back.svg diff --git a/resources/icons/bounce.svg b/resources/icons/bounce.svg new file mode 100644 index 0000000..16c2483 --- /dev/null +++ b/resources/icons/bounce.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/loop.svg b/resources/icons/loop.svg new file mode 100644 index 0000000..1792d6d --- /dev/null +++ b/resources/icons/loop.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/play-back.svg b/resources/icons/play-back.svg new file mode 100644 index 0000000..89c127e --- /dev/null +++ b/resources/icons/play-back.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/IconManager.cpp b/src/ui/IconManager.cpp index 50f4dd0..c8b9c62 100644 --- a/src/ui/IconManager.cpp +++ b/src/ui/IconManager.cpp @@ -50,10 +50,13 @@ static const char* IconFilename(Icon icon) { case Icon::LayoutQuad: return "layout-quad.svg"; case Icon::SkipBack: return "skip-back.svg"; case Icon::StepBack: return "step-back.svg"; + case Icon::PlayBack: return "play-back.svg"; case Icon::Play: return "play.svg"; case Icon::Pause: return "pause.svg"; case Icon::StepForward: return "step-forward.svg"; case Icon::SkipEnd: return "skip-end.svg"; + case Icon::Loop: return "loop.svg"; + case Icon::Bounce: return "bounce.svg"; default: return nullptr; } } @@ -67,7 +70,8 @@ static constexpr Icon kAllIcons[] = { Icon::ToolSelect, Icon::ToolMove, Icon::ToolRotate, Icon::ToolScale, Icon::WorldSpace, Icon::LocalSpace, Icon::Grid, Icon::Antialias, Icon::LayoutSingle, Icon::LayoutHSplit, Icon::LayoutVSplit, Icon::LayoutQuad, - Icon::SkipBack, Icon::StepBack, Icon::Play, Icon::Pause, Icon::StepForward, Icon::SkipEnd, + Icon::SkipBack, Icon::StepBack, Icon::PlayBack, Icon::Play, Icon::Pause, + Icon::StepForward, Icon::SkipEnd, Icon::Loop, Icon::Bounce, }; // --------------------------------------------------------------------------- diff --git a/src/ui/IconManager.h b/src/ui/IconManager.h index 1a12fc5..1de2595 100644 --- a/src/ui/IconManager.h +++ b/src/ui/IconManager.h @@ -43,10 +43,13 @@ enum class Icon { // Timeline transport SkipBack, // go to first frame (|◀) StepBack, // previous frame (◀◀) + PlayBack, // play in reverse (◀) Play, // play (▶) Pause, // pause (⏸) StepForward, // next frame (▶▶) SkipEnd, // go to last frame (▶|) + Loop, // loop toggle (↻) + Bounce, // bounce/ping-pong (↔) }; /// Loads SVG files from disk, rasterizes them with NanoSVG, uploads them as diff --git a/src/ui/TimelinePanel.cpp b/src/ui/TimelinePanel.cpp index a6d703b..5e7ba4c 100644 --- a/src/ui/TimelinePanel.cpp +++ b/src/ui/TimelinePanel.cpp @@ -13,6 +13,7 @@ void TimelinePanel::SetStage(pxr::UsdStageRefPtr stage) { m_stage = stage; m_playing = false; + m_reversed = false; m_startFrame = 1.0; m_endFrame = 100.0; @@ -27,8 +28,6 @@ void TimelinePanel::SetStage(pxr::UsdStageRefPtr stage) } if (m_endFrame < m_startFrame) m_endFrame = m_startFrame; - // Always notify on stage change so consumers pick up the new range, - // even when the numeric frame happens to be unchanged. m_currentFrame = m_startFrame; NotifyTimeChanged(); } @@ -37,20 +36,61 @@ void TimelinePanel::SetStage(pxr::UsdStageRefPtr stage) void TimelinePanel::Update(float deltaTime) { if (!m_playing) return; - double next = m_currentFrame + double(deltaTime) * m_fps; - if (next > m_endFrame) { - double span = m_endFrame - m_startFrame; - next = (span > 0.0) - ? m_startFrame + std::fmod(next - m_startFrame, span) - : m_startFrame; + + double playStart = m_startFrame; + double playEnd = m_endFrame; + double sign = m_reversed ? -1.0 : 1.0; + double next = m_currentFrame + sign * double(deltaTime) * m_fps; + + if (m_reversed && next < playStart) { + switch (m_loopMode) { + case LoopMode::Loop: { + double span = playEnd - playStart; + next = (span > 0.0) + ? playEnd - std::fmod(playStart - next, span) + : playEnd; + break; + } + case LoopMode::Bounce: + next = playStart + (playStart - next); // reflect + m_reversed = false; + break; + default: + next = playStart; + m_playing = false; + break; + } + } else if (!m_reversed && next > playEnd) { + switch (m_loopMode) { + case LoopMode::Loop: { + double span = playEnd - playStart; + next = (span > 0.0) + ? playStart + std::fmod(next - playStart, span) + : playStart; + break; + } + case LoopMode::Bounce: + next = playEnd - (next - playEnd); // reflect + m_reversed = true; + break; + default: + next = playEnd; + m_playing = false; + break; + } } + SetCurrentFrame(next); } // --------------------------------------------------------------------------- void TimelinePanel::Render() { - // ---- Transport row: icon buttons | start/end range | fps | auto-key ---- + // Shared colours for active (pressed-in) toggle buttons. + const ImVec4 kActive (0.26f, 0.59f, 0.98f, 1.00f); + const ImVec4 kActiveHv(0.36f, 0.69f, 1.00f, 1.00f); + + // Plain icon button (no active-state colouring). auto iconBtn = [&](const char* id, Icon icon, const char* fallback) -> bool { if (m_iconManager) { ImTextureID tex = m_iconManager->Get(icon); @@ -59,51 +99,150 @@ void TimelinePanel::Render() return ImGui::Button(fallback); }; - if (iconBtn("##skipback", Icon::SkipBack, "|<")) { m_playing = false; SetCurrentFrame(m_startFrame); } - ImGui::SameLine(); - if (iconBtn("##stepback", Icon::StepBack, "<")) { m_playing = false; SetCurrentFrame(std::floor(m_currentFrame) - 1.0); } - ImGui::SameLine(); - if (iconBtn("##playpause", m_playing ? Icon::Pause : Icon::Play, m_playing ? "Pause" : "Play")) m_playing = !m_playing; - ImGui::SameLine(); - if (iconBtn("##stepfwd", Icon::StepForward, ">")) { m_playing = false; SetCurrentFrame(std::floor(m_currentFrame) + 1.0); } - ImGui::SameLine(); - if (iconBtn("##skipend", Icon::SkipEnd, ">|")) { m_playing = false; SetCurrentFrame(m_endFrame); } - - ImGui::SameLine(); - int start = int(std::lround(m_startFrame)); - int end = int(std::lround(m_endFrame)); - ImGui::SetNextItemWidth(90.f); - bool rangeEdited = ImGui::DragInt("##start", &start, 1.0f, 0, 0, "Start %d"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.f); - rangeEdited |= ImGui::DragInt("##end", &end, 1.0f, 0, 0, "End %d"); - if (rangeEdited) { - if (end < start) end = start; - m_startFrame = double(start); - m_endFrame = double(end); - if (m_stage) { - // Stage time metadata must live on the root (or session) layer; - // the active edit target may be a sublayer. - pxr::UsdEditContext ec(m_stage, m_stage->GetRootLayer()); - m_stage->SetStartTimeCode(m_startFrame); - m_stage->SetEndTimeCode(m_endFrame); + // Icon button that highlights blue when `active` is true. + auto iconToggle = [&](const char* id, Icon icon, const char* fallback, + bool active) -> bool { + if (active) { + ImGui::PushStyleColor(ImGuiCol_Button, kActive); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kActiveHv); } - SetCurrentFrame(m_currentFrame); // re-clamp into the new range - } + bool clicked = iconBtn(id, icon, fallback); + if (active) ImGui::PopStyleColor(2); + return clicked; + }; + // ---- Transport buttons ---- + if (iconBtn("##skipback", Icon::SkipBack, "|<")) { + m_playing = false; + m_reversed = false; + SetCurrentFrame(m_startFrame); + } + ImGui::SameLine(); + + if (iconBtn("##stepback", Icon::StepBack, "<")) { + m_playing = false; + SetCurrentFrame(std::floor(m_currentFrame) - 1.0); + } + ImGui::SameLine(); + + // Play backward (rewind): clicking starts reverse play; clicking again pauses. + { + bool isPlayingBack = m_playing && m_reversed; + if (iconToggle("##playback", + isPlayingBack ? Icon::Pause : Icon::PlayBack, + isPlayingBack ? "||" : "<|", + isPlayingBack)) { + if (isPlayingBack) { + m_playing = false; + } else { + m_reversed = true; + m_playing = true; + } + } + } + ImGui::SameLine(); + + // Play forward: clicking starts forward play; clicking again pauses. + { + bool isPlayingFwd = m_playing && !m_reversed; + if (iconToggle("##playfwd", + isPlayingFwd ? Icon::Pause : Icon::Play, + isPlayingFwd ? "||" : "|>", + isPlayingFwd)) { + if (isPlayingFwd) { + m_playing = false; + } else { + m_reversed = false; + m_playing = true; + } + } + } + ImGui::SameLine(); + + if (iconBtn("##stepfwd", Icon::StepForward, ">")) { + m_playing = false; + SetCurrentFrame(std::floor(m_currentFrame) + 1.0); + } + ImGui::SameLine(); + + if (iconBtn("##skipend", Icon::SkipEnd, ">|")) { + m_playing = false; + m_reversed = false; + SetCurrentFrame(m_endFrame); + } + ImGui::SameLine(); + + // ---- Loop mode button — click to open Loop / Bounce / Off submenu ---- + ImGui::TextDisabled("|"); + ImGui::SameLine(); + { + bool isActive = (m_loopMode != LoopMode::None); + Icon modeIcon = (m_loopMode == LoopMode::Bounce) ? Icon::Bounce : Icon::Loop; + const char* tip = (m_loopMode == LoopMode::Bounce) ? "Bounce (click to change)" + : (m_loopMode == LoopMode::Loop) ? "Loop (click to change)" + : "No loop (click to change)"; + if (iconToggle("##loopModeBtn", modeIcon, "Loop", isActive)) + ImGui::OpenPopup("##loopModePopup"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); + + if (ImGui::BeginPopup("##loopModePopup")) { + if (ImGui::Selectable("Loop", m_loopMode == LoopMode::Loop)) + m_loopMode = LoopMode::Loop; + if (ImGui::Selectable("Bounce", m_loopMode == LoopMode::Bounce)) + m_loopMode = LoopMode::Bounce; + ImGui::Separator(); + if (ImGui::Selectable("Off", m_loopMode == LoopMode::None)) + m_loopMode = LoopMode::None; + ImGui::EndPopup(); + } + } + ImGui::SameLine(); + + // ---- FPS + Auto-Key ---- + ImGui::TextDisabled("|"); ImGui::SameLine(); ImGui::Text("%.6g fps", m_fps); - ImGui::SameLine(); if (ImGui::Checkbox("Auto-Key", &m_autoKey)) NotifyTimeChanged(); - // ---- Frame slider: full width, snaps to whole frames ---- - float frame = float(m_currentFrame); - ImGui::SetNextItemWidth(-FLT_MIN); - if (ImGui::SliderFloat("##frame", &frame, - float(m_startFrame), float(m_endFrame), "%.0f")) { - m_playing = false; - SetCurrentFrame(std::round(double(frame))); + // ---- Slider row: [start] [----slider----] [end] ---- + { + const float kNumWidth = 50.f; + const float kGap = 4.f; + + int start = int(std::lround(m_startFrame)); + int end = int(std::lround(m_endFrame)); + + ImGui::SetNextItemWidth(kNumWidth); + bool rangeEdited = ImGui::DragInt("##start", &start, 1.0f, 0, 0, "%d"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Start frame"); + ImGui::SameLine(0.f, kGap); + + float sliderWidth = ImGui::GetContentRegionAvail().x - kNumWidth - kGap; + float frame = float(m_currentFrame); + ImGui::SetNextItemWidth(sliderWidth); + if (ImGui::SliderFloat("##frame", &frame, + float(m_startFrame), float(m_endFrame), "%.0f")) { + m_playing = false; + SetCurrentFrame(std::round(double(frame))); + } + ImGui::SameLine(0.f, kGap); + + ImGui::SetNextItemWidth(kNumWidth); + rangeEdited |= ImGui::DragInt("##end", &end, 1.0f, 0, 0, "%d"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("End frame"); + + if (rangeEdited) { + if (end < start) end = start; + m_startFrame = double(start); + m_endFrame = double(end); + if (m_stage) { + pxr::UsdEditContext ec(m_stage, m_stage->GetRootLayer()); + m_stage->SetStartTimeCode(m_startFrame); + m_stage->SetEndTimeCode(m_endFrame); + } + SetCurrentFrame(m_currentFrame); + } } } diff --git a/src/ui/TimelinePanel.h b/src/ui/TimelinePanel.h index 2a8c6b2..8b0a8d4 100644 --- a/src/ui/TimelinePanel.h +++ b/src/ui/TimelinePanel.h @@ -10,10 +10,14 @@ namespace UsdLayerManager { /// Bottom-docked playback timeline. /// /// Owns the current frame, playback state, and the Auto-Key toggle, and fires -/// OnTimeChanged whenever either changes: +/// OnTimeChanged whenever the time changes: /// displayTime — the current frame; used for all reads/evaluation /// editTime — the current frame when Auto-Key is ON, otherwise Default() -/// (writes at editTime author time samples = keyframes) +/// +/// Supports: +/// Loop — playback wraps from end back to start (or vice-versa). +/// Bounce — playback reverses direction at each boundary (ping-pong). +/// Rewind — reverse-direction playback (m_reversed). class TimelinePanel { public: void SetStage(pxr::UsdStageRefPtr stage); @@ -27,7 +31,7 @@ public: pxr::UsdTimeCode editTime)> OnTimeChanged; private: - /// Clamp to [start, end]; fires OnTimeChanged when the frame changes. + /// Clamp to [startFrame, endFrame]; fires OnTimeChanged when the frame changes. void SetCurrentFrame(double frame); void NotifyTimeChanged(); pxr::UsdTimeCode EditTime() const; @@ -35,11 +39,20 @@ private: IconManager* m_iconManager = nullptr; pxr::UsdStageRefPtr m_stage; + // Stage range (written back to stage metadata on edit). double m_startFrame = 1.0; double m_endFrame = 100.0; double m_fps = 24.0; + + // Playback state. double m_currentFrame = 1.0; bool m_playing = false; + enum class LoopMode { None, Loop, Bounce }; + + bool m_reversed = false; // play backward when true + LoopMode m_loopMode = LoopMode::Loop; // boundary behaviour + + // Auto-Key. bool m_autoKey = false; };