Anim: Optional wrapping for timeline navigation

Add the option to make various timeline navitagion operators wrap the
scene/playback range.

The wrapping occurs when:

- jumping 1 frame
- jumping a delta of frames
- jumping between keyframes
- jumping between markers

The wrapping ignores frames/markers outside the playback range.

If the Preview Range is active the wrapping will limit to that range
instead.

This is managed via a playback setting on the Scene.

Pull Request: https://projects.blender.org/blender/blender/pulls/161367
This commit is contained in:
Cyloci 2026-08-11 10:38:05 +02:00 • committed by Sybren A. Stüvel
parent 728c8624b3
commit 675d94bd4b
5 changed files with 159 additions and 9 deletions

View file

@ -91,7 +91,7 @@ def playback_controls(layout, context):
row.operator("screen.time_jump", text="", icon='FRAME_NEXT').backward = False
row.operator("screen.frame_jump", text="", icon='FF').end = True
row.popover(panel="TIME_PT_jump", text="")
row.popover(panel="TIME_PT_playback_options", text="")
if tool_settings:
row = layout.row(align=True)
@ -348,11 +348,11 @@ class TIME_PT_auto_keyframing(TimelinePanelButtons, Panel):
col.prop(tool_settings, "use_record_with_nla", text="Layered Recording")
class TIME_PT_jump(TimelinePanelButtons, Panel):
bl_label = "Time Jump"
class TIME_PT_playback_options(TimelinePanelButtons, Panel):
bl_label = "Playback Options"
bl_options = {'HIDE_HEADER'}
bl_region_type = 'HEADER'
bl_ui_units_x = 10
bl_ui_units_x = 14
def draw(self, context):
layout = self.layout
@ -363,6 +363,8 @@ class TIME_PT_jump(TimelinePanelButtons, Panel):
is_sequencer = st.type == 'SEQUENCE_EDITOR' and st.view_type == 'SEQUENCER'
scene = context.scene if not is_sequencer else context.sequencer_scene
layout.prop(scene, "wrap_timeline_navigation", text="Wrap Timeline Navigation")
layout.separator()
layout.prop(scene, "time_jump_unit", expand=True, text="Jump Unit")
layout.prop(scene, "time_jump_delta", text="Delta")
@ -375,7 +377,7 @@ classes = (
TIME_PT_keyframing,
TIME_PT_keyframing_settings,
TIME_PT_auto_keyframing,
TIME_PT_jump,
TIME_PT_playback_options,
TIME_PT_playhead_snapping,
)

View file

@ -199,6 +199,12 @@ struct ScenePlaybackRange {
/** The start frame is always less or equal the end frame. */
int start_frame;
int end_frame;
/** Check whether a given frame falls within this range (inclusive). */
bool contains(const int frame) const
{
return frame >= start_frame && frame <= end_frame;
}
};
/**

View file

@ -9,6 +9,7 @@
#include <cmath>
#include <cstring>
#include <fmt/format.h>
#include <optional>
#include "MEM_guardedalloc.h"
@ -3860,6 +3861,26 @@ void ED_areas_do_frame_follow(bContext *C, bool center_view)
}
}
/** Wrap a frame value within the playback range using modulo arithmetic.
*
* If the frame is already within the range, it is returned unchanged. Otherwise,
* the frame wraps around (e.g., going past the end jumps to the start).
*/
static int wrap_frame_in_range(const int frame, const ScenePlaybackRange &range)
{
if (range.contains(frame)) {
return frame;
}
const int range_size = range.end_frame - range.start_frame + 1;
const int frames_since_start = frame - range.start_frame;
int offset = frames_since_start % range_size;
if (offset < 0) {
offset += range_size;
}
return range.start_frame + offset;
}
/* function to be called outside UI context, or for redo */
static wmOperatorStatus frame_offset_exec(bContext *C, wmOperator *op)
{
@ -3877,7 +3898,15 @@ static wmOperatorStatus frame_offset_exec(bContext *C, wmOperator *op)
delta += 1;
}
scene->r.cfra += delta;
FRAMENUMBER_MIN_CLAMP(scene->r.cfra);
const bool wrap_timeline_navigation = scene->r.flag & SCER_WRAP_TIMELINE_NAVIGATION;
if (wrap_timeline_navigation) {
const ScenePlaybackRange playback_range = BKE_scene_get_playback_range(scene);
scene->r.cfra = wrap_frame_in_range(scene->r.cfra, playback_range);
}
else {
FRAMENUMBER_MIN_CLAMP(scene->r.cfra);
}
scene->r.subframe = 0.0f;
ED_areas_do_frame_follow(C, false);
@ -4024,7 +4053,14 @@ static wmOperatorStatus frame_jump_delta_exec(bContext *C, wmOperator *op)
scene->r.subframe -= subframe_offset;
}
FRAMENUMBER_MIN_CLAMP(scene->r.cfra);
const bool wrap_timeline_navigation = scene->r.flag & SCER_WRAP_TIMELINE_NAVIGATION;
if (wrap_timeline_navigation) {
const ScenePlaybackRange playback_range = BKE_scene_get_playback_range(scene);
scene->r.cfra = wrap_frame_in_range(scene->r.cfra, playback_range);
}
else {
FRAMENUMBER_MIN_CLAMP(scene->r.cfra);
}
ED_areas_do_frame_follow(C, true);
ed::vse::sync_active_scene_and_time_with_scene_strip(*C);
@ -4132,6 +4168,20 @@ static void keylist_fallback_for_keyframe_jump(bContext &C, Scene *scene, AnimKe
}
}
template<typename KeyColumnIterator>
static bool scene_to_first_key_column_in_range(Scene *scene,
const ScenePlaybackRange playback_range,
KeyColumnIterator key_columns)
{
for (const ActKeyColumn &ak_wrap : key_columns) {
if (playback_range.contains(ak_wrap.cfra)) {
BKE_scene_frame_set(scene, ak_wrap.cfra);
return true;
}
}
return false;
}
/* function to be called outside UI context, or for redo */
static wmOperatorStatus keyframe_jump_exec(bContext *C, wmOperator *op)
{
@ -4166,12 +4216,28 @@ static wmOperatorStatus keyframe_jump_exec(bContext *C, wmOperator *op)
ED_keylist_prepare_for_direct_access(keylist);
const float cfra = BKE_scene_frame_get(scene);
/* find matching keyframe in the right direction */
const bool wrap_timeline_navigation = scene->r.flag & SCER_WRAP_TIMELINE_NAVIGATION;
const ScenePlaybackRange playback_range = BKE_scene_get_playback_range(scene);
/* Find matching keyframe in the right direction. */
const ActKeyColumn *ak;
if (next) {
ak = ED_keylist_find_next(keylist, cfra);
while ((ak != nullptr) && (done == false)) {
if (wrap_timeline_navigation) {
/* Ignore keyframes before playback_range. */
if (ak->cfra < playback_range.start_frame) {
ak = ak->next;
continue;
}
/* No more keyframes within playback_range. */
if (ak->cfra > playback_range.end_frame) {
break;
}
}
if (cfra < ak->cfra) {
BKE_scene_frame_set(scene, ak->cfra);
done = true;
@ -4180,11 +4246,29 @@ static wmOperatorStatus keyframe_jump_exec(bContext *C, wmOperator *op)
ak = ak->next;
}
}
/* Wrap around to the beginning of the frame range. */
if (!done && wrap_timeline_navigation) {
done = scene_to_first_key_column_in_range(
scene, playback_range, *ED_keylist_listbase(keylist));
}
}
else {
ak = ED_keylist_find_prev(keylist, cfra);
while ((ak != nullptr) && (done == false)) {
if (wrap_timeline_navigation) {
/* Ignore keyframes after playback_range. */
if (ak->cfra > playback_range.end_frame) {
ak = ak->prev;
continue;
}
/* No more keyframes within playback_range. */
if (ak->cfra < playback_range.start_frame) {
break;
}
}
if (cfra > ak->cfra) {
BKE_scene_frame_set(scene, ak->cfra);
done = true;
@ -4193,6 +4277,12 @@ static wmOperatorStatus keyframe_jump_exec(bContext *C, wmOperator *op)
ak = ak->prev;
}
}
/* Wrap around to the end of the frame range. */
if (!done && wrap_timeline_navigation) {
done = scene_to_first_key_column_in_range(
scene, playback_range, ED_keylist_listbase(keylist)->items_reversed());
}
}
/* free temp stuff */
@ -4242,6 +4332,18 @@ static void SCREEN_OT_keyframe_jump(wmOperatorType *ot)
/** \name Jump to Marker Operator
* \{ */
template<typename MarkerIterator>
static std::optional<int> get_first_marker_in_range(const ScenePlaybackRange playback_range,
MarkerIterator markers)
{
for (const TimeMarker &marker : markers) {
if (playback_range.contains(marker.frame)) {
return marker.frame;
}
}
return std::nullopt;
}
/* function to be called outside UI context, or for redo */
static wmOperatorStatus marker_jump_exec(bContext *C, wmOperator *op)
{
@ -4250,12 +4352,21 @@ static wmOperatorStatus marker_jump_exec(bContext *C, wmOperator *op)
if (!scene) {
return OPERATOR_CANCELLED;
}
const bool wrap_timeline_navigation = scene->r.flag & SCER_WRAP_TIMELINE_NAVIGATION;
const ScenePlaybackRange playback_range = BKE_scene_get_playback_range(scene);
int closest = scene->r.cfra;
const bool next = RNA_boolean_get(op->ptr, "next");
bool found = false;
/* find matching marker in the right direction */
/* Find matching marker in the right direction. */
for (TimeMarker &marker : scene->markers) {
/* Ignore markers outside of playback_range. */
if (wrap_timeline_navigation && !playback_range.contains(marker.frame)) {
continue;
}
if (next) {
if ((marker.frame > scene->r.cfra) && (!found || closest > marker.frame)) {
closest = marker.frame;
@ -4270,6 +4381,26 @@ static wmOperatorStatus marker_jump_exec(bContext *C, wmOperator *op)
}
}
/* Wrap around playback range and try to look for markers again. */
if (!found && wrap_timeline_navigation) {
if (next) {
if (const std::optional<int> frame = get_first_marker_in_range(playback_range,
scene->markers))
{
closest = *frame;
found = true;
}
}
else {
if (const std::optional<int> frame = get_first_marker_in_range(
playback_range, scene->markers.items_reversed()))
{
closest = *frame;
found = true;
}
}
}
/* any success? */
if (!found) {
BKE_report(op->reports, RPT_INFO, "No more markers to jump to in this direction");

View file

@ -714,6 +714,8 @@ enum eRender_Flag : short {
SCER_ALLOW_PREROLL = 1 << 2,
/** Show/use sub-frames (for checking motion blur). */
SCER_SHOW_SUBFRAME = 1 << 3,
/** Wrap the playhead within the playback range while navigating timeline. */
SCER_WRAP_TIMELINE_NAVIGATION = 1 << 4,
};
ENUM_OPERATORS(eRender_Flag)

View file

@ -9115,6 +9115,15 @@ void RNA_def_scene(BlenderRNA *brna)
prop, "Only Show Selected", "Only include channels relating to selected objects and data");
RNA_def_property_update(prop, NC_SCENE | ND_FRAME, nullptr);
prop = RNA_def_property(srna, "wrap_timeline_navigation", PROP_BOOLEAN, PROP_NONE);
RNA_def_property_clear_flag(prop, PROP_ANIMATABLE);
RNA_def_property_boolean_sdna(prop, nullptr, "r.flag", SCER_WRAP_TIMELINE_NAVIGATION);
RNA_def_property_ui_text(
prop,
"Wrap Timeline",
"Wrap the playhead around the playback range when navigating the timeline");
RNA_def_property_update(prop, NC_SCENE | ND_FRAME, nullptr);
/* Stamp */
prop = RNA_def_property(srna, "use_stamp_note", PROP_STRING, PROP_NONE);
RNA_def_property_string_sdna(prop, nullptr, "r.stamp_udata");