Editor/RigControlEditor/RigBoneColumn.cs
using Editor;
using Marionette;
using Sandbox;
using System;
using System.Linq;

namespace Marionette.Tools;

/// <summary>
/// The bone-name column down the left of the timeline.
///
/// It used to be painted inside the lanes canvas at the current scroll offset, cancelling the
/// horizontal scroll so it appeared to hold still. That worked, but it meant the ScrollArea - and
/// therefore its scrollbar - began at the far left of the window while every ruler tick was offset
/// by the gutter, so the scrollbar visibly ran past where everything else started.
///
/// It's a real widget now, sitting beside the ScrollArea rather than inside it, so the scroll area
/// starts where the tracks start and the scrollbar lines up for free.
///
/// The objection to doing this earlier was vertical scroll: a second scrolling widget would have
/// to be kept in step with the first, and two scrolls that must agree is a worse bug than one
/// offset. That turns out not to apply - this widget doesn't scroll. It reads the lanes' vertical
/// offset when it paints and draws its rows shifted by it. One direction, no state, nothing to
/// fall out of sync.
/// </summary>
internal sealed class RigBoneColumn : Widget
{
	public RigAnimDocument Anim { get; set; }

	/// <summary>Which bone the viewport has selected, so the row can be marked.</summary>
	public string SelectedBone { get; set; }

	/// <summary>How far the tracks beside this have scrolled vertically. Pulled at paint time
	/// rather than pushed on change - the ScrollArea raises nothing we can hook, and reading it is
	/// cheap.</summary>
	public Func<float> ScrollY { get; set; }

	public Action<string> BoneSelected { get; set; }
	public Action<BoneTrack> ContextMenuRequested { get; set; }

	public RigBoneColumn( Widget parent ) : base( parent )
	{
		FixedWidth = RigTimelineLayout.Gutter;
	}

	private float Offset => ScrollY?.Invoke() ?? 0f;

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		Paint.ClearPen();
		Paint.SetBrush( Theme.WidgetBackground );
		Paint.DrawRect( LocalRect );

		var tracks = Anim?.BoneTracks;

		if ( tracks is not null )
		{
			var scrollY = Offset;

			for ( var i = 0; i < tracks.Count; i++ )
			{
				var top = i * RigTimelineLayout.RowHeight - scrollY;

				// Rows scrolled out of view still get walked, but not drawn - the count here is
				// bone tracks, not something unbounded.
				if ( top + RigTimelineLayout.RowHeight < 0f || top > Height )
					continue;

				PaintRow( tracks[i], top );
			}
		}

		// The edge the tracks begin at. Drawn last so no row background covers it.
		Paint.SetPen( Theme.TextControl.WithAlpha( 0.2f ) );
		Paint.DrawLine( new Vector2( Width - 1f, 0f ), new Vector2( Width - 1f, Height ) );
	}

	private void PaintRow( BoneTrack track, float top )
	{
		var isSelected = track.BoneName == SelectedBone;
		var row = new Rect( 0f, top, Width, RigTimelineLayout.RowHeight );

		Paint.ClearPen();

		if ( isSelected )
		{
			Paint.SetBrush( Theme.Yellow.WithAlpha( 0.12f ) );
			Paint.DrawRect( row );

			// A bar hard against the left edge, so the selected bone is findable without reading
			// any of the names.
			Paint.SetBrush( Theme.Yellow );
			Paint.DrawRect( new Rect( 0f, top, 2f, RigTimelineLayout.RowHeight ) );
		}

		Paint.SetDefaultFont( 7, isSelected ? 600 : 400 );
		Paint.SetPen( isSelected ? Theme.Yellow : Theme.TextControl.WithAlpha( 0.9f ) );
		Paint.DrawText( new Rect( 8f, top, Width - 16f, RigTimelineLayout.RowHeight ),
			track.BoneName, TextFlag.LeftCenter );
	}

	private BoneTrack TrackAt( Vector2 position )
	{
		if ( Anim is null )
			return null;

		var index = (int)((position.y + Offset) / RigTimelineLayout.RowHeight);

		return index >= 0 && index < Anim.BoneTracks.Count ? Anim.BoneTracks[index] : null;
	}

	protected override void OnMousePress( MouseEvent e )
	{
		if ( !e.LeftMouseButton || TrackAt( e.LocalPosition ) is not { } track )
			return;

		BoneSelected?.Invoke( track.BoneName );
		e.Accepted = true;
	}

	protected override void OnContextMenu( ContextMenuEvent e )
	{
		if ( TrackAt( e.LocalPosition ) is not { } track )
			return;

		ContextMenuRequested?.Invoke( track );
	}
}