Editor/TextToAnimation/UI/TimelineWidget.cs

A UI widget for the timeline under the viewport in the editor. Draws the ruler, playhead, range selection, pinned frames, keyframes and events, and handles mouse interactions for scrubbing, range selection, pinning, moving keys, zooming and context menu building.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
using TextToAnimation.Animation;
using TextToAnimation.Editor.Session;

namespace TextToAnimation.Editor.UI;

/// <summary>
/// The timeline under the viewport: a ruler with the playhead, a range selection, and three lanes - pinned
/// frames (poses kept when regenerating), keys (pose edits of the selected bones, or of all bones) and events
/// (footsteps). Click or drag the ruler to scrub; drag across the lanes to highlight frames, and the range bar above the
/// timeline keeps, deletes, repeats or reverses them; drag a key to move it; right click for every
/// timeline action (split, trim before/after, reverse…); wheel to zoom.
/// </summary>
public sealed class TimelineWidget : Widget
{
	readonly EditorSession _session;
	public Action<Menu, int> BuildContextMenu { get; set; }

	const float RulerHeight = 22f;
	const float LaneHeight = 18f;
	const float LabelWidth = 64f;
	static readonly string[] Lanes = { "Pinned", "Keys", "Events" };

	float _zoom = 1f;      // 1 = whole clip fits
	float _scroll;         // first visible frame
	enum Drag { None, Scrub, Pending, Range, Key }
	Drag _drag;
	int _dragStartFrame;
	float _pressX;
	int? _anchor; // the first point of a two-click highlight
	int _keyFrom, _keyTo;

	public TimelineWidget( Widget parent, EditorSession session ) : base( parent )
	{
		_session = session;
		MinimumHeight = RulerHeight + LaneHeight * Lanes.Length + 10;
		MouseTracking = true;
		FocusMode = FocusMode.Click;
		_session.Changed += c => { if ( (c & (SessionChange.Playhead | SessionChange.ClipData | SessionChange.ActiveClip | SessionChange.Selection)) != 0 ) Update(); };
	}

	int FrameCount => Math.Max( 1, _session.ActiveClip?.FrameCount ?? 1 );
	float TrackLeft => LabelWidth;
	float TrackWidth => Math.Max( 10f, Width - LabelWidth - 8f );
	float VisibleFrames => Math.Max( 4f, (FrameCount - 1) / _zoom );
	float PixelsPerFrame => TrackWidth / Math.Max( 1f, VisibleFrames );

	float FrameToX( float frame ) => TrackLeft + (frame - _scroll) * PixelsPerFrame;
	float XToFrame( float x ) => _scroll + (x - TrackLeft) / PixelsPerFrame;
	int SnapFrame( float x ) => Math.Clamp( (int)MathF.Round( XToFrame( x ) ), 0, FrameCount - 1 );

	IEnumerable<int> KeyFrames()
	{
		var clip = _session.ActiveClip;
		if ( clip is null ) return Enumerable.Empty<int>();
		if ( _session.SelectedBones.Count == 0 ) return clip.Keys.AllKeyFrames;
		var names = _session.SelectedBones.Select( b => _session.Rig.Skeleton[b].Name ).ToList();
		return names.SelectMany( clip.Keys.KeyFrames ).Distinct().OrderBy( f => f );
	}

	protected override void OnPaint()
	{
		var started = FrameProbe.Now;
		try { PaintTimeline(); }
		finally { FrameProbe.Add( "timeline paint", FrameProbe.Now - started ); }
	}

	void PaintTimeline()
	{
		Paint.Antialiasing = true;
		Paint.ClearPen();
		Paint.SetBrush( Theme.WindowBackground );
		Paint.DrawRect( LocalRect, 4 );

		var clip = _session.ActiveClip;
		if ( clip is null || clip.FrameCount == 0 )
		{
			Paint.SetPen( Theme.TextLight );
			Paint.SetDefaultFont( 8 );
			Paint.DrawText( LocalRect, "No animation selected", TextFlag.Center );
			return;
		}

		var fps = clip.Fps;
		var trackRect = new Rect( TrackLeft, 0, TrackWidth, Height );

		// range selection
		if ( _session.Range is { } range )
		{
			// only the lanes: the ruler numbers and the lane labels stay clear
			var x0 = MathF.Max( FrameToX( range.Start ), TrackLeft ); var x1 = MathF.Max( FrameToX( range.End ), TrackLeft );
			Paint.ClearPen();
			Paint.SetBrush( TaStyle.Accent.WithAlpha( .2f ) );
			Paint.DrawRect( new Rect( x0, RulerHeight, Math.Max( 2, x1 - x0 ), Height - RulerHeight ) );
			foreach ( var x in new[] { FrameToX( range.Start ), FrameToX( range.End ) } )
			{
				if ( x < TrackLeft ) continue;
				Paint.SetPen( TaStyle.AccentLight, 2 );
				Paint.DrawLine( new Vector2( x, RulerHeight ), new Vector2( x, Height ) );
				Paint.ClearPen();
				Paint.SetBrush( TaStyle.AccentLight );
				Paint.DrawRect( new Rect( x - 3, Height - 16, 6, 12 ), 2 ); // grab handle
			}
		}
		else if ( _anchor is int a && _drag != Drag.Range )
		{
			// the first point of a two-point highlight
			var ax = FrameToX( a );
			Paint.SetPen( TaStyle.AccentLight.WithAlpha( .5f ), 1 );
			Paint.DrawLine( new Vector2( ax, RulerHeight ), new Vector2( ax, Height ) );
		}

		// ruler ticks: choose a step that keeps labels ~60px apart
		Paint.SetDefaultFont( 7 );
		var step = NiceStep( 60f / PixelsPerFrame );
		var first = (int)MathF.Floor( _scroll / step ) * step;
		var badge = FrameToX( _session.Playhead );
		for ( var f = first; f <= Math.Min( FrameCount - 1, _scroll + VisibleFrames + step ); f += step )
		{
			if ( f < 0 ) continue;
			var x = FrameToX( f );
			if ( x < TrackLeft - 1 || x > Width ) continue;
			Paint.SetPen( Color.White.WithAlpha( .12f ), 1 );
			Paint.DrawLine( new Vector2( x, RulerHeight - 6 ), new Vector2( x, Height ) );
			// labels never hide under the playhead badge or run off the right edge
			var label = $"{f}  {f / fps:0.0#}s";
			var w = Paint.MeasureText( label ).x;
			if ( x + 3 + w > Width - 4 ) continue;
			if ( x + 3 + w > badge - 16 && x + 3 < badge + 16 ) continue;
			Paint.SetPen( Theme.TextLight );
			Paint.DrawText( new Rect( x + 3, 2, w + 2, RulerHeight - 6 ), label, TextFlag.LeftCenter );
		}

		// lanes
		for ( var lane = 0; lane < Lanes.Length; lane++ )
		{
			var y = RulerHeight + lane * LaneHeight;
			Paint.SetPen( Color.White.WithAlpha( .05f ), 1 );
			Paint.DrawLine( new Vector2( 0, y ), new Vector2( Width, y ) );
			Paint.SetPen( Theme.TextLight );
			Paint.SetDefaultFont( 7, 600 );
			Paint.DrawText( new Rect( 8, y, LabelWidth - 10, LaneHeight ), Lanes[lane].ToUpperInvariant(), TextFlag.LeftCenter );
		}

		// pinned frames
		foreach ( var pin in clip.PinnedFrames ) Diamond( FrameToX( pin ), RulerHeight + LaneHeight * 0.5f, 5f, Theme.Yellow );
		// keys
		var keyColor = _session.SelectedBones.Count == 0 ? TaStyle.Accent.WithAlpha( .7f ) : TaStyle.AccentLight;
		foreach ( var key in KeyFrames() )
		{
			var x = _drag == Drag.Key && key == _keyFrom ? FrameToX( _keyTo ) : FrameToX( key );
			Diamond( x, RulerHeight + LaneHeight * 1.5f, 5f, keyColor );
		}
		// events
		foreach ( var e in clip.Events )
		{
			var x = FrameToX( e.Frame );
			var y = RulerHeight + LaneHeight * 2f;
			Paint.ClearPen();
			Paint.SetBrush( (e.Foot == "1" ? Color.Lerp( TaStyle.Accent, Color.White, .4f ) : TaStyle.Accent).WithAlpha( .85f ) );
			Paint.DrawRect( new Rect( x - 1.5f, y + 3, 3, LaneHeight - 6 ), 1 );
		}

		// playhead
		var px = FrameToX( _session.Playhead );
		Paint.SetPen( TaStyle.Accent, 2 );
		Paint.DrawLine( new Vector2( px, 0 ), new Vector2( px, Height ) );
		Paint.ClearPen();
		Paint.SetBrush( TaStyle.Accent );
		Paint.DrawRect( new Rect( px - 14, 1, 28, 14 ), 3 );
		Paint.SetPen( Color.White );
		Paint.SetDefaultFont( 7, 600 );
		Paint.DrawText( new Rect( px - 14, 1, 28, 14 ), _session.CurrentFrame.ToString(), TextFlag.Center );
		Paint.SetPen( Theme.ControlBackground.Lighten( .4f ), 1 );
		Paint.ClearBrush();
		Paint.DrawRect( LocalRect.Shrink( .5f ), 4 );
	}

	static void Diamond( float x, float y, float r, Color color )
	{
		Paint.ClearPen();
		Paint.SetBrush( color );
		Paint.DrawPolygon( new[] { new Vector2( x, y - r ), new Vector2( x + r, y ), new Vector2( x, y + r ), new Vector2( x - r, y ) } );
	}

	static int NiceStep( float framesPerLabel )
	{
		foreach ( var s in new[] { 1, 2, 5, 10, 15, 30, 60, 120, 300, 600 } )
			if ( s >= framesPerLabel ) return s;
		return 1200;
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );
		if ( _session.ActiveClip is null ) return;
		if ( e.RightMouseButton )
		{
			var frame = SnapFrame( e.LocalPosition.x );
			var menu = new Menu( this );
			BuildContextMenu?.Invoke( menu, frame );
			menu.OpenAtCursor();
			return;
		}
		if ( !e.LeftMouseButton ) return;
		var mx = e.LocalPosition.x;
		var f = SnapFrame( mx );
		var lane = (int)((e.LocalPosition.y - RulerHeight) / LaneHeight);
		if ( lane == 1 && KeyFrames().Any( k => MathF.Abs( FrameToX( k ) - mx ) < 6 ) )
		{
			_drag = Drag.Key;
			_keyFrom = _keyTo = KeyFrames().OrderBy( k => MathF.Abs( FrameToX( k ) - mx ) ).First();
			return;
		}
		_session.Playing = false;
		// grab an edge of the highlight to adjust it
		if ( _session.Range is { } r )
		{
			if ( MathF.Abs( mx - FrameToX( r.Start ) ) < 6 ) { _drag = Drag.Range; _dragStartFrame = r.End; e.Accepted = true; return; }
			if ( MathF.Abs( mx - FrameToX( r.End ) ) < 6 ) { _drag = Drag.Range; _dragStartFrame = r.Start; e.Accepted = true; return; }
		}
		if ( e.HasShift )
		{
			// two points: highlight from the first click (or the playhead) to here; dragging keeps adjusting
			var from = _anchor ?? _session.CurrentFrame;
			_drag = Drag.Range;
			_dragStartFrame = from;
			_session.SetRange( Math.Min( from, f ), Math.Max( from, f ) );
			e.Accepted = true;
				return;
		}
		if ( e.LocalPosition.y > RulerHeight )
		{
			// in the lanes: a click scrubs and sets the first point, a drag highlights frames
			_drag = Drag.Pending;
			_dragStartFrame = f;
			_anchor = f;
			_pressX = mx;
			_session.Seek( f );
			e.Accepted = true;
			return;
		}
		_drag = Drag.Scrub;
		_session.Seek( f );
		e.Accepted = true;
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );
		var f = SnapFrame( e.LocalPosition.x );
		if ( _drag == Drag.None )
			Cursor = _session.Range is { } hr && (MathF.Abs( e.LocalPosition.x - FrameToX( hr.Start ) ) < 6 || MathF.Abs( e.LocalPosition.x - FrameToX( hr.End ) ) < 6)
				? CursorShape.SizeH : CursorShape.Arrow;
		switch ( _drag )
		{
			case Drag.Scrub: _session.Seek( f ); break;
			case Drag.Pending when MathF.Abs( e.LocalPosition.x - _pressX ) > 4:
				_drag = Drag.Range;
				_session.SetRange( Math.Min( _dragStartFrame, f ), Math.Max( _dragStartFrame, f ) );
				break;
			case Drag.Range: _session.SetRange( Math.Min( _dragStartFrame, f ), Math.Max( _dragStartFrame, f ) ); break;
			case Drag.Key: _keyTo = f; Update(); break;
		}
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		base.OnMouseReleased( e );
		if ( _drag == Drag.Key && _keyTo != _keyFrom )
		{
			var bones = _session.SelectedBones.Count == 0 ? null : _session.SelectedBones.Select( b => _session.Rig.Skeleton[b].Name ).ToList();
			var from = _keyFrom; var to = _keyTo;
			_session.Edit( $"Move keys {from} → {to}", c => c.Keys.MoveKeys( from, to, bones ) );
		}
		if ( _drag == Drag.Pending ) _session.SetRange( null, null ); // a plain click clears the highlight
		_drag = Drag.None;
		Update();
	}

	protected override void OnWheel( WheelEvent e )
	{
		base.OnWheel( e );
		var anchor = XToFrame( e.Position.x );
		_zoom = Math.Clamp( _zoom * (e.Delta > 0 ? 1.15f : 1f / 1.15f), 1f, Math.Max( 1f, FrameCount / 8f ) );
		_scroll = Math.Clamp( anchor - (e.Position.x - TrackLeft) / PixelsPerFrame, 0, Math.Max( 0, FrameCount - 1 - VisibleFrames ) );
		Update();
		e.Accept();
	}

	protected override void OnDoubleClick( MouseEvent e )
	{
		base.OnDoubleClick( e );
		var lane = (int)((e.LocalPosition.y - RulerHeight) / LaneHeight);
		var f = SnapFrame( e.LocalPosition.x );
		if ( lane == 0 ) TogglePin( f );
	}

	public void TogglePin( int frame )
	{
		_session.Edit( _session.ActiveClip?.PinnedFrames.Contains( frame ) == true ? $"Unpin frame {frame}" : $"Pin frame {frame}",
			c => { if ( !c.PinnedFrames.Remove( frame ) ) c.PinnedFrames.Add( frame ); } );
	}

	public void ResetZoom() { _zoom = 1f; _scroll = 0; Update(); }

	/// <summary>Sets the start (in) or end (out) of the highlight at <paramref name="frame"/> (I / O keys).</summary>
	public void SetInOut( int frame, bool isIn )
	{
		var r = _session.Range;
		var start = isIn ? frame : r?.Start ?? _anchor ?? frame;
		var end = isIn ? r?.End ?? frame : frame;
		if ( start > end ) (start, end) = (end, start);
		if ( isIn ) _anchor = frame;
		if ( start == end ) { _anchor = frame; Update(); return; } // one point so far: it waits for the other
		_session.SetRange( start, end );
	}

	/// <summary>Runs an edit on the highlighted frames, then clears the highlight.</summary>
	public static void RangeEdit( EditorSession session, string label, Action<AnimClip, int, int> op )
	{
		if ( session.Range is not { } r ) return;
		session.Edit( label, c => op( c, r.Start, r.End ) );
		session.SetRange( null, null );
	}

	/// <summary>Copies the highlighted frames into a new animation next to this one.</summary>
	public static void CopyRangeToNewClip( EditorSession session )
	{
		if ( session.Range is not { } r || session.ActiveClip is not { } clip ) return;
		var copy = clip.Duplicate( $"{clip.Name} ({r.Start}-{r.End})" );
		ClipOps.Crop( copy, session.Rig, r.Start, r.End );
		session.SetRange( null, null );
		session.AddClip( copy );
	}
}