Editor/RigControlEditor/RigTimeline.cs
using Editor;
using Marionette;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Marionette.Tools;
/// <summary>
/// One row per keyed bone, a diamond per keyframe (drag to move, Delete to remove, click to jump
/// the playhead there) - matching the reference video exactly rather than the frame-range event
/// bars this used to draw (AnimEvents live in their own tab now, not on this timeline). Frame-to-
/// pixel geometry and drag semantics are the same shape as AnimGraph's own ClipEventLayout/Lanes/
/// Ruler (editor/AnimGraph/Code/Editors), re-keyed off BoneTrack/BoneKeyframe instead.
/// </summary>
internal sealed class RigTimeline : Widget
{
private readonly RigTimelineLanes _lanes;
private readonly RigTimelineRuler _ruler;
private readonly RigTimelineRuler _frameRuler;
private readonly ScrollArea _scroll;
private readonly RigBoneColumn _boneColumn;
private readonly Editor.Label _timeLabel;
private readonly Button _playButton;
private readonly Button _loopButton;
private RigAnimDocument _anim;
private RealTimeSince _lastStep;
private bool _playing;
private bool _looping = true;
public Action<float> Scrubbed { get; set; }
public Action Edited { get; set; }
/// <summary>Raised by the transport's key button - the window owns what "key the selected
/// bone" means, since it's the thing holding both the document and the viewport.</summary>
public Action KeyRequested { get; set; }
/// <summary>Raised when a bone's row is clicked in the timeline gutter, so viewport selection
/// and timeline selection stay the same thing. Forwarded to the lanes, which own the hit
/// testing and the painting.</summary>
public Action<string> BoneRowSelected
{
get => _lanes.BoneRowSelected;
set => _lanes.BoneRowSelected = value;
}
public string SelectedBone
{
get => _lanes.SelectedBone;
set
{
_lanes.SelectedBone = value;
// The name column marks the selected row too, and it's a separate widget now - so it
// has to be told, and told to repaint.
if ( _boneColumn is not null )
{
_boneColumn.SelectedBone = value;
_boneColumn.Update();
}
}
}
public float Playhead
{
get => _lanes.Playhead;
set
{
value = value.Clamp( 0f, LastFrame );
if ( _lanes.Playhead == value )
return;
_lanes.Playhead = value;
_ruler.Playhead = value;
_frameRuler.Playhead = value;
_frameRuler.Update();
_lanes.Update();
_ruler.Update();
UpdateTimeLabel();
}
}
/// <summary>
/// Thirty seconds at 30fps - the shortest the timeline is ever allowed to be.
///
/// THE TIMELINE IS A CANVAS, NOT A MEASUREMENT OF THE CLIP. It used to end exactly at the
/// document's FrameCount, so a clip authored at the old 30-frame default gave you one second
/// of timeline and no way to work past it without going and finding a number field first.
/// Being told "raise Frame Count" is a worse answer than just having room.
/// </summary>
private const int MinimumTimelineFrames = 900;
/// <summary>The last frame the timeline goes to - the clip's own length, or thirty seconds,
/// whichever is longer. Everything that positions, scrolls, zooms or clamps reads this.</summary>
private float LastFrame => MathF.Max( MathF.Max( _anim?.FrameCount ?? 1, MinimumTimelineFrames ) - 1, 1f );
/// <summary>
/// Where playback stops - the last keyframe in the clip, not the end of the timeline.
///
/// THE CANVAS IS NOT THE ANIMATION. Since the timeline is always at least thirty seconds, a
/// two second clip played on it would run twenty-eight seconds of nothing before stopping, and
/// loop with a twenty-eight second gap. Playing to the last key means what you watch is what
/// you made, at any canvas length.
///
/// Falls back to the whole timeline when there are no keys at all, so pressing play on an
/// empty clip still does something rather than sitting on frame zero looking broken.
/// </summary>
private float PlaybackLastFrame
{
get
{
var last = 0f;
if ( _anim?.BoneTracks is { } tracks )
{
foreach ( var track in tracks )
{
foreach ( var key in track.Keyframes )
last = MathF.Max( last, key.Frame );
}
}
return last > 0f ? last : LastFrame;
}
}
private float FrameRate => _anim?.AnimationSpeed > 0 ? _anim.AnimationSpeed : 30f;
public RigTimeline( Widget parent ) : base( parent )
{
Name = "Timeline";
WindowTitle = "Timeline";
SetWindowIcon( "view_timeline" );
Layout = Layout.Column();
_lanes = new RigTimelineLanes( this )
{
Scrubbed = frame => ScrubTo( frame ),
Edited = () => Edited?.Invoke(),
};
_ruler = new RigTimelineRuler( this, _lanes ) { Scrubbed = ScrubTo, ShowTimecode = true };
Layout.Add( _ruler );
// THE REAL SCROLLBAR. On, not Off - the lanes canvas is now as wide as the whole clip
// instead of the viewport, so the ScrollArea has something genuine to scroll and gives us
// its own bar with a proportional handle. That handle doubles as a readout of how much
// clip there is either side of what you can see, which a slider can never show.
// The names sit BESIDE the scroll area, not inside it, so the area - and its scrollbar -
// starts where the tracks start. See RigBoneColumn.
var trackRow = Layout.AddRow();
_boneColumn = trackRow.Add( new RigBoneColumn( this )
{
BoneSelected = bone => BoneRowSelected?.Invoke( bone ),
ContextMenuRequested = track => _lanes.OpenBoneRowMenuFor( track ),
} );
_scroll = trackRow.Add( new ScrollArea( this ), 1 );
_scroll.VerticalScrollbarMode = ScrollbarMode.Auto;
_scroll.HorizontalScrollbarMode = ScrollbarMode.Auto;
_scroll.Canvas = _lanes;
// Pulled, not pushed - the ScrollArea raises nothing to hook, and reading it costs nothing.
_boneColumn.ScrollY = () => _scroll?.VerticalScrollbar?.Value ?? 0f;
// Frame numbers below the tracks, timecode above - the tracks sit between the two units
// so neither has to be converted in your head.
_frameRuler = new RigTimelineRuler( this, _lanes ) { Scrubbed = ScrubTo, ShowTimecode = false };
Layout.Add( _frameRuler );
// ONE view range, three widgets. Each holds the same object rather than its own copy, so
// zooming anywhere moves all of them together - a ruler that scrolls independently of the
// lanes it labels is worse than no ruler at all.
_ruler.View = _lanes.View;
_frameRuler.View = _lanes.View;
_lanes.ViewChanged = RefreshView;
_ruler.ViewChanged = RefreshView;
_frameRuler.ViewChanged = RefreshView;
// All three ask; one place answers. Zoom and shift-wheel from any of them move the same
// scrollbar, which is the only thing that decides where the view actually sits.
_lanes.ScrollRequested = ScrollTo;
_ruler.ScrollRequested = ScrollTo;
_frameRuler.ScrollRequested = ScrollTo;
_lanes.ScrollOffset = CurrentScrollX;
// EVERY transport control lives here, with the timeline it drives - not split between here
// and the window toolbar. Scrubbing, stepping and playing are all things you do while
// looking at the timeline; making you travel to the top of the window for half of them and
// back down for the rest is the kind of split that never stops being mildly annoying.
var transport = Layout.AddRow();
transport.Margin = new Sandbox.UI.Margin( 8, 4 );
transport.Spacing = 4;
// LABELLED AND TINTED, because it is not a transport control.
//
// Everything else in this row moves you through time and changes nothing. This one edits
// the clip - it's the most-used action in the tool and the only irreversible-feeling one
// here - and as another unlabelled icon in a row of six it was indistinguishable from the
// buttons that just move the playhead.
//
// The yellow matches the colour keyframes are drawn in on the timeline, so the button and
// the thing it makes are visibly the same idea. Diamond because that's a keyframe in every
// animation tool there is; it was "fiber_manual_record" before, which renders as an
// anonymous filled circle and reads as "record" at best.
transport.Add( new RigIconButton( this, RigIcon.AddKeyframe, Theme.Yellow, "",
"Key the selected bone at the playhead, using its current pose (K)",
() => KeyRequested?.Invoke() ) );
transport.AddSpacingCell( 12f );
transport.Add( new RigStatusBar.HintButton( "skip_previous", "Jump to the first frame", () => ScrubTo( 0f ) ) );
transport.Add( new RigStatusBar.HintButton( "navigate_before", "Step back one frame", () => StepFrame( -1 ) ) );
_playButton = transport.Add( new RigStatusBar.HintButton( "play_arrow", "Play or pause the clip", TogglePlay ) );
transport.Add( new RigStatusBar.HintButton( "navigate_next", "Step forward one frame", () => StepFrame( 1 ) ) );
transport.Add( new RigStatusBar.HintButton( "skip_next", "Jump to the last frame", () => ScrubTo( LastFrame ) ) );
_loopButton = transport.Add( new RigStatusBar.HintButton( "loop", "Loop playback when it reaches the end", ToggleLoop ) );
transport.AddSpacingCell( 8f );
_timeLabel = transport.Add( new Editor.Label( "0:00 / 0:00" ) );
transport.AddStretchCell();
// Preview speed, not clip speed - see PlaybackSpeed.
_speedButton = transport.Add( new Button( "x1", "speed" ) { Clicked = OpenSpeedMenu, ToolTip = "Preview speed - does not change the clip" } );
_fpsButton = transport.Add( new Button( "30 FPS", "schedule" ) { Clicked = OpenFpsMenu, ToolTip = "Frame rate of the clip itself - this DOES change playback in game" } );
UpdateLoopButton();
}
private Button _speedButton;
private Button _fpsButton;
/// <summary>How fast playback runs relative to the clip's own frame rate.
///
/// A REVIEW CONTROL, NOT A CLIP PROPERTY. It never touches the document - a clip previewed at
/// 0.25x still plays at full speed in game. Timing mistakes that are invisible at full speed
/// are obvious at a quarter, which is most of why this exists.</summary>
public float PlaybackSpeed { get; set; } = 1f;
private void StepFrame( int delta ) => ScrubTo( (Playhead + delta).Clamp( 0f, LastFrame ) );
private void OpenSpeedMenu()
{
var menu = new Menu( this );
foreach ( var speed in new[] { 0.1f, 0.25f, 0.5f, 1f, 2f } )
{
var option = menu.AddOption( $"x{speed:0.##}", null, () =>
{
PlaybackSpeed = speed;
_speedButton.Text = $"x{speed:0.##}";
} );
option.Checkable = true;
option.Checked = PlaybackSpeed.AlmostEqual( speed );
}
menu.OpenAtCursor();
}
private void OpenFpsMenu()
{
if ( _anim is null )
return;
var menu = new Menu( this );
foreach ( var fps in new[] { 10, 12, 24, 30, 60 } )
{
var option = menu.AddOption( $"{fps} FPS", null, () =>
{
_anim.AnimationSpeed = fps;
Refresh();
Edited?.Invoke();
} );
option.Checkable = true;
option.Checked = _anim.AnimationSpeed == fps;
}
menu.OpenAtCursor();
}
public void SetAsset( RigAnimDocument anim )
{
_anim = anim;
_lanes.Anim = anim;
Playhead = 0f;
// Back to the start of the clip at the standard zoom. Carrying the previous clip's scroll
// position over would open a short clip already scrolled past its own end.
_lanes.View.PixelsPerFrame = RigTimelineLayout.DefaultPixelsPerFrame;
_lanes.View.ScrollX = 0f;
if ( _scroll?.HorizontalScrollbar is { } bar )
bar.Value = 0;
Refresh();
}
/// <summary>Repaint everything that draws against the view window, without touching the
/// document - zooming changes what you see, never what's stored.</summary>
private void RefreshView()
{
_lanes.Update();
_ruler.Update();
_frameRuler.Update();
SyncScrollBar();
}
/// <summary>
/// Points the scrollbar at the current view window.
///
/// Its range is how far the window can travel - clip length minus what's visible - so it
/// shrinks to nothing as you zoom out and the handle covers the whole bar when the clip
/// already fits. Disabled rather than hidden in that case: a control that vanishes reads as
/// broken, and one that's greyed out says "there is nothing to scroll to", which is the
/// truth.
///
/// Driven from RefreshView, so wheel-panning moves the bar and dragging the bar moves the
/// view. One view range behind both, same as the rulers.
/// </summary>
/// <summary>Moves the ScrollArea, clamped to what it can actually reach. Sizing the canvas
/// first matters: a zoom-out that shrinks the canvas has to shrink the scroll range before the
/// new offset is clamped against it, or the view sticks past the end of a clip that just got
/// narrower.</summary>
private void ScrollTo( float x )
{
SyncScrollBar();
if ( _scroll?.HorizontalScrollbar is not { } bar )
return;
bar.Value = (int)MathF.Max( x, 0f );
_lanes.View.ScrollX = bar.Value;
RefreshView();
}
/// <summary>The one place that knows where the horizontal scroll actually is. Everything else
/// reads it from here rather than reaching into the ScrollArea, so there is a single answer.</summary>
private float CurrentScrollX() =>
_scroll?.HorizontalScrollbar is { } bar ? bar.Value : 0f;
private void SyncScrollBar()
{
if ( _scroll is null || !_lanes.IsValid() )
return;
var layout = new RigTimelineLayout( 0f, _anim?.FrameCount ?? 1, 0, 0f, _lanes.View.PixelsPerFrame );
// SIZING THE CANVAS IS WHAT CREATES THE SCROLLBAR. A ScrollArea scrolls because its canvas
// is bigger than its viewport and for no other reason, so the whole feature comes down to
// this one assignment - the bar, its handle size and its range all follow from it.
_lanes.MinimumWidth = layout.CanvasWidth;
// Published for the rulers, which live outside the ScrollArea and are therefore never
// moved by it. Read once here rather than by each of them, so all three widgets are
// guaranteed to be drawing the same frame of scroll.
_lanes.View.ScrollX = CurrentScrollX();
}
public void Refresh()
{
if ( _boneColumn is not null )
{
_boneColumn.Anim = _anim;
_boneColumn.Update();
}
_lanes.MinimumHeight = RigTimelineLayout.HeightFor( _anim?.BoneTracks.Count ?? 0 );
_lanes.Update();
_ruler.FrameCount = (int)LastFrame + 1;
_ruler.Fps = _anim?.AnimationSpeed ?? 30;
_ruler.Update();
_frameRuler.FrameCount = _ruler.FrameCount;
_frameRuler.Fps = _ruler.Fps;
_frameRuler.Update();
// Clip length can change under us (the Frame Count field), which changes how far there is
// to scroll.
SyncScrollBar();
if ( _fpsButton is not null )
_fpsButton.Text = $"{_ruler.Fps} FPS";
UpdateTimeLabel();
}
private void ScrubTo( float frame )
{
Playhead = frame;
Scrubbed?.Invoke( frame );
}
public bool IsPlaying => _playing;
public bool Looping => _looping;
public bool HasSelectedKeyframe => _lanes.HasSelectedKeyframe;
public void DeleteSelectedKeyframe() => _lanes.DeleteSelectedKeyframe();
public void SetPlaying( bool playing )
{
_playing = playing;
_playButton.Icon = _playing ? "pause" : "play_arrow";
_lastStep = 0;
}
public void Stop()
{
SetPlaying( false );
Playhead = 0f;
}
public void SetLooping( bool looping )
{
_looping = looping;
UpdateLoopButton();
}
private void TogglePlay() => SetPlaying( !_playing );
private void ToggleLoop() => SetLooping( !_looping );
private void UpdateLoopButton()
{
_loopButton.Icon = _looping ? "loop" : "trending_flat";
_loopButton.ToolTip = _looping ? "Looping - click to play once" : "Plays once - click to loop";
}
// [EditorEvent.Frame] appears to keep calling Frame() on a closed/replaced RigTimeline
// instance - "Error calling event 'tool.frame'" fired repeatedly with a NullReferenceException
// underneath, consistent with touching a native widget (_playButton) whose backing Qt object
// is already gone. IsValid guards the real cause if this build exposes it; the try/catch is
// the unconditional backstop either way, since a dead timer tick should never spam the
// console or risk cascading into other systems the way this one clearly was.
[EditorEvent.Frame]
public void Frame()
{
if ( !IsValid )
return;
try
{
if ( !_playing )
return;
var elapsed = (float)_lastStep;
if ( elapsed < 1f / (FrameRate * MathF.Max( PlaybackSpeed, 0.01f )) )
return;
_lastStep = 0;
var next = Playhead + 1f;
if ( next > PlaybackLastFrame )
{
if ( !_looping )
{
_playing = false;
_playButton.Icon = "play_arrow";
return;
}
next = 0f;
}
ScrubTo( next );
}
catch ( Exception )
{
_playing = false;
}
}
private void UpdateTimeLabel()
{
var current = TimeSpan.FromSeconds( Playhead / FrameRate );
var total = TimeSpan.FromSeconds( LastFrame / FrameRate );
_timeLabel.Text = $"{(int)current.TotalMinutes}:{current.Seconds:00} / {(int)total.TotalMinutes}:{total.Seconds:00}";
}
}
/// <summary>Frame-to-pixel geometry, shared by the ruler and the lanes so they can't drift apart.</summary>
internal readonly struct RigTimelineLayout
{
public const float RowHeight = 22f;
public const float RulerHeight = 22f;
public const float Gutter = 130f;
/// <summary>Where the vertical divider is drawn. Its own number rather than LaneArea.Left,
/// because this is only a line - moving it must not move the lane area, the frame mapping or
/// anything that hit-tests against them.</summary>
public const float DividerX = 100f;
public const float DiamondSize = 9f;
private const float RightPadding = 10f;
/// <summary>
/// Pixels per frame at rest - a frame is 8px wide until you zoom.
///
/// THE TIMELINE IS NOW A WIDE CANVAS THAT SCROLLS, not a window that remaps the clip onto a
/// fixed width. The old model made a frame's width depend on the clip's length, so a 900 frame
/// clip crushed every frame to a fraction of a pixel and the keys became an unclickable smear -
/// making a clip longer actively made it harder to work on.
///
/// A constant pixels-per-frame means a frame is the same size whether the clip is one second
/// or thirty, and the ScrollArea's own horizontal scrollbar handles moving along it - a real
/// scrollbar with a proportional handle, which is also a readout of how much clip there is.
/// </summary>
public const float DefaultPixelsPerFrame = 8f;
/// <summary>Zoom limits. The floor keeps a 900 frame clip inside a sane canvas width; the
/// ceiling stops a single frame filling the screen.</summary>
public const float MinPixelsPerFrame = 0.4f;
public const float MaxPixelsPerFrame = 80f;
private readonly float _width;
private readonly int _rows;
public float LastFrame { get; }
/// <summary>Width of one frame. Zoom changes this and nothing else.</summary>
public float PixelsPerFrame { get; }
/// <summary>
/// How far the ScrollArea has scrolled, and the thing that makes one FrameToX serve two
/// coordinate spaces.
///
/// The lanes widget IS the ScrollArea's canvas, so its local coordinates are already scrolled -
/// it builds a layout with ScrollX 0 and gets canvas space. The rulers sit outside the
/// ScrollArea and don't move, so they build a layout with the real offset and get viewport
/// space. Same maths, same call, both correct.
/// </summary>
public float ScrollX { get; }
/// <summary>Left inset before frame zero. The rulers span the full window and sit above the
/// bone-name column, so they offset by Gutter. The lanes are inside a ScrollArea that now
/// STARTS after that column, so for them it's zero - that's what makes the ScrollArea's own
/// scrollbar line up with the tracks instead of starting under the names.</summary>
public float GutterWidth { get; }
public RigTimelineLayout( float width, int frameCount, int rows, float scrollX = 0f, float pixelsPerFrame = 0f, float gutter = Gutter )
{
_width = width;
_rows = rows;
GutterWidth = gutter;
LastFrame = MathF.Max( frameCount - 1, 1f );
PixelsPerFrame = (pixelsPerFrame > 0f ? pixelsPerFrame : DefaultPixelsPerFrame)
.Clamp( MinPixelsPerFrame, MaxPixelsPerFrame );
ScrollX = scrollX;
}
/// <summary>How wide the lanes widget has to be for the whole clip to exist inside the
/// ScrollArea. This is what gives the scrollbar something to scroll.</summary>
public float CanvasWidth => GutterWidth + (LastFrame + 1f) * PixelsPerFrame + RightPadding;
public Rect LaneArea => new( GutterWidth, 0f, MathF.Max( _width - GutterWidth - RightPadding, 1f ), _rows * RowHeight );
public float FrameToX( float frame ) => GutterWidth + frame * PixelsPerFrame - ScrollX;
public float XToFrame( float x ) => ((x + ScrollX - GutterWidth) / PixelsPerFrame).Clamp( 0f, LastFrame );
public Rect RowRect( int index ) => new( 0f, index * RowHeight, MathF.Max( _width, 1f ), RowHeight );
public Rect DiamondRect( int index, float frame )
{
var row = RowRect( index );
var x = FrameToX( frame );
var half = DiamondSize * 0.5f;
return new Rect( x - half, row.Center.y - half, DiamondSize, DiamondSize );
}
public int HitRow( Vector2 position )
{
var index = (int)(position.y / RowHeight);
return index >= 0 && index < _rows ? index : -1;
}
/// <summary>Ruler divisions spaced so their LABELS don't collide.
///
/// minLabelWidth is the width of the widest label the caller will draw, and it matters a lot:
/// a timecode like "00:00.933" is roughly twice the width of a frame number, so a single
/// hardcoded spacing crams one ruler into an unreadable smear at small window sizes. Callers
/// pass what they actually need.</summary>
public IEnumerable<float> RulerFrames( float minLabelWidth = 38f )
{
var step = RulerSteps[^1];
foreach ( var candidate in RulerSteps )
{
if ( candidate * PixelsPerFrame < minLabelWidth )
continue;
step = candidate;
break;
}
// Only the frames actually on screen. Derived from the scroll offset and this widget's
// width rather than from a view range, so it works the same for the rulers (which scroll
// by offset) and the canvas (which is scrolled for them, and passes ScrollX 0 with its
// full width - yielding every division across the whole canvas, which is what it wants).
var firstVisible = (ScrollX - GutterWidth) / PixelsPerFrame;
var lastVisible = (ScrollX + _width - GutterWidth) / PixelsPerFrame;
var first = MathF.Max( MathF.Floor( firstVisible / step ) * step, 0f );
var last = MathF.Min( lastVisible, LastFrame );
for ( var frame = first; frame <= last; frame += step )
yield return frame;
}
private static readonly float[] RulerSteps = [1f, 2f, 5f, 10f, 25f, 50f, 100f, 250f, 500f, 1000f];
public static float HeightFor( int rows ) => rows * RowHeight;
}
internal sealed class RigTimelineLanes : Widget
{
public RigAnimDocument Anim { get; set; }
public float Playhead { get; set; }
public Action<float> Scrubbed { get; set; }
public Action Edited { get; set; }
private bool _grabbed;
private bool _dragged;
private BoneTrack _grabTrack;
private BoneKeyframe _grabKey;
private float _grabOffset;
private (BoneTrack Track, BoneKeyframe Key)? _hover;
private (BoneTrack Track, BoneKeyframe Key)? _selected;
public RigTimelineLanes( Widget parent ) : base( parent )
{
FocusMode = FocusMode.Click;
MouseTracking = true;
}
/// <summary>
/// The visible frame window, shared by the lanes and both rulers so they can't disagree about
/// what's on screen. Held here rather than in each widget because a ruler that scrolls
/// independently of the lanes it labels is worse than no ruler.
/// </summary>
/// <summary>
/// The shared zoom level. Scroll position is NOT here any more - it belongs to the ScrollArea,
/// which owns the scrollbar and is the only thing that should be deciding where the view sits.
///
/// Still a shared object rather than a value per widget, for the original reason: the rulers
/// and the lanes must never disagree about scale, and the cheapest guarantee of that is their
/// holding the same instance.
/// </summary>
public sealed class ViewRange
{
public float PixelsPerFrame = RigTimelineLayout.DefaultPixelsPerFrame;
/// <summary>Where the horizontal scroll currently is, in canvas pixels. Written by the
/// timeline from the ScrollArea each frame so the rulers can offset by it.</summary>
public float ScrollX;
/// <summary>
/// Zoom about a fixed frame - the one under the cursor - and report how far the scroll has
/// to move to keep that frame under the pointer.
///
/// Returns the new scroll offset rather than applying it, because the scroll belongs to
/// the ScrollArea and this type has no business reaching into a widget.
/// </summary>
public float ZoomAt( float anchorFrame, float anchorViewportX, float factor )
{
PixelsPerFrame = (PixelsPerFrame * factor)
.Clamp( RigTimelineLayout.MinPixelsPerFrame, RigTimelineLayout.MaxPixelsPerFrame );
// The anchor frame's new position in canvas space, minus where on screen it has to
// stay, IS the scroll offset. anchorViewportX must be measured from the ScrollArea's
// left edge - the lanes widget has to subtract the current scroll first, since its own
// coordinates are canvas ones.
return MathF.Max( RigTimelineLayout.Gutter + anchorFrame * PixelsPerFrame - anchorViewportX, 0f );
}
}
public ViewRange View { get; set; } = new();
/// <summary>Canvas space: this widget IS the ScrollArea's canvas, so it is already translated
/// and must not subtract the offset again. Its own width is the full canvas width.</summary>
private RigTimelineLayout Geometry => new( Width, (Anim?.FrameCount ?? 1), Anim?.BoneTracks.Count ?? 0,
0f, View.PixelsPerFrame, 0f );
/// <summary>
/// The divider and the gutter's right edge, IN CANVAS SPACE.
///
/// Both are pinned to the screen, so in this widget's coordinates they slide right as you
/// scroll. Everything that asks "is this behind the name column" has to ask against these
/// rather than the raw constants - the constants are screen positions, and this widget does
/// not draw in screen positions.
/// </summary>
// Zero now that the bone names are their own widget outside the ScrollArea. These used to be
// the boundary that keyframes, curves and grid lines were clipped against so they didn't draw
// underneath the pinned name column; with nothing overlapping the tracks any more there is
// nothing to clip, and leaving them at zero turns every one of those checks into a no-op
// rather than needing each call site unpicked.
private float DividerCanvasX => 0f;
/// <summary>Zero - clicks in the name column are the column widget's business now.</summary>
private float GutterCanvasX => 0f;
/// <summary>
/// Ctrl+wheel zooms about the cursor, Shift+wheel pans. Plain wheel is deliberately left to
/// the ScrollArea so the bone list still scrolls vertically.
///
/// Modifiers come off the event (HasCtrl/HasShift) rather than from a global key query, and
/// the anchor comes from the last mouse position rather than the wheel event, because
/// WheelEvent carries no position - this is the same shape MovieMaker's own timeline uses.
/// </summary>
protected override void OnMouseWheel( WheelEvent e )
{
var layout = Geometry;
if ( e.HasCtrl )
{
// 1.15 per notch: fine enough to land on a zoom deliberately, coarse enough that
// crossing a long clip doesn't take a dozen scrolls.
//
// _lastMousePos is in CANVAS space here - this widget is the scrolled canvas - so the
// scroll has to come back off it to get the on-screen position the anchor must hold.
var anchorFrame = layout.XToFrame( _lastMousePos.x );
var anchorViewportX = _lastMousePos.x - View.ScrollX;
ScrollRequested?.Invoke( View.ZoomAt( anchorFrame, anchorViewportX, e.Delta > 0 ? 1f / 1.15f : 1.15f ) );
ViewChanged?.Invoke();
e.Accept();
return;
}
if ( e.HasShift )
{
// Pan by a fifth of a screen per notch. The ScrollArea owns the position now, so this
// asks for a new offset rather than moving a view range of its own.
var page = MathF.Max( Width * 0.2f, 32f );
ScrollRequested?.Invoke( MathF.Max( View.ScrollX + (e.Delta > 0 ? -page : page), 0f ) );
ViewChanged?.Invoke();
e.Accept();
return;
}
base.OnMouseWheel( e );
}
/// <summary>Asks the timeline to move the ScrollArea. Raised rather than done here, because
/// the ScrollArea is the canvas's parent and a canvas scrolling itself is how you get a widget
/// fighting its own container.</summary>
public Action<float> ScrollRequested { get; set; }
private Vector2 _lastMousePos;
public Action ViewChanged { get; set; }
/// <summary>
/// Reads the live horizontal scroll off the ScrollArea.
///
/// PULLED AT PAINT TIME, NOT PUSHED ON CHANGE, because dragging the scrollbar raises nothing
/// we can subscribe to - the ScrollArea simply moves its canvas and repaints it. Without this
/// the pinned name column would keep using a stale offset and slide away precisely when you
/// scrolled by hand, which is the one case the pinning exists for.
/// </summary>
public Func<float> ScrollOffset { get; set; }
private float _paintedScrollX = float.NaN;
protected override void OnPaint()
{
if ( ScrollOffset is not null )
View.ScrollX = ScrollOffset();
// The rulers live outside the ScrollArea, so nothing moves or repaints them when it
// scrolls. This widget does get repainted, so it's the only thing in a position to notice
// and tell them. Guarded on an actual change, or a repaint would schedule a repaint.
if ( View.ScrollX != _paintedScrollX )
{
_paintedScrollX = View.ScrollX;
ViewChanged?.Invoke();
}
var layout = Geometry;
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( Theme.WidgetBackground );
Paint.DrawRect( LocalRect );
// Drawn before the early-out below, so the column edge is there even with nothing keyed -
// it's part of the furniture, not a thing that appears once you have tracks.
var tracks = Anim?.BoneTracks;
if ( tracks is null || tracks.Count == 0 )
{
Paint.SetPen( Theme.TextControl.WithAlpha( 0.4f ) );
Paint.DrawText( LocalRect, "No bones keyed yet - pose one in the viewport to start", TextFlag.Center );
return;
}
for ( var i = 0; i < tracks.Count; i++ )
PaintRow( layout, i, tracks[i] );
PaintGrid( layout );
for ( var i = 0; i < tracks.Count; i++ )
PaintKeyframes( layout, i, tracks[i] );
PaintPlayhead( layout );
}
private void PaintRow( RigTimelineLayout layout, int index, BoneTrack track )
{
var row = layout.RowRect( index );
var isSelected = track.BoneName == SelectedBone;
Paint.ClearPen();
Paint.SetBrush( index % 2 == 1 ? Theme.WindowBackground.WithAlpha( 0.2f ) : Color.Transparent );
Paint.DrawRect( row );
// The selected bone's whole row is tinted, and its name marked, so which bone you're
// posing is answerable from either the viewport or the timeline.
if ( isSelected )
{
Paint.SetBrush( Theme.Yellow.WithAlpha( 0.12f ) );
Paint.DrawRect( row );
}
}
/// <summary>Which bone the viewport has selected, so the matching row can be highlighted.</summary>
public string SelectedBone { get; set; }
/// <summary>Raised when a bone's name is clicked in the gutter.</summary>
public Action<string> BoneRowSelected { get; set; }
/// <summary>The bone row under a point, if the point is in the name gutter rather than out
/// among the keyframes.</summary>
private BoneTrack HitBoneRow( Vector2 position )
{
if ( position.x >= GutterCanvasX || Anim is null )
return null;
var index = Geometry.HitRow( position );
return index >= 0 && index < Anim.BoneTracks.Count ? Anim.BoneTracks[index] : null;
}
/// <summary>Right-clicking a bone's name - the track-wide operations, which previously had no
/// home at all: the only way to clear a bone was a menu-bar item acting on whatever the
/// viewport happened to have selected.</summary>
/// <summary>Raised by the bone-name column, which owns those clicks now.</summary>
public void OpenBoneRowMenuFor( BoneTrack track ) => OpenBoneRowMenu( track );
private void OpenBoneRowMenu( BoneTrack track )
{
var menu = new Menu( this );
menu.AddHeading( track.BoneName );
menu.AddOption( "Select This Bone", "my_location", () => BoneRowSelected?.Invoke( track.BoneName ) )
.StatusTip = "Select it in the viewport so it can be posed";
menu.AddSeparator();
var setAll = menu.AddMenu( "Set All Keys To", "show_chart" );
foreach ( var mode in Enum.GetValues<KeyInterpolation>() )
{
var captured = mode;
setAll.AddOption( Label( mode ), null, () =>
{
foreach ( var key in track.Keyframes )
key.Interpolation = captured;
Update();
Edited?.Invoke();
} );
}
menu.AddSeparator();
var clear = menu.AddOption( "Clear All Keyframes", "clear_all", () =>
{
track.Keyframes.Clear();
Update();
Edited?.Invoke();
} );
clear.Enabled = track.Keyframes.Count > 0;
clear.StatusTip = "Remove every keyframe on this bone, leaving the track in place";
menu.AddOption( "Delete Track", "delete", () =>
{
Anim?.BoneTracks.Remove( track );
Update();
Edited?.Invoke();
} ).StatusTip = "Remove the bone from the timeline entirely";
menu.OpenAtCursor();
}
/// <summary>
/// The edge between the bone-name column and the lanes.
///
/// Without it the names float in the same field as the keyframes and the eye has nothing to
/// stop at, which is what makes a dense timeline hard to read - you lose which row you're on
/// halfway across.
///
/// Deliberately dim. It's a boundary, not information, and it sits behind the keyframes.
/// </summary>
private void PaintGrid( RigTimelineLayout layout )
{
Paint.SetPen( Theme.WindowBackground.WithAlpha( 0.5f ) );
foreach ( var frame in layout.RulerFrames() )
{
var x = layout.FrameToX( frame );
Paint.DrawLine( new Vector2( x, 0f ), new Vector2( x, Height ) );
}
}
/// <summary>
/// Ringed dots joined by a segment line, the way MovieMaker draws a channel.
///
/// The connecting segment isn't decoration - it's coloured by the OUTGOING interpolation of
/// the key on its left, so the easing of the whole clip is readable at a glance instead of
/// only through a right-click menu. A stepped segment is drawn as a flat hold with a riser at
/// the end, which is literally what it does to the pose.
/// </summary>
private const int CurveSamples = 20;
private void PaintKeyframes( RigTimelineLayout layout, int index, BoneTrack track )
{
var row = layout.RowRect( index );
var y = row.Center.y;
// track.Keyframes is ALREADY SORTED - SetKeyframe inserts in place and EnsureSorted keeps
// it that way, precisely so nothing has to sort per frame. This used to be
// OrderBy(...).ToList(), which allocated a throwaway list for every visible track on every
// repaint; the document's own comment calls that out as the thing to avoid, and the paint
// path was quietly doing it anyway.
var ordered = track.Keyframes;
for ( var i = 0; i < ordered.Count - 1; i++ )
{
var from = layout.FrameToX( ordered[i].Frame );
var to = layout.FrameToX( ordered[i + 1].Frame );
// Wholly off the left - nothing to draw.
if ( to < DividerCanvasX )
continue;
var key = ordered[i];
// Eased segments share the green family - they are the same idea at different
// strengths, and the curve drawn on the segment already says which half is eased.
var color = key.Interpolation switch
{
KeyInterpolation.Smooth => Theme.Green.WithAlpha( 0.7f ),
KeyInterpolation.EaseIn => Theme.Green.WithAlpha( 0.5f ),
KeyInterpolation.EaseOut => Theme.Green.WithAlpha( 0.5f ),
KeyInterpolation.Linear => Theme.Blue.WithAlpha( 0.7f ),
_ => Theme.TextControl.WithAlpha( 0.4f )
};
Paint.SetPen( color, 2f );
if ( key.Interpolation == KeyInterpolation.Stepped )
{
// Flat hold, then a vertical riser at the moment it snaps. The hold starts at the
// divider when the key itself is off to the left.
Paint.DrawLine( new Vector2( MathF.Max( from, DividerCanvasX ), y ), new Vector2( to, y ) );
Paint.DrawLine( new Vector2( to, y - 5f ), new Vector2( to, y + 5f ) );
continue;
}
// The curve is SAMPLED FROM Ease() itself, not faked with a hand-drawn bezier, so what
// you see is the function playback actually uses. Plotted as the easing's deviation
// from a straight line: Smooth bows into an S (slow out, fast through, slow in),
// Linear stays flat because its deviation is zero by definition. Change Ease and this
// drawing changes with it - it can't drift out of sync with the real behaviour.
var amplitude = row.Height * 0.32f;
var previous = new Vector2( from, y );
for ( var step = 1; step <= CurveSamples; step++ )
{
var t = step / (float)CurveSamples;
var point = new Vector2(
MathX.Lerp( from, to, t ),
y - (key.Ease( t ) - t) * amplitude );
// Samples left of the divider are carried forward without being drawn, so the
// curve starts exactly at the divider instead of either poking into the names or
// beginning a few samples late with a visible notch.
if ( point.x < DividerCanvasX )
{
previous = point;
continue;
}
Paint.DrawLine( previous.x < DividerCanvasX
? new Vector2( DividerCanvasX, previous.y )
: previous, point );
previous = point;
}
}
foreach ( var key in track.Keyframes )
{
var center = layout.DiamondRect( index, key.Frame ).Center;
// A key scrolled off the left edge would be drawn on top of the bone name, where it
// looks like it belongs to the name column rather than to the track.
if ( center.x < DividerCanvasX )
continue;
var isSelected = _selected is { } sel && sel.Track == track && sel.Key == key;
var isHovered = _hover is { } hov && hov.Track == track && hov.Key == key;
var radius = RigTimelineLayout.DiamondSize * 0.5f;
var outer = isSelected ? Color.White : isHovered ? Theme.Yellow : Theme.Blue;
// Drawn as an outer shape with the background punched out of the middle, so every key
// reads as a ring. Selected fills solid instead - the one key you're acting on should
// be the only solid thing on the row.
Paint.ClearPen();
Paint.SetBrush( outer );
PaintKeyShape( center, radius * 2f, key.Interpolation );
if ( isSelected )
continue;
Paint.SetBrush( Theme.WidgetBackground );
PaintKeyShape( center, radius, key.Interpolation );
}
}
/// <summary>
/// THE SHAPE IS THE INTERPOLATION MODE. Round eases, angular is linear, square holds.
///
/// This is the one place the tool deliberately doesn't copy MovieMaker, which draws every key
/// as the same dot - meaning the only way to see a key's easing there is to right-click it one
/// at a time. Encoding it in the silhouette makes the timing of a whole clip readable in a
/// glance, and it survives colourblindness and a dark screen in a way a colour swap wouldn't.
/// </summary>
/// <summary>Menu label for an interpolation mode. ToString() gives "EaseIn", which reads as a
/// code identifier that leaked into the UI.</summary>
private static string Label( KeyInterpolation mode ) => mode switch
{
KeyInterpolation.EaseIn => "Ease In",
KeyInterpolation.EaseOut => "Ease Out",
_ => mode.ToString()
};
private static void PaintKeyShape( Vector2 center, float radius, KeyInterpolation interpolation )
{
switch ( interpolation )
{
case KeyInterpolation.Linear:
Paint.DrawPolygon(
center + new Vector2( 0f, -radius ),
center + new Vector2( radius, 0f ),
center + new Vector2( 0f, radius ),
center + new Vector2( -radius, 0f ) );
return;
case KeyInterpolation.Stepped:
// Slightly tucked in - a square at the same radius reads noticeably bigger than a
// circle, because it covers the corners too.
var side = radius * 1.7f;
Paint.DrawRect( new Rect( center.x - side * 0.5f, center.y - side * 0.5f, side, side ), 1f );
return;
default:
// Rect form, matching RigHelpBox - the center+float overload is ambiguous about
// whether the float is a radius or a diameter, and a wrong guess there is a
// silently double-sized dot.
Paint.DrawCircle( new Rect( center.x - radius, center.y - radius, radius * 2f, radius * 2f ) );
return;
}
}
private void PaintPlayhead( RigTimelineLayout layout )
{
var x = layout.FrameToX( Playhead );
// Scrolled off the left, the playhead would otherwise be drawn straight down the bone
// names - see the divider rule in PaintGutterDivider.
if ( x < DividerCanvasX )
return;
Paint.SetPen( Theme.Primary.WithAlpha( 0.8f ), 1f );
Paint.DrawLine( new Vector2( x, 0f ), new Vector2( x, Height ) );
}
private (BoneTrack Track, BoneKeyframe Key)? HitKeyframe( RigTimelineLayout layout, int row, Vector2 position )
{
if ( row < 0 || Anim is null || row >= Anim.BoneTracks.Count )
return null;
var track = Anim.BoneTracks[row];
foreach ( var key in track.Keyframes )
{
var rect = layout.DiamondRect( row, key.Frame );
// Keys scrolled behind the name column are not DRAWN there (see PaintKeyframes), so
// they must not be clickable there either. Without this, the bone-name strip contains
// invisible hitboxes that select and drag keys you can't see - and since the click
// also scrubs, the playhead jumps somewhere unrelated at the same time.
if ( rect.Center.x < DividerCanvasX )
continue;
if ( rect.IsInside( position ) )
return (track, key);
}
return null;
}
protected override void OnMousePress( MouseEvent e )
{
Focus();
if ( Anim is null || !e.LeftMouseButton )
return;
// Clicking a bone's name selects that bone, rather than doing nothing - the gutter looked
// like a list of things you could pick and wasn't one.
if ( HitBoneRow( e.LocalPosition ) is { } clickedBone )
{
BoneRowSelected?.Invoke( clickedBone.BoneName );
e.Accepted = true;
return;
}
var layout = Geometry;
var row = layout.HitRow( e.LocalPosition );
var hit = HitKeyframe( layout, row, e.LocalPosition );
_selected = hit;
_grabbed = false;
if ( hit is not { } key )
return;
_grabbed = true;
_dragged = false;
_grabTrack = key.Track;
_grabKey = key.Key;
_grabOffset = layout.XToFrame( e.LocalPosition.x ) - key.Key.Frame;
Scrubbed?.Invoke( key.Key.Frame );
e.Accepted = true;
}
protected override void OnMouseMove( MouseEvent e )
{
// Kept for the wheel handler - WheelEvent carries no position, so the zoom anchor has to
// come from the last place the mouse actually was.
_lastMousePos = e.LocalPosition;
var layout = Geometry;
if ( !_grabbed || !e.ButtonState.HasFlag( MouseButtons.Left ) )
{
UpdateHover( layout, e.LocalPosition );
return;
}
var frame = (int)MathF.Round( layout.XToFrame( e.LocalPosition.x ) - _grabOffset );
frame = frame.Clamp( 0, (int)layout.LastFrame );
if ( frame != _grabKey.Frame )
{
_grabKey.Frame = frame;
_dragged = true;
Scrubbed?.Invoke( frame );
Update();
}
e.Accepted = true;
}
private void UpdateHover( RigTimelineLayout layout, Vector2 position )
{
var row = Anim is null ? -1 : layout.HitRow( position );
var hit = HitKeyframe( layout, row, position );
Cursor = hit is null ? CursorShape.Arrow : CursorShape.Finger;
if ( _hover?.Key == hit?.Key )
return;
_hover = hit;
Update();
}
protected override void OnMouseLeave()
{
if ( _hover is null )
return;
_hover = null;
Update();
}
protected override void OnMouseReleased( MouseEvent e )
{
_grabbed = false;
if ( !_dragged )
return;
_dragged = false;
Edited?.Invoke();
}
// Static so a pose can be carried between clips, not just within one. A keyframe is a small
// value object, so this holds a copy rather than a reference - copying then editing the
// original shouldn't silently change what's on the clipboard.
private static BoneKeyframe _clipboard;
protected override void OnContextMenu( ContextMenuEvent e )
{
if ( Anim is null )
return;
// The name gutter gets the track-wide menu; out among the keyframes you get the per-key one.
if ( HitBoneRow( e.LocalPosition ) is { } boneRow )
{
OpenBoneRowMenu( boneRow );
e.Accepted = true;
return;
}
var layout = Geometry;
var row = layout.HitRow( e.LocalPosition );
// Right-clicking a keyframe selects it first, so the menu always acts on what's under the
// cursor rather than on whatever happened to be selected before.
if ( HitKeyframe( layout, row, e.LocalPosition ) is { } hit )
_selected = hit;
var menu = new Menu( this );
BuildKeyframeMenu( menu );
menu.OpenAtCursor();
Update();
}
private void BuildKeyframeMenu( Menu menu )
{
if ( _selected is not { } sel )
{
var pasteHere = menu.AddOption( "Paste", "content_paste", PasteClipboard );
pasteHere.Enabled = false;
pasteHere.StatusTip = "Select a keyframe's row first - paste needs to know which bone to paste onto";
return;
}
// Bone name and frame as the heading itself rather than a disabled row - three greyed-out
// lines of readout read as broken menu items, and cost more vertical space than the
// actions underneath them.
menu.AddHeading( $"{Shorten( sel.Track.BoneName )} — frame {sel.Key.Frame}" );
// Editable, not a readout. Typing an exact number is the whole reason to open this menu on
// a keyframe you've already dragged into roughly the right place - and it's the one thing
// dragging in the viewport genuinely can't do.
menu.AddWidget( BuildValueRow( "rotation", sel, true ) );
menu.AddWidget( BuildValueRow( "position", sel, false ) );
menu.AddSeparator();
var interpolation = menu.AddMenu( "Interpolation Mode", "show_chart" );
foreach ( var mode in Enum.GetValues<KeyInterpolation>() )
{
var option = interpolation.AddOption( Label( mode ), null, () => SetInterpolation( mode ) );
option.Checkable = true;
option.Checked = sel.Key.Interpolation == mode;
option.StatusTip = mode switch
{
KeyInterpolation.Smooth => "Ease out of this key and into the next - the default for body motion",
KeyInterpolation.Linear => "Constant speed, no easing - for mechanical motion",
KeyInterpolation.EaseIn => "Start slow, arrive at full speed - the wind-up half of an action",
KeyInterpolation.EaseOut => "Leave fast, settle slowly - what makes a movement land with weight",
_ => "Hold this pose until the next key, then snap"
};
}
menu.AddSeparator();
menu.AddHeading( "Clipboard" );
menu.AddOption( "Copy", "content_copy", CopySelected );
menu.AddOption( "Cut", "content_cut", () => { CopySelected(); DeleteSelectedKeyframe(); } );
var paste = menu.AddOption( "Paste", "content_paste", PasteClipboard );
paste.Enabled = _clipboard is not null;
paste.StatusTip = "Paste the copied pose onto this bone at the playhead";
menu.AddOption( "Delete", "delete", DeleteSelectedKeyframe );
}
/// <summary>Long rig bones ("arm_lower_R_twistctrl1") push the menu absurdly wide and bury the
/// frame number at the end. The tail is the part that identifies it, so the front is what
/// gets dropped.</summary>
private static string Shorten( string bone, int max = 22 ) =>
string.IsNullOrEmpty( bone ) || bone.Length <= max ? bone : "…" + bone[^max..];
/// <summary>
/// An X/Y/Z row of editable fields, axis-coloured red/green/blue.
///
/// The colours aren't decoration - they're the same axis convention as the viewport gizmo's
/// arrows, so "the red one" means one thing everywhere in the tool. Committing a field writes
/// straight through to the keyframe and re-poses the viewport, so a typed number is visible
/// immediately rather than on the next scrub.
/// </summary>
private Widget BuildValueRow( string label, (BoneTrack Track, BoneKeyframe Key) sel, bool rotation )
{
var row = new Widget( this ) { Layout = Layout.Row() };
row.Layout.Margin = new Sandbox.UI.Margin( 8, 2 );
row.Layout.Spacing = 4;
row.Layout.Add( new Editor.Label( label ) { FixedWidth = 52 } );
var angles = sel.Key.Local.Rotation.Angles();
var position = sel.Key.Local.Position;
var values = rotation
? new[] { angles.pitch, angles.yaw, angles.roll }
: new[] { position.x, position.y, position.z };
// Matches the gizmo's arrow colours, not the pitch/yaw/roll naming - what you see in the
// viewport is what you're typing into.
var colors = new[] { Theme.Red, Theme.Green, Theme.Blue };
var names = rotation ? new[] { "P", "Y", "R" } : new[] { "X", "Y", "Z" };
for ( var axis = 0; axis < 3; axis++ )
{
var index = axis;
var chip = new Editor.Label( names[axis] ) { FixedWidth = 12, Color = colors[axis] };
chip.SetStyles( "font-weight: 600;" );
row.Layout.Add( chip );
var edit = new LineEdit( $"{values[axis]:0.##}", row ) { FixedWidth = 52 };
edit.TextEdited += text =>
{
if ( !float.TryParse( text, out var parsed ) )
return;
ApplyValue( sel, rotation, index, parsed );
};
row.Layout.Add( edit );
}
return row;
}
private void ApplyValue( (BoneTrack Track, BoneKeyframe Key) sel, bool rotation, int axis, float value )
{
var local = sel.Key.Local;
if ( rotation )
{
var angles = local.Rotation.Angles();
if ( axis == 0 ) angles.pitch = value;
else if ( axis == 1 ) angles.yaw = value;
else angles.roll = value;
local = new Transform( local.Position, angles.ToRotation(), local.Scale );
}
else
{
var position = local.Position;
if ( axis == 0 ) position.x = value;
else if ( axis == 1 ) position.y = value;
else position.z = value;
local = new Transform( position, local.Rotation, local.Scale );
}
sel.Key.Local = local;
Update();
// Re-poses the viewport so a typed number shows up now, not on the next scrub.
Scrubbed?.Invoke( Playhead );
Edited?.Invoke();
}
private void SetInterpolation( KeyInterpolation mode )
{
if ( _selected is not { } sel )
return;
sel.Key.Interpolation = mode;
Update();
Edited?.Invoke();
}
private void CopySelected()
{
if ( _selected is not { } sel )
return;
_clipboard = new BoneKeyframe
{
Frame = sel.Key.Frame,
Local = sel.Key.Local,
Interpolation = sel.Key.Interpolation
};
}
/// <summary>Pastes onto the selected bone's track at the playhead - not at the frame the pose
/// was copied from, which would make copying a key to a different time impossible.</summary>
private void PasteClipboard()
{
if ( _clipboard is null || _selected is not { } sel )
return;
var frame = (int)MathF.Round( Playhead );
sel.Track.SetKeyframe( frame, _clipboard.Local );
if ( sel.Track.Keyframes.FirstOrDefault( k => k.Frame == frame ) is { } pasted )
pasted.Interpolation = _clipboard.Interpolation;
Update();
Edited?.Invoke();
}
public bool HasSelectedKeyframe => _selected is not null;
public void DeleteSelectedKeyframe()
{
if ( _selected is not { } sel )
return;
sel.Track.Keyframes.Remove( sel.Key );
_selected = null;
Update();
Edited?.Invoke();
}
protected override void OnKeyPress( KeyEvent e )
{
if ( e.Key != KeyCode.Delete )
return;
DeleteSelectedKeyframe();
e.Accepted = true;
}
}
internal sealed class RigTimelineRuler : Widget
{
private readonly Widget _lanes;
public int FrameCount { get; set; } = 30;
public float Playhead { get; set; }
public Action<float> Scrubbed { get; set; }
/// <summary>Frames per second, for turning a frame index into a timecode. Comes from the
/// clip's Animation Speed.</summary>
public int Fps { get; set; } = 30;
/// <summary>Timecode above the lanes, frame numbers below - the same split MovieMaker uses.
/// Time is what you think in when judging whether an action reads; frames are what you think
/// in when placing a key. Showing both means never converting in your head.</summary>
public bool ShowTimecode { get; set; } = true;
private bool _scrubbing;
public RigTimelineRuler( Widget parent, Widget lanes ) : base( parent )
{
_lanes = lanes;
FixedHeight = RigTimelineLayout.RulerHeight;
Cursor = CursorShape.SizeH;
// Needed for the wheel zoom anchor: without it OnMouseMove only fires while a button is
// held, so the anchor would be stale whenever you simply hover and scroll.
MouseTracking = true;
}
/// <summary>Compact, and only as precise as the clip is long.
///
/// MovieMaker's full "00:00.000" is nine characters for what is usually under a second of
/// animation - at a small window size those labels collide into an unreadable strip. A clip
/// under a minute doesn't need a minutes field, and one under ten seconds doesn't need
/// milliseconds.</summary>
private string Timecode( float frame )
{
var seconds = frame / MathF.Max( Fps, 1 );
var total = FrameCount / MathF.Max( Fps, 1 );
if ( total < 10f )
return $"{seconds:0.00}s";
if ( total < 60f )
return $"{seconds:0.0}s";
return $"{(int)(seconds / 60f)}:{seconds % 60f:00.0}";
}
/// <summary>The same instance the lanes hold, so ruler and lanes always agree on what window
/// is on screen.</summary>
public RigTimelineLanes.ViewRange View { get; set; } = new();
/// <summary>VIEWPORT space: this widget sits outside the ScrollArea and never moves, so it has
/// to subtract the scroll itself. Its own width, not the canvas's - it only draws what fits on
/// screen. That one difference from the lanes' Geometry is the whole of how the two coordinate
/// spaces are kept apart.</summary>
private RigTimelineLayout Geometry => new( Width, FrameCount, 0, View.ScrollX, View.PixelsPerFrame );
/// <summary>Zoom and pan work from the rulers too - they're the natural place to reach for it,
/// and having it only work over the lanes would be an arbitrary dead zone.</summary>
protected override void OnMouseWheel( WheelEvent e )
{
var layout = Geometry;
if ( e.HasCtrl )
{
// _lastMouseX is already viewport space here, so unlike the lanes there is nothing to
// convert - it goes to both arguments' worth of meaning directly.
ScrollRequested?.Invoke( View.ZoomAt( layout.XToFrame( _lastMouseX ), _lastMouseX,
e.Delta > 0 ? 1f / 1.15f : 1.15f ) );
ViewChanged?.Invoke();
e.Accept();
return;
}
if ( e.HasShift )
{
var page = MathF.Max( Width * 0.2f, 32f );
ScrollRequested?.Invoke( MathF.Max( View.ScrollX + (e.Delta > 0 ? -page : page), 0f ) );
ViewChanged?.Invoke();
e.Accept();
return;
}
base.OnMouseWheel( e );
}
public Action<float> ScrollRequested { get; set; }
private float _lastMouseX;
public Action ViewChanged { get; set; }
protected override void OnPaint()
{
var layout = Geometry;
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( Theme.SurfaceBackground );
Paint.DrawRect( LocalRect );
if ( _lanes.Width < RigTimelineLayout.Gutter )
return;
Paint.SetDefaultFont( 7 );
Paint.SetPen( Theme.TextControl.WithAlpha( 0.45f ) );
Paint.DrawText( new Rect( 8f, 0f, RigTimelineLayout.Gutter - 16f, Height ),
ShowTimecode ? "BONE" : "FRAME", TextFlag.LeftCenter );
// Same edge as the lanes draw, at the same x and the same weight, so the column reads as
// one continuous strip through the ruler, the tracks and the second ruler rather than as
// three separate widgets that happen to be stacked.
Paint.SetPen( Theme.TextControl.WithAlpha( 0.2f ) );
Paint.DrawLine(
new Vector2( RigTimelineLayout.DividerX, 0f ),
new Vector2( RigTimelineLayout.DividerX, Height ) );
// Timecodes are the wider label, so they get the wider minimum spacing.
var labelWidth = ShowTimecode ? 58f : 40f;
foreach ( var frame in layout.RulerFrames( labelWidth ) )
{
var x = layout.FrameToX( frame );
// NOTHING IN THE RULER CROSSES THE DIVIDER. RulerFrames starts at the last tick BEFORE
// the view, so that first label sits left of the lane area - fine when the view starts
// at zero and nothing is there, and a label printed on top of "BONE" the moment you
// scroll.
if ( x < RigTimelineLayout.DividerX )
continue;
Paint.SetPen( Theme.TextControl.WithAlpha( 0.25f ) );
// Ticks hang off the edge that faces the lanes, so the two rulers bracket the tracks.
Paint.DrawLine(
new Vector2( x, ShowTimecode ? Height - 5f : 0f ),
new Vector2( x, ShowTimecode ? Height : 5f ) );
if ( x + labelWidth * 0.7f > layout.LaneArea.Right + 8f )
continue;
Paint.SetPen( Theme.TextControl.WithAlpha( 0.6f ) );
Paint.DrawText( new Rect( x + 4f, 0f, labelWidth, Height ),
ShowTimecode ? Timecode( frame ) : $"{(int)frame}", TextFlag.LeftCenter );
}
// The rule bracketing the tracks - under the top ruler, over the bottom one. It was
// Theme.WindowBackground, which is all but invisible against the ruler's own surface, so
// the rulers bled into the track area. Same colour and weight as the gutter edge, so the
// three of them read as one frame around the tracks rather than three unrelated lines.
Paint.SetPen( Theme.TextControl.WithAlpha( 0.2f ) );
Paint.DrawLine(
new Vector2( 0f, ShowTimecode ? Height - 1f : 0f ),
new Vector2( Width, ShowTimecode ? Height - 1f : 0f ) );
PaintPlayhead( layout );
}
/// <summary>A flag pointing at the lanes, matching MovieMaker's - the top ruler's points down,
/// the bottom ruler's points up, so the playhead reads as one continuous marker through the
/// whole timeline rather than two unrelated arrows.</summary>
private void PaintPlayhead( RigTimelineLayout layout )
{
var x = layout.FrameToX( Playhead );
// The flag is drawn from its centre and is 8px wide, so it starts spilling into the name
// column half a flag before its frame reaches the divider - hence the half-width margin
// rather than a plain x < DividerX. The lanes' playhead has the same rule; this one was
// missed because it's a different widget drawing a different shape for the same thing.
if ( x + 4f < RigTimelineLayout.DividerX )
return;
Paint.ClearPen();
Paint.SetBrush( Theme.Yellow );
if ( ShowTimecode )
{
Paint.DrawRect( new Rect( x - 4f, Height - 11f, 8f, 7f ), 1f );
Paint.DrawPolygon(
new Vector2( x - 4f, Height - 5f ),
new Vector2( x + 4f, Height - 5f ),
new Vector2( x, Height - 1f ) );
return;
}
Paint.DrawRect( new Rect( x - 4f, 4f, 8f, 7f ), 1f );
Paint.DrawPolygon(
new Vector2( x - 4f, 5f ),
new Vector2( x + 4f, 5f ),
new Vector2( x, 1f ) );
}
protected override void OnMousePress( MouseEvent e )
{
if ( !e.LeftMouseButton || e.LocalPosition.x < RigTimelineLayout.Gutter )
return;
_scrubbing = true;
Scrub( e.LocalPosition );
e.Accepted = true;
}
protected override void OnMouseMove( MouseEvent e )
{
_lastMouseX = e.LocalPosition.x;
if ( !_scrubbing )
return;
Scrub( e.LocalPosition );
e.Accepted = true;
}
protected override void OnMouseReleased( MouseEvent e ) => _scrubbing = false;
private void Scrub( Vector2 position )
{
var layout = Geometry;
Scrubbed?.Invoke( layout.XToFrame( position.x ) );
}
}