Editor/TimelineEventEditor.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Editor.GraphicsItems;
using Sandbox;
namespace Timeline;
public partial class TimelineEventEditorWidget : Widget
{
public Action<EventTracks> ValueChanged { get; set; }
EventStopWidget eventBar;
TimelineAreaWidget timelineArea;
public EventStopWidget.Point selectedPoint;
[Range( 0.0f, 100.0f, slider: false ), Step( 0.1f ), Title( "Time (seconds)" )]
public float TimeValue
{
get => selectedPoint?.AbsoluteTime ?? 0.0f;
set
{
if ( selectedPoint is null )
return;
selectedPoint.AbsoluteTime = value;
// Convert back to normalized time for display
selectedPoint.Time = Value.Duration > 0 ? value / Value.Duration : 0;
UpdateFromPoints();
}
}
[Range( 0.1f, 10.0f, slider: false ), Step( 0.1f ), Title( "Track Duration (seconds)" )]
public float DurationValue
{
get => Value?.Duration ?? 10.0f;
set
{
if ( Value != null )
{
Value.Duration = Math.Max(0.1f, value);
UpdatePoints(); // Recalculate normalized positions
OnEdited();
}
}
}
public string NameValue
{
get => Value?.EventId ?? "";
set
{
if ( Value != null )
{
Value.EventId = value;
OnEdited();
}
}
}
Label labelMultiple;
ControlWidget editTime;
ControlWidget editDuration;
ControlWidget editName;
public EventTracks _value;
/// <summary>
/// The current event tracks value
/// </summary>
public EventTracks Value
{
get => _value;
set
{
_value = value;
Update();
ValueChanged?.Invoke( _value );
UpdatePoints();
}
}
public SerializedProperty SerializedProperty { get; set; }
public TimelineEventEditorWidget( Widget parent = null ) : base( parent )
{
_value = new EventTracks();
Layout = Layout.Column();
FocusMode = FocusMode.Click;
labelMultiple = Layout.Add( new Label( this ) );
labelMultiple.Text = "Multiple Values Selected. Making changes will modify all.";
labelMultiple.SetStyles( $"color: {Theme.MultipleValues.Hex};" );
labelMultiple.Visible = false;
// Timeline area for visualization
timelineArea = Layout.Add( new TimelineAreaWidget( this ) );
// Event stops bar
eventBar = Layout.Add( new EventStopWidget( this ) );
eventBar.OnAddPoint = ( normalizedTime ) =>
{
// Convert normalized time to absolute time
float absoluteTime = normalizedTime * Value.Duration;
_value.AddEvent( new TimelineEvent { Time = absoluteTime } );
UpdatePoints();
OnEdited();
};
Layout.AddSpacingCell( 6 );
var so = this.GetSerialized();
so.OnPropertyChanged = OnEventEdited;
var row = Layout.AddRow();
var controls = Layout.Grid();
controls.Margin = 8;
controls.Spacing = 8;
row.AddLayout( controls );
row.AddStretchCell( 1 );
{
editTime = controls.AddCell( 0, 0, new FloatControlWidget( so.GetProperty( "TimeValue" ) ) { Label = "Time", Icon = "timeline" } );
editTime.Enabled = false;
editTime.MaximumWidth = 200;
editTime.MaximumHeight = 25;
controls.AddCell( 1, 0, new Label( "Duration" ) );
editDuration = controls.AddCell( 2, 0, new FloatControlWidget( so.GetProperty( "DurationValue" ) ) { } );
editDuration.MaximumWidth = 200;
editDuration.MaximumHeight = 25;
editDuration.Enabled = false;
editName = controls.AddCell( 0, 1, new StringControlWidget( so.GetProperty( "NameValue" ) ) );
editName.MaximumWidth = 300;
editName.Enabled = false;
var options = controls.AddCell( 1, 1, Layout.Row(), alignment: TextFlag.Left );
options.Spacing = 8;
var delete = options.Add( new IconButton( "delete", DeletePoint ) );
delete.ToolTip = "Remove Event";
delete.Bind( "Enabled" ).ReadOnly().From( () => selectedPoint is not null, null );
options.AddStretchCell( 1 );
var selectNext = options.Add( new IconButton( "chevron_left", () => SelectNext( false ) ) { ToolTip = "Select previous" } );
selectNext.Bind( "Enabled" ).ReadOnly().From( () => selectedPoint is not null, null );
var selectPrev = options.Add( new IconButton( "chevron_right", () => SelectNext( true ) ) { ToolTip = "Select next" } );
selectPrev.Bind( "Enabled" ).ReadOnly().From( () => selectedPoint is not null, null );
options.Add( new IconButton( "more_horiz", DoMoreOptionsMenu ) );
}
}
private void SelectNext( bool forward )
{
if ( selectedPoint == null || eventBar.Points.Count == 0 )
return;
int currentIdx = selectedPoint.Index;
int nextIdx = (currentIdx + (forward ? 1 : -1) + eventBar.Points.Count) % eventBar.Points.Count;
UpdateSelection( eventBar.Points[nextIdx] );
}
private void DoMoreOptionsMenu()
{
var menu = new ContextMenu( this );
menu.AddOption( new Option( "Distribute Evenly", "balance", () =>
{
for ( int i = 0; i < eventBar.Points.Count; i++ )
{
float normalizedTime = (float)i / Math.Max( 1, eventBar.Points.Count - 1 );
eventBar.Points[i].Time = normalizedTime;
eventBar.Points[i].AbsoluteTime = normalizedTime * Value.Duration;
}
UpdateFromPoints();
} ) );
menu.AddOption( new Option( "Clear All Events", "delete_sweep", () =>
{
Value.Events = new List<TimelineEvent>();
eventBar.Points.Clear();
Update();
} ) );
menu.OpenAtCursor();
}
protected override void OnPaint()
{
labelMultiple.Visible = SerializedProperty?.IsMultipleDifferentValues ?? false;
editDuration.Enabled = true;
editName.Enabled = true;
}
public void OnEdited()
{
Update();
}
[Shortcut( "editor.delete", "DEL" )]
void DeletePoint()
{
if ( selectedPoint == null )
return;
eventBar.Points.Remove( selectedPoint );
UpdateSelection( null );
UpdateFromPoints();
}
private void OnEventEdited( SerializedProperty property )
{
UpdateFromPoints();
}
bool skipUpdatePoints;
public void UpdatePoints()
{
if ( skipUpdatePoints ) return;
eventBar.Points.Clear();
if ( Value.Events != null )
{
for ( int i = 0; i < Value.Events.Count; i++ )
{
var evt = Value.Events[i];
var p = new EventStopWidget.Point
{
Index = i,
Time = Value.Duration > 0 ? evt.Time / Value.Duration : 0, // Normalized for display
AbsoluteTime = evt.Time, // Store absolute time
Paint = PaintEvent,
Moved = ( p ) => UpdateFromPoints(),
Pressed = p => UpdateSelection( p )
};
eventBar.Points.Add( p );
}
}
eventBar.Update();
timelineArea.Update();
UpdateSelection( selectedPoint ); // Maintain selection if possible
}
private void UpdateFromPoints()
{
var val = Value ?? new EventTracks();
val.Events = new List<TimelineEvent>();
foreach ( var p in eventBar.Points )
{
if ( p.Disabled ) continue;
// Convert normalized time back to absolute time
float absoluteTime = p.Time * val.Duration;
p.AbsoluteTime = absoluteTime; // Update the stored absolute time
val.AddEvent( new TimelineEvent { Time = absoluteTime } );
}
skipUpdatePoints = true;
Value = val;
skipUpdatePoints = false;
}
void PaintEvent( EventStopWidget.Point p )
{
var box = p.Rect.Shrink( 2, 2, 2, 2 );
// Selection highlight
if ( selectedPoint == p )
{
Paint.SetPen( Theme.Blue, 3 );
Paint.ClearBrush();
Paint.DrawRect( box.Grow( 2 ), 3 );
}
// Event marker
Paint.SetPen( Theme.Primary, 2 );
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( box, 2 );
// Event type indicator (small colored dot)
var dotRect = new Rect( box.Center.x - 3, box.Center.y - 3, 6, 6 );
Paint.SetBrush( Color.Blue );
Paint.ClearPen();
Paint.DrawCircle( dotRect );
}
void UpdateSelection( EventStopWidget.Point p )
{
selectedPoint = p;
editTime.Enabled = p is not null;
}
}
// Timeline visualization area
class TimelineAreaWidget : Widget
{
private TimelineEventEditorWidget _eventEditor;
public TimelineAreaWidget( TimelineEventEditorWidget eventEditor )
{
_eventEditor = eventEditor;
FixedHeight = 64;
}
protected override void OnPaint()
{
base.OnPaint();
var rect = LocalRect.Shrink( 8, 4 );
// Timeline background
Paint.SetBrush( Theme.ControlBackground.Darken( 0.5f ) );
Paint.ClearPen();
Paint.DrawRect( rect, 4 );
// Draw duration info
var duration = _eventEditor.Value?.Duration ?? 10.0f;
Paint.SetFont( "Roboto", 9, 400 );
Paint.SetPen( Theme.TextControl.WithAlpha( 0.7f ) );
Paint.DrawText( rect.Shrink( 4, 2 ), $"Duration: {duration:F1}s", TextFlag.Left | TextFlag.Top );
// Timeline ruler with time markers
Paint.SetPen( Theme.TextControl.WithAlpha( 0.3f ), 1 );
for ( int i = 0; i <= 10; i++ )
{
float x = rect.Left + (rect.Width * i / 10f);
float tickHeight = i % 5 == 0 ? 8 : 4;
Paint.DrawLine( new Vector2( x, rect.Top + 12 ), new Vector2( x, rect.Top + 12 + tickHeight ) );
Paint.DrawLine( new Vector2( x, rect.Bottom ), new Vector2( x, rect.Bottom - tickHeight ) );
}
// Events
if ( _eventEditor.Value?.Events != null )
{
foreach ( var evt in _eventEditor.Value.Events )
{
// Calculate position based on absolute time
float normalizedTime = duration > 0 ? evt.Time / duration : 0;
float x = rect.Left + normalizedTime * rect.Width;
// Clamp to visible area
if ( x >= rect.Left && x <= rect.Right )
{
Paint.SetPen( Color.Blue, 3 );
Paint.DrawLine( new Vector2( x, rect.Top + 20 ), new Vector2( x, rect.Bottom - 20 ) );
}
}
}
}
protected override void OnMouseClick( MouseEvent e )
{
base.OnMouseClick( e );
var rect = LocalRect.Shrink( 8, 4 );
var normalizedTime = (e.LocalPosition.x - rect.Left) / rect.Width;
normalizedTime = normalizedTime.Clamp( 0, 1 );
// Convert to absolute time
var absoluteTime = normalizedTime * _eventEditor.Value.Duration;
// Add event at clicked position
_eventEditor._value.AddEvent( new TimelineEvent { Time = absoluteTime } );
_eventEditor.UpdatePoints();
_eventEditor.OnEdited();
}
}
// Event stops widget for dragging events
public class EventStopWidget : Widget
{
public Action<float> OnAddPoint;
Point Pressed;
Point Hovered;
public class Point
{
public int Index { get; set; }
public float Time { get; set; } // Normalized time (0-1) for display positioning
public float AbsoluteTime { get; set; } // Absolute time in seconds
public Action<Point> Paint { get; set; }
public Action<Point> Pressed { get; set; }
public Action<Point> Moved { get; set; }
public Rect Rect { get; set; }
public bool Disabled { get; set; }
}
public List<Point> Points = new();
public EventStopWidget( Widget parent ) : base( parent )
{
FixedHeight = 24;
MouseTracking = true;
}
protected override void OnPaint()
{
var w = 16;
Paint.Antialiasing = true;
foreach ( var p in Points )
{
if ( p.Disabled ) continue;
var x = 8 + p.Time * (LocalRect.Width - 16.0f);
p.Rect = new Rect( x - (w / 2), 2, w, Height - 4 );
Paint.SetFlags( false, Hovered == p, Pressed == p, false, true );
p.Paint?.Invoke( p );
}
}
float LocalToTime( float local ) => (local - 8) / (Width - 16.0f);
protected override void OnMouseMove( MouseEvent e )
{
if ( Pressed is not null )
{
Pressed.Disabled = !LocalRect.Grow( 256, 16 ).IsInside( e.LocalPosition );
Pressed.Time = LocalToTime( e.LocalPosition.x ).Clamp( 0, 1 );
Pressed.Moved?.Invoke( Pressed );
Cursor = CursorShape.Finger;
Update();
return;
}
Hovered = null;
foreach ( var p in Points )
{
var x = 8 + p.Time * (LocalRect.Width - 16.0f);
if ( MathF.Abs( x - e.LocalPosition.x ) < 8.0f )
{
Hovered = p;
}
}
Cursor = Hovered == null ? CursorShape.Arrow : CursorShape.Finger;
Update();
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
var delta = LocalToTime( e.LocalPosition.x ).Clamp( 0, 1 );
if ( Hovered is not null )
{
Pressed = Hovered;
Pressed?.Pressed?.Invoke( Pressed );
return;
}
OnAddPoint?.Invoke( delta );
Pressed = Points.FirstOrDefault( x => MathF.Abs( x.Time - delta ) < 0.01f );
Pressed?.Pressed?.Invoke( Pressed );
Update();
}
protected override void OnMouseReleased( MouseEvent e )
{
if ( Pressed is not null && Pressed.Disabled )
{
Points.Remove( Pressed );
}
Pressed = null;
Update();
}
protected override void OnMouseLeave()
{
Hovered = null;
Update();
}
}