Editor/Widgets/TimelineEditorCanvas.cs

Editor UI widgets for the timeline in the weapon animator. It implements the main TimelineEditorCanvas and sub-widgets: TimelineRangeNavigator, TimelineFrameRuler and TimelineTrackArea. It handles drawing the timeline ruler, visible-range navigator, dope sheet rows, key markers, marquee/key drag selection and mouse input to scrub, pan, zoom and move keys.

Native InteropFile Access
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

internal sealed class TimelineEditorCanvas : Widget
{
	internal const float TrackHeaderWidth = 170;
	internal const float GraphStartX = TrackHeaderWidth + 10;
	internal const float CurveTrackListWidth = 205;
	internal const float CurveAxisWidth = 52;
	internal const float ScrollbarGutter = WeaponAnimatorTheme.ScrollbarGutter;
	internal const float TrackHeight = 22;
	private readonly WeaponAnimatorController _controller;
	private readonly CurveEditorToolbar _curveToolbar;
	private readonly TimelineRangeNavigator _navigator;
	private readonly TimelineFrameRuler _ruler;
	private readonly TimelineTrackArea _tracks;
	private readonly CurveEditorArea _curves;

	public TimelineEditorCanvas(
		WeaponAnimatorController controller,
		Widget? parent = null ) : base( parent )
	{
		_controller = controller;
		MinimumSize = new Vector2( 320, 120 );
		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;
		SetStyles( "background-color: rgb(12,14,16); border: none;" );

		_curveToolbar = new CurveEditorToolbar( this, this )
		{
			FixedHeight = 34,
			Visible = false
		};
		_navigator = new TimelineRangeNavigator( this, this ) { FixedHeight = 22 };
		_ruler = new TimelineFrameRuler( this, this ) { FixedHeight = 24 };
		_tracks = new TimelineTrackArea( this, this );
		_curves = new CurveEditorArea( this, this ) { Visible = false };
		Layout.Add( _curveToolbar );
		Layout.Add( _navigator );
		Layout.Add( _ruler );
		Layout.Add( _tracks, 1 );
		Layout.Add( _curves, 1 );

		_controller.DocumentChanged += RefreshDocument;
		_controller.PoseChanged += RefreshCurveData;
		_controller.KeySelectionChanged += Refresh;
		_controller.TimelineChanged += Refresh;
		_controller.TimelineViewChanged += RefreshTimelineView;
		_controller.PlaybackChanged += Refresh;
	}

	public override void OnDestroyed()
	{
		_controller.DocumentChanged -= RefreshDocument;
		_controller.PoseChanged -= RefreshCurveData;
		_controller.KeySelectionChanged -= Refresh;
		_controller.TimelineChanged -= Refresh;
		_controller.TimelineViewChanged -= RefreshTimelineView;
		_controller.PlaybackChanged -= Refresh;
		base.OnDestroyed();
	}

	internal WeaponAnimatorController Controller => _controller;
	internal WeaponAnimationClip? Clip => _controller.Document.GetSelectedClip();

	internal TimelineFrameRange VisibleRange =>
		Clip is null
			? default
			: _controller.GetTimelineRange( Clip );

	internal bool CurveMode => _controller.Document.Workspace.CurveEditorVisible;
	internal float GraphLeft => CurveMode
		? CurveTrackListWidth + CurveAxisWidth
		: GraphStartX;

	internal float BodyRight( float width ) =>
		MathF.Max(
			GraphLeft + 1,
			CurveMode ? width : width - ScrollbarGutter );

	internal float FrameToX( float frame, float width )
	{
		var range = VisibleRange;
		var bodyWidth = MathF.Max( BodyRight( width ) - GraphLeft, 1 );
		return GraphLeft
			+ (frame - range.StartFrame) / MathF.Max( range.Span, 1) * bodyWidth;
	}

	internal int XToFrame( float x, float width )
	{
		var clip = Clip;
		if ( clip is null )
			return 0;
		var range = VisibleRange;
		var amount = (x - GraphLeft)
			/ MathF.Max( BodyRight( width ) - GraphLeft, 1 );
		return Math.Clamp(
			(int)MathF.Round( range.StartFrame + amount * range.Span ),
			0,
			TimelineInteraction.LastFrame( clip ) );
	}

	internal float FullFrameToX( float frame, float width )
	{
		var clip = Clip;
		if ( clip is null )
			return GraphLeft;
		return GraphLeft
			+ frame / TimelineInteraction.LastFrame( clip )
			* MathF.Max( BodyRight( width ) - GraphLeft, 1 );
	}

	internal int FullXToFrame( float x, float width )
	{
		var clip = Clip;
		if ( clip is null )
			return 0;
		var amount = (x - GraphLeft)
			/ MathF.Max( BodyRight( width ) - GraphLeft, 1 );
		return Math.Clamp(
			(int)MathF.Round( amount * TimelineInteraction.LastFrame( clip ) ),
			0,
			TimelineInteraction.LastFrame( clip ) );
	}

	internal void Zoom( bool zoomIn )
	{
		var clip = Clip;
		if ( clip is null )
			return;
		_controller.SetTimelineRange(
			clip,
			TimelineInteraction.Zoom(
				VisibleRange,
				TimelineInteraction.LastFrame( clip ),
				zoomIn ) );
	}

	internal void DrawLabelGutter( float height )
	{
		Paint.SetBrushAndPen( new Color( 0.086f, 0.094f, 0.104f ) );
		Paint.DrawRect( new Rect( 0, 0, GraphLeft, height ) );
	}

	private void Refresh()
	{
		_curveToolbar.Visible = CurveMode;
		_tracks.Visible = !CurveMode;
		_curves.Visible = CurveMode;
		_curveToolbar.Refresh();
		_navigator.Update();
		_ruler.Update();
		_tracks.Refresh();
		_curves.Refresh();
		Update();
	}

	private void RefreshDocument()
	{
		_curves.InvalidateData();
		Refresh();
	}

	private void RefreshCurveData()
	{
		_curves.InvalidateData();
		Refresh();
	}

	private void RefreshTimelineView()
	{
		_curves.InvalidateView();
		Refresh();
	}
}

internal sealed class TimelineRangeNavigator : Widget
{
	private const float HandleHitWidth = 8;
	private readonly TimelineEditorCanvas _timeline;
	private RangeDragMode _dragMode;
	private TimelineFrameRange _dragStartRange;
	private int _dragStartFrame;

	public TimelineRangeNavigator(
		TimelineEditorCanvas timeline,
		Widget? parent = null ) : base( parent )
	{
		_timeline = timeline;
		MouseTracking = true;
		Cursor = CursorShape.SizeH;
		ToolTip = "Drag the handles to zoom, or drag the cyan region to pan";
	}

	protected override void OnPaint()
	{
		Paint.SetBrushAndPen( new Color( 0.06f, 0.07f, 0.08f ) );
		Paint.DrawRect( LocalRect );
		_timeline.DrawLabelGutter( Height );
		var clip = _timeline.Clip;
		if ( clip is null )
			return;

		var bodyRight = _timeline.BodyRight( Width );
		var rail = new Rect(
			_timeline.GraphLeft,
			5,
			bodyRight - _timeline.GraphLeft,
			12 );
		Paint.SetBrushAndPen( WeaponAnimatorTheme.SurfaceRaised );
		Paint.DrawRect( rail, 2 );

		var range = _timeline.VisibleRange;
		var left = _timeline.FullFrameToX( range.StartFrame, Width );
		var right = _timeline.FullFrameToX( range.EndFrame, Width );
		var active = new Rect( left, rail.Top, MathF.Max( right - left, 1 ), rail.Height );
		Paint.SetBrushAndPen( WeaponAnimatorTheme.Cyan.WithAlpha( 0.24f ) );
		Paint.DrawRect( active, 2 );
		Paint.SetBrushAndPen( WeaponAnimatorTheme.Cyan.WithAlpha( 0.92f ) );
		Paint.DrawRect( new Rect( left - 2, rail.Top - 1, 4, rail.Height + 2 ), 1 );
		Paint.DrawRect( new Rect( right - 2, rail.Top - 1, 4, rail.Height + 2 ), 1 );

		var playheadFrame = TimelineInteraction.TimeToFrame(
			_timeline.Controller.Document.Workspace.TimelineTime,
			clip.SampleRate );
		var playhead = _timeline.FullFrameToX( playheadFrame, Width );
		Paint.SetPen( WeaponAnimatorTheme.Coral.WithAlpha( 0.9f ) );
		Paint.DrawLine(
			new Vector2( playhead, rail.Top ),
			new Vector2( playhead, rail.Bottom ) );

		Paint.SetDefaultFont( 8, 550 );
		Paint.SetPen( WeaponAnimatorTheme.Muted );
		Paint.DrawText(
			new Rect( 8, 0, TimelineEditorCanvas.TrackHeaderWidth - 16, Height ),
			"VISIBLE RANGE",
			TextFlag.LeftCenter );
	}

	protected override void OnMousePress( MouseEvent e )
	{
		if ( !e.LeftMouseButton || _timeline.Clip is null )
		{
			base.OnMousePress( e );
			return;
		}

		var range = _timeline.VisibleRange;
		var left = _timeline.FullFrameToX( range.StartFrame, Width );
		var right = _timeline.FullFrameToX( range.EndFrame, Width );
		_dragMode = MathF.Abs( e.LocalPosition.x - left ) <= HandleHitWidth
			? RangeDragMode.Start
			: MathF.Abs( e.LocalPosition.x - right ) <= HandleHitWidth
				? RangeDragMode.End
				: e.LocalPosition.x > left && e.LocalPosition.x < right
					? RangeDragMode.Body
					: RangeDragMode.None;
		if ( _dragMode == RangeDragMode.None )
			return;

		_dragStartRange = range;
		_dragStartFrame = _timeline.FullXToFrame( e.LocalPosition.x, Width );
		e.Accepted = true;
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		if ( _dragMode == RangeDragMode.None
			|| (e.ButtonState & MouseButtons.Left) == 0
			|| _timeline.Clip is not { } clip )
		{
			base.OnMouseMove( e );
			return;
		}

		var frame = _timeline.FullXToFrame( e.LocalPosition.x, Width );
		var last = TimelineInteraction.LastFrame( clip );
		var range = _dragMode switch
		{
			RangeDragMode.Start => TimelineInteraction.ResizeStart(
				_dragStartRange,
				frame,
				last ),
			RangeDragMode.End => TimelineInteraction.ResizeEnd(
				_dragStartRange,
				frame,
				last ),
			_ => TimelineInteraction.Pan(
				_dragStartRange,
				frame - _dragStartFrame,
				last )
		};
		_timeline.Controller.SetTimelineRange( clip, range );
		e.Accepted = true;
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		if ( _dragMode == RangeDragMode.None || e.Button != MouseButtons.Left )
		{
			base.OnMouseReleased( e );
			return;
		}
		_dragMode = RangeDragMode.None;
		e.Accepted = true;
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		if ( e.HasCtrl )
		{
			_timeline.Zoom( e.Delta > 0 );
			e.Accept();
			return;
		}
		base.OnMouseWheel( e );
	}

	private enum RangeDragMode
	{
		None,
		Start,
		End,
		Body
	}
}

internal sealed class TimelineFrameRuler : Widget
{
	private readonly TimelineEditorCanvas _timeline;
	private bool _scrubbing;

	public TimelineFrameRuler(
		TimelineEditorCanvas timeline,
		Widget? parent = null ) : base( parent )
	{
		_timeline = timeline;
		MouseTracking = true;
		Cursor = CursorShape.SizeH;
		ToolTip = "Drag to scrub whole frames";
	}

	protected override void OnPaint()
	{
		Paint.SetBrushAndPen( new Color( 0.075f, 0.083f, 0.092f ) );
		Paint.DrawRect( LocalRect );
		_timeline.DrawLabelGutter( Height );
		var clip = _timeline.Clip;
		if ( clip is null )
			return;

		var range = _timeline.VisibleRange;
		var bodyWidth = MathF.Max(
			_timeline.BodyRight( Width ) - _timeline.GraphLeft,
			1 );
		var spacing = TimelineInteraction.TickSpacing( bodyWidth / MathF.Max( range.Span, 1 ) );
		var first = (int)MathF.Ceiling( range.StartFrame / (float)spacing.MinorFrames )
			* spacing.MinorFrames;
		for ( var frame = first; frame <= range.EndFrame; frame += spacing.MinorFrames )
		{
			var x = _timeline.FrameToX( frame, Width );
			var major = frame % spacing.MajorFrames == 0;
			Paint.SetPen( Color.White.WithAlpha( major ? 0.18f : 0.075f ) );
			Paint.DrawLine(
				new Vector2( x, major ? 8 : 15 ),
				new Vector2( x, Height ) );
			if ( !major )
				continue;

			Paint.SetDefaultFont( 9, 500 );
			Paint.SetPen( WeaponAnimatorTheme.Muted );
			Paint.DrawText(
				new Rect( x + 4, 0, 52, Height ),
				frame.ToString(),
				TextFlag.LeftCenter );
		}

		Paint.SetDefaultFont( 8, 550 );
		Paint.SetPen( WeaponAnimatorTheme.Muted );
		Paint.DrawText(
			new Rect( 8, 0, TimelineEditorCanvas.TrackHeaderWidth - 16, Height ),
			"FRAME",
			TextFlag.LeftCenter );

		var playheadFrame = TimelineInteraction.TimeToFrame(
			_timeline.Controller.Document.Workspace.TimelineTime,
			clip.SampleRate );
		var playhead = _timeline.FrameToX( playheadFrame, Width );
		if ( IsGraphX( playhead ) )
		{
			Paint.SetPen( WeaponAnimatorTheme.Coral, 1.5f );
			Paint.DrawLine( new Vector2( playhead, 0 ), new Vector2( playhead, Height ) );
			Paint.SetBrushAndPen( WeaponAnimatorTheme.Coral );
			Paint.DrawRect( new Rect( playhead - 3, 0, 6, 7 ), 1 );
		}
	}

	protected override void OnMousePress( MouseEvent e )
	{
		if ( !e.LeftMouseButton
			|| e.LocalPosition.x < _timeline.GraphLeft
			|| e.LocalPosition.x > _timeline.BodyRight( Width ) )
		{
			base.OnMousePress( e );
			return;
		}

		_scrubbing = true;
		Scrub( e.LocalPosition.x );
		e.Accepted = true;
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		if ( !_scrubbing || (e.ButtonState & MouseButtons.Left) == 0 )
		{
			base.OnMouseMove( e );
			return;
		}
		Scrub( e.LocalPosition.x );
		e.Accepted = true;
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		if ( !_scrubbing || e.Button != MouseButtons.Left )
		{
			base.OnMouseReleased( e );
			return;
		}
		_scrubbing = false;
		e.Accepted = true;
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		if ( e.HasCtrl )
		{
			_timeline.Zoom( e.Delta > 0 );
			e.Accept();
			return;
		}
		base.OnMouseWheel( e );
	}

	private void Scrub( float x ) =>
		_timeline.Controller.SetTimelineFrame( _timeline.XToFrame( x, Width ) );

	private bool IsGraphX( float x ) =>
		x >= _timeline.GraphLeft
		&& x <= _timeline.BodyRight( Width );
}

internal sealed class TimelineTrackArea : BaseScrollWidget
{
	private const float KeyHitRadius = 8;
	private const float DragThreshold = 3;
	private readonly TimelineEditorCanvas _timeline;
	private TimelineGridDragMode _dragMode;
	private Vector2 _pressPosition;
	private Vector2 _dragPosition;
	private float _marqueePressContentY;
	private float _marqueeDragContentY;
	private bool _dragStarted;
	private bool _additiveSelection;
	private bool _toggleSelection;
	private HashSet<Guid> _selectionAtPress = [];
	private Dictionary<Guid, float> _dragStartTimes = [];
	private int _dragStartFrame;
	private int _dragDeltaFrames;
	private Guid _restoredClipId;
	private bool _restoringScroll;
	private bool _lastCurveMode;

	public TimelineTrackArea(
		TimelineEditorCanvas timeline,
		Widget? parent = null ) : base( parent )
	{
		_timeline = timeline;
		MinimumSize = new Vector2( 320, 72 );
		MouseTracking = true;
		FocusMode = FocusMode.Click;
		HorizontalScrollbarMode = ScrollbarMode.Off;
		VerticalScrollbarMode = ScrollbarMode.Auto;
		SetStyles( "background-color: rgb(12,14,16); border: none;" );
	}

	[Shortcut( "weaponanim.timeline.delete", "DEL" )]
	private void ShortcutDelete() => _timeline.Controller.DeleteSelectedKeys();

	[Shortcut( "weaponanim.timeline.backspace", "BACKSPACE" )]
	private void ShortcutBackspace() => _timeline.Controller.DeleteSelectedKeys();

	public void Refresh()
	{
		ConfigureScrollbar();
		Update();
	}

	protected override void OnPaint()
	{
		base.OnPaint();
		Paint.SetBrushAndPen( WeaponAnimatorTheme.Background );
		Paint.DrawRect( LocalRect );
		_timeline.DrawLabelGutter( Height );
		var clip = _timeline.Clip;
		if ( clip is null )
			return;

		ConfigureScrollbar();
		DrawGrid( clip );
		DrawDopeSheet( clip );

		var playheadFrame = TimelineInteraction.TimeToFrame(
			_timeline.Controller.Document.Workspace.TimelineTime,
			clip.SampleRate );
		var playheadX = _timeline.FrameToX( playheadFrame, Width );
		if ( IsGraphX( playheadX ) )
		{
			Paint.SetPen( WeaponAnimatorTheme.Coral, 1.5f );
			Paint.DrawLine(
				new Vector2( playheadX, 0 ),
				new Vector2( playheadX, Height ) );
		}

		if ( _dragMode == TimelineGridDragMode.Marquee )
		{
			var selection = SelectionRect();
			Paint.SetBrushAndPen( WeaponAnimatorTheme.Cyan.WithAlpha( 0.12f ) );
			Paint.DrawRect( selection, 1 );
			Paint.ClearBrush();
			Paint.SetPen( WeaponAnimatorTheme.Cyan.WithAlpha( 0.9f ) );
			Paint.DrawRect( selection, 1 );
		}

		var gutterLeft = _timeline.BodyRight( Width );
		Paint.SetBrushAndPen( new Color( 0.055f, 0.061f, 0.068f ) );
		Paint.DrawRect( new Rect( gutterLeft, 0, Width - gutterLeft, Height ) );
	}

	protected override void OnMousePress( MouseEvent e )
	{
		if ( !e.LeftMouseButton
			|| _timeline.Clip is not { } clip
			|| e.LocalPosition.x < _timeline.GraphLeft
			|| e.LocalPosition.x > _timeline.BodyRight( Width ) )
		{
			base.OnMousePress( e );
			return;
		}

		_pressPosition = _dragPosition = e.LocalPosition;
		_marqueePressContentY = _marqueeDragContentY =
			e.LocalPosition.y + VerticalScrollbar.Value;
		_dragStarted = false;
		_additiveSelection = e.HasShift;
		_toggleSelection = e.HasCtrl;
		_selectionAtPress = _timeline.Controller.SelectedKeys.ToHashSet();

		var playheadFrame = TimelineInteraction.TimeToFrame(
			_timeline.Controller.Document.Workspace.TimelineTime,
			clip.SampleRate );
		if ( MathF.Abs(
			_timeline.FrameToX( playheadFrame, Width )
			- e.LocalPosition.x ) <= 5 )
		{
			_dragMode = TimelineGridDragMode.Playhead;
			_timeline.Controller.SetTimelineFrame(
				_timeline.XToFrame( e.LocalPosition.x, Width ) );
			e.Accepted = true;
			return;
		}

		var hit = HitKey( clip, e.LocalPosition );
		if ( hit is null )
		{
			_dragMode = TimelineGridDragMode.Marquee;
			e.Accepted = true;
			return;
		}

		if ( _toggleSelection )
		{
			_timeline.Controller.ToggleSelectedKeys( [hit.Value.Id] );
			if ( !_timeline.Controller.SelectedKeys.Contains( hit.Value.Id ) )
			{
				_dragMode = TimelineGridDragMode.None;
				e.Accepted = true;
				return;
			}
		}
		else if ( _additiveSelection )
		{
			_timeline.Controller.SelectKeys( [hit.Value.Id], true );
		}
		else if ( !_timeline.Controller.SelectedKeys.Contains( hit.Value.Id ) )
		{
			_timeline.Controller.SetSelectedKeys( [hit.Value.Id] );
		}

		_dragMode = TimelineGridDragMode.Keys;
		_dragStartFrame = TimelineInteraction.TimeToFrame(
			hit.Value.Time,
			clip.SampleRate );
		_dragStartTimes = SelectedKeyTimes( clip );
		_timeline.Controller.BeginSelectedKeyMove();
		e.Accepted = true;
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		if ( _dragMode == TimelineGridDragMode.None
			|| (e.ButtonState & MouseButtons.Left) == 0
			|| _timeline.Clip is not { } clip )
		{
			base.OnMouseMove( e );
			return;
		}

		_dragPosition = e.LocalPosition;
		if ( _dragMode == TimelineGridDragMode.Marquee )
			_marqueeDragContentY = e.LocalPosition.y + VerticalScrollbar.Value;
		_dragStarted |= (_dragPosition - _pressPosition).Length >= DragThreshold;
		switch ( _dragMode )
		{
			case TimelineGridDragMode.Playhead:
				_timeline.Controller.SetTimelineFrame(
					_timeline.XToFrame( e.LocalPosition.x, Width ) );
				break;
			case TimelineGridDragMode.Marquee:
				ApplyMarqueeSelection( clip );
				Update();
				break;
			case TimelineGridDragMode.Keys when _dragStarted:
				var requested = _timeline.XToFrame( e.LocalPosition.x, Width )
					- _dragStartFrame;
				var startFrames = _dragStartTimes.Values.Select( x =>
					TimelineInteraction.TimeToFrame( x, clip.SampleRate ) );
				var delta = TimelineInteraction.ClampGroupFrameDelta(
					startFrames,
					requested,
					TimelineInteraction.LastFrame( clip ) );
				if ( delta == _dragDeltaFrames )
					break;
				_dragDeltaFrames = delta;
				_timeline.Controller.UpdateSelectedKeyMove(
					_dragStartTimes,
					_dragDeltaFrames );
				Update();
				break;
		}
		e.Accepted = true;
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		if ( _dragMode == TimelineGridDragMode.None || e.Button != MouseButtons.Left )
		{
			base.OnMouseReleased( e );
			return;
		}

		var clip = _timeline.Clip;
		if ( _dragMode == TimelineGridDragMode.Marquee && clip is not null )
		{
			_dragPosition = e.LocalPosition;
			_marqueeDragContentY = e.LocalPosition.y + VerticalScrollbar.Value;
			ApplyMarqueeSelection( clip );
		}
		else if ( _dragMode == TimelineGridDragMode.Keys )
		{
			if ( _dragStarted )
				_timeline.Controller.EndSelectedKeyMove(
					_dragStartTimes,
					_dragDeltaFrames );
			else
				_timeline.Controller.EndContinuousEdit();
		}

		_dragMode = TimelineGridDragMode.None;
		_dragStartTimes.Clear();
		_dragDeltaFrames = 0;
		Update();
		e.Accepted = true;
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		if ( e.HasCtrl )
		{
			_timeline.Zoom( e.Delta > 0 );
			e.Accept();
			return;
		}
		base.OnMouseWheel( e );
	}

	protected override void OnScrollChanged()
	{
		base.OnScrollChanged();
		if ( _restoringScroll
			|| _timeline.Clip is not { } clip
			|| _timeline.CurveMode )
			return;
		if ( _dragMode == TimelineGridDragMode.Marquee )
		{
			_marqueeDragContentY = _dragPosition.y + VerticalScrollbar.Value;
			ApplyMarqueeSelection( clip );
		}
		_timeline.Controller.SetTimelineVerticalScroll(
			clip,
			VerticalScrollbar.Value );
		Update();
	}

	private void ConfigureScrollbar()
	{
		var clip = _timeline.Clip;
		if ( clip is null )
			return;
		var curves = _timeline.CurveMode;
		var rowCount = curves ? 1 : TimelineRows( clip );
		var maximum = Math.Max(
			0,
			(int)MathF.Ceiling( rowCount * TimelineEditorCanvas.TrackHeight - Height ) );
		_restoringScroll = true;
		VerticalScrollbar.Minimum = 0;
		VerticalScrollbar.Maximum = maximum;
		VerticalScrollbar.SingleStep = (int)TimelineEditorCanvas.TrackHeight;
		VerticalScrollbar.PageStep = Math.Max( (int)Height, 1 );
		if ( _restoredClipId != clip.Id || _lastCurveMode != curves )
		{
			_restoredClipId = clip.Id;
			_lastCurveMode = curves;
			var saved = curves
				? 0
				: _timeline.Controller.Document.Workspace
					.GetTimelineView( clip.Id )?.VerticalScroll ?? 0;
			VerticalScrollbar.Value = Math.Clamp( (int)saved, 0, maximum );
		}
		_restoringScroll = false;
	}

	private void DrawGrid( WeaponAnimationClip clip )
	{
		var range = _timeline.VisibleRange;
		var bodyWidth = MathF.Max(
			_timeline.BodyRight( Width ) - _timeline.GraphLeft,
			1 );
		var spacing = TimelineInteraction.TickSpacing( bodyWidth / MathF.Max( range.Span, 1 ) );
		var first = (int)MathF.Ceiling( range.StartFrame / (float)spacing.MinorFrames )
			* spacing.MinorFrames;
		for ( var frame = first; frame <= range.EndFrame; frame += spacing.MinorFrames )
		{
			var x = _timeline.FrameToX( frame, Width );
			var major = frame % spacing.MajorFrames == 0;
			Paint.SetPen( Color.White.WithAlpha( major ? 0.10f : 0.035f ) );
			Paint.DrawLine( new Vector2( x, 0 ), new Vector2( x, Height ) );
		}
	}

	private void DrawDopeSheet( WeaponAnimationClip clip )
	{
		var parts = _timeline.Controller.Document.Rig.VisibilityParts;
		for ( var index = 0; index < parts.Count; index++ )
			DrawVisibilityRow( clip, parts[index], index );
		for ( var index = 0; index < clip.Tracks.Count; index++ )
			DrawTransformRow( clip, clip.Tracks[index], parts.Count + index );
		DrawTagRow( clip, parts.Count + clip.Tracks.Count );
	}

	private void DrawTransformRow(
		WeaponAnimationClip clip,
		TransformTrack track,
		int rowIndex )
	{
		var y = RowY( rowIndex );
		if ( y + TimelineEditorCanvas.TrackHeight < 0 || y > Height )
			return;
		DrawRowBackground( y, rowIndex );
		Paint.SetPen( TrackColor( track.Kind ) );
		Paint.DrawText(
			new Rect(
				8,
				y,
				TimelineEditorCanvas.TrackHeaderWidth - 12,
				TimelineEditorCanvas.TrackHeight ),
			track.Target,
			TextFlag.LeftCenter );
		foreach ( var key in track.Keys )
			DrawKey(
				key.Id,
				key.Time * clip.SampleRate,
				y,
				TrackColor( track.Kind ) );
	}

	private void DrawVisibilityRow(
		WeaponAnimationClip clip,
		WeaponVisibilityPart part,
		int rowIndex )
	{
		var y = RowY( rowIndex );
		if ( y + TimelineEditorCanvas.TrackHeight < 0 || y > Height )
			return;
		DrawRowBackground( y, rowIndex );
		Paint.SetPen( WeaponAnimatorTheme.Amber );
		Paint.DrawText(
			new Rect(
				8,
				y,
				TimelineEditorCanvas.TrackHeaderWidth - 12,
				TimelineEditorCanvas.TrackHeight ),
			$"◉  {part.Name}",
			TextFlag.LeftCenter );

		foreach ( var span in WeaponVisibilityEvaluator.BuildSpans( part, clip ) )
		{
			var start = _timeline.FrameToX( span.StartTime * clip.SampleRate, Width );
			var end = _timeline.FrameToX( span.EndTime * clip.SampleRate, Width );
			if ( end < _timeline.GraphLeft
				|| start > _timeline.BodyRight( Width ) )
				continue;
			start = MathF.Max( start, _timeline.GraphLeft );
			end = MathF.Min( end, _timeline.BodyRight( Width ) );
			var color = span.Visible
				? WeaponAnimatorTheme.Green
				: WeaponAnimatorTheme.Coral;
			Paint.SetBrushAndPen( color.WithAlpha( span.Visible ? 0.28f : 0.24f ) );
			Paint.DrawRect(
				new Rect(
					start,
					y + 4,
					MathF.Max( end - start, 1 ),
					TimelineEditorCanvas.TrackHeight - 8 ),
				2 );
		}

		var track = clip.VisibilityTracks.FirstOrDefault( x => x.PartId == part.Id );
		if ( track is null )
			return;
		foreach ( var key in track.Keys )
			DrawKey(
				key.Id,
				key.Time * clip.SampleRate,
				y,
				key.Visible ? WeaponAnimatorTheme.Green : WeaponAnimatorTheme.Coral );
	}

	private void DrawTagRow( WeaponAnimationClip clip, int rowIndex )
	{
		var y = RowY( rowIndex );
		if ( y + TimelineEditorCanvas.TrackHeight < 0 || y > Height )
			return;
		DrawRowBackground( y, rowIndex );
		Paint.SetPen( WeaponAnimatorTheme.Muted );
		Paint.DrawText(
			new Rect(
				8,
				y,
				TimelineEditorCanvas.TrackHeaderWidth - 12,
				TimelineEditorCanvas.TrackHeight ),
			"TAGS",
			TextFlag.LeftCenter );
		foreach ( var tag in clip.Tags )
		{
			var start = _timeline.FrameToX( tag.StartTime * clip.SampleRate, Width );
			var end = _timeline.FrameToX( tag.EndTime * clip.SampleRate, Width );
			if ( tag.Kind == AnimationTagKind.Point && !IsGraphX( start ) )
				continue;
			if ( tag.Kind == AnimationTagKind.Range
				&& (end < _timeline.GraphLeft
					|| start > _timeline.BodyRight( Width )) )
				continue;
			start = Math.Clamp(
				start,
				_timeline.GraphLeft,
				_timeline.BodyRight( Width ) );
			end = Math.Clamp(
				end,
				_timeline.GraphLeft,
				_timeline.BodyRight( Width ) );
			Paint.SetBrushAndPen( WeaponAnimatorTheme.Green.WithAlpha( 0.65f ) );
			if ( tag.Kind == AnimationTagKind.Point )
				Paint.DrawRect(
					new Rect(
						start - 2,
						y + 4,
						4,
						TimelineEditorCanvas.TrackHeight - 8 ),
					1 );
			else
				Paint.DrawRect(
					new Rect(
						start,
						y + 5,
						MathF.Max( end - start, 4 ),
						TimelineEditorCanvas.TrackHeight - 10 ),
					2 );
		}
	}

	private void DrawRowBackground( float y, int rowIndex )
	{
		Paint.SetBrushAndPen( rowIndex % 2 == 0
			? Color.White.WithAlpha( 0.018f )
			: Color.Transparent );
		Paint.DrawRect(
			new Rect(
				0,
				y,
				_timeline.BodyRight( Width ),
				TimelineEditorCanvas.TrackHeight ) );
	}

	private void DrawKey( Guid id, float frame, float y, Color color )
	{
		var x = _timeline.FrameToX( frame, Width );
		if ( !IsGraphX( x ) )
			return;
		var marker = TimelineInteraction.KeyMarkerPosition(
			x,
			y,
			_timeline.GraphLeft,
			_timeline.BodyRight( Width ),
			TimelineEditorCanvas.TrackHeight );
		var radius = TimelineInteraction.KeyMarkerRadius;
		var selected = _timeline.Controller.SelectedKeys.Contains( id );
		Paint.Antialiasing = true;
		Paint.SetBrushAndPen( selected ? Color.White : color );
		Paint.DrawPolygon(
		[
			new Vector2( marker.X, marker.Y - radius ),
			new Vector2( marker.X + radius, marker.Y ),
			new Vector2( marker.X, marker.Y + radius ),
			new Vector2( marker.X - radius, marker.Y )
		] );
		Paint.Antialiasing = false;
	}

	private TimelineKeyPoint? HitKey( WeaponAnimationClip clip, Vector2 position )
	{
		var row = (int)MathF.Floor(
			(position.y + VerticalScrollbar.Value)
			/ TimelineEditorCanvas.TrackHeight );
		foreach ( var key in KeyPoints( clip )
			.Where( x => x.Row == row )
			.OrderBy( x => MathF.Abs( x.Position.x - position.x ) ) )
		{
			if ( MathF.Abs( key.Position.x - position.x ) <= KeyHitRadius )
				return key;
		}
		return null;
	}

	private IEnumerable<TimelineKeyPoint> KeyPoints( WeaponAnimationClip clip )
	{
		var parts = _timeline.Controller.Document.Rig.VisibilityParts;
		for ( var row = 0; row < parts.Count; row++ )
		{
			var track = clip.VisibilityTracks.FirstOrDefault( x =>
				x.PartId == parts[row].Id );
			if ( track is null )
				continue;
			foreach ( var key in track.Keys )
			{
				var marker = TimelineInteraction.KeyMarkerPosition(
					_timeline.FrameToX( key.Time * clip.SampleRate, Width ),
					RowY( row ),
					_timeline.GraphLeft,
					_timeline.BodyRight( Width ),
					TimelineEditorCanvas.TrackHeight );
				yield return new TimelineKeyPoint(
					key.Id,
					key.Time,
					row,
					new Vector2( marker.X, marker.Y ) );
			}
		}

		for ( var index = 0; index < clip.Tracks.Count; index++ )
		{
			var row = parts.Count + index;
			foreach ( var key in clip.Tracks[index].Keys )
			{
				var marker = TimelineInteraction.KeyMarkerPosition(
					_timeline.FrameToX( key.Time * clip.SampleRate, Width ),
					RowY( row ),
					_timeline.GraphLeft,
					_timeline.BodyRight( Width ),
					TimelineEditorCanvas.TrackHeight );
				yield return new TimelineKeyPoint(
					key.Id,
					key.Time,
					row,
					new Vector2( marker.X, marker.Y ) );
			}
		}
	}

	private Dictionary<Guid, float> SelectedKeyTimes( WeaponAnimationClip clip ) =>
		clip.Tracks.SelectMany( x => x.Keys )
			.Select( x => (x.Id, x.Time) )
			.Concat( clip.VisibilityTracks.SelectMany( x => x.Keys )
				.Select( x => (x.Id, x.Time) ) )
			.Where( x => _timeline.Controller.SelectedKeys.Contains( x.Id ) )
			.ToDictionary( x => x.Id, x => x.Time );

	private void ApplyMarqueeSelection( WeaponAnimationClip clip )
	{
		var selection = SelectionRect();
		var hits = KeyPoints( clip )
			.Where( x => Contains( selection, x.Position ) )
			.Select( x => x.Id )
			.ToHashSet();
		var result = TimelineInteraction.CombineKeySelection(
			_selectionAtPress,
			hits,
			_additiveSelection,
			_toggleSelection );
		_timeline.Controller.SetSelectedKeys( result );
	}

	private Rect SelectionRect()
	{
		var bounds = TimelineInteraction.ProjectMarquee(
			_pressPosition.x,
			_marqueePressContentY,
			_dragPosition.x,
			_marqueeDragContentY,
			VerticalScrollbar.Value,
			_timeline.GraphLeft,
			_timeline.BodyRight( Width ) );
		return new Rect(
			bounds.Left,
			bounds.Top,
			bounds.Right - bounds.Left,
			bounds.Bottom - bounds.Top );
	}

	private float RowY( int row ) =>
		row * TimelineEditorCanvas.TrackHeight - VerticalScrollbar.Value;

	private int TimelineRows( WeaponAnimationClip clip ) =>
		TimelineInteraction.TrackRowCount(
			_timeline.Controller.Document,
			clip );

	private static bool Contains( Rect rect, Vector2 point ) =>
		point.x >= rect.Left
		&& point.x <= rect.Right
		&& point.y >= rect.Top
		&& point.y <= rect.Bottom;

	private bool IsGraphX( float x ) =>
		x >= _timeline.GraphLeft
		&& x <= _timeline.BodyRight( Width );

	private static Color TrackColor( RigControlKind kind ) => kind switch
	{
		RigControlKind.Weapon => WeaponAnimatorTheme.Amber,
		RigControlKind.Camera => WeaponAnimatorTheme.Green,
		_ => WeaponAnimatorTheme.Cyan
	};

	private readonly record struct TimelineKeyPoint(
		Guid Id,
		float Time,
		int Row,
		Vector2 Position );

	private enum TimelineGridDragMode
	{
		None,
		Marquee,
		Keys,
		Playhead
	}
}