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;
};