Files
sbox-public/game/editor/MovieMaker/Code/Editor/TrackWidget.cs
s&box team 71f266059a Open source release
This commit imports the C# engine code and game files, excluding C++ source code.

[Source-Commit: ceb3d758046e50faa6258bc3b658a30c97743268]
2025-11-24 09:05:18 +00:00

803 lines
18 KiB
C#

using Editor.NodeEditor;
using Sandbox.MovieMaker;
using Sandbox.MovieMaker.Properties;
using Sandbox.UI;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
namespace Editor.MovieMaker;
#nullable enable
/// <summary>
/// An item in the <see cref="TrackListWidget"/>, showing the name of a track with buttons to configure it.
/// </summary>
public partial class TrackWidget : Widget
{
public TrackListWidget TrackList { get; }
public new TrackWidget? Parent { get; }
public new IEnumerable<TrackWidget> Children => _children;
public TrackView View { get; }
RealTimeSince _timeSinceInteraction = 1000;
private readonly Label? _label;
private readonly Button _collapseButton;
private readonly Button _lockButton;
private readonly Layout _childLayout;
private readonly SynchronizedSet<TrackView, TrackWidget> _children;
private ControlWidget? _controlWidget;
public TrackWidget( TrackListWidget trackList, TrackWidget? parent, TrackView view )
: base( (Widget?)parent ?? trackList )
{
TrackList = trackList;
Parent = parent;
View = view;
FocusMode = FocusMode.TabOrClickOrWheel;
VerticalSizeMode = SizeMode.CanGrow;
_children = new SynchronizedSet<TrackView, TrackWidget>(
AddChildTrack, RemoveChildTrack, UpdateChildTrack );
ToolTip = View.Description;
View.Changed += View_Changed;
View.ValueChanged += View_ValueChanged;
Layout = Layout.Column();
var row = Layout.AddRow();
row.Spacing = 4f;
row.Margin = 4f;
_childLayout = Layout.Add( Layout.Column() );
_childLayout.Margin = new Margin( 8f, 0f, 0f, 0f );
_collapseButton = new CollapseButton( this );
row.Add( _collapseButton );
if ( !AddReferenceControl( row ) )
{
row.AddSpacingCell( 8f );
_label = row.Add( new Label( view.Target.Name ) );
}
row.AddStretchCell();
row.AddSpacingCell( 24f );
_lockButton = row.Add( new LockButton( this ) );
View_Changed( view );
}
private TrackWidget AddChildTrack( TrackView source ) => new( TrackList, this, source );
private void RemoveChildTrack( TrackWidget item ) => item.Destroy();
private bool UpdateChildTrack( TrackView source, TrackWidget item ) => item.UpdateLayout();
public bool UpdateLayout()
{
_children.Update( View.IsExpanded ? View.Children : [] );
_childLayout.Clear( false );
foreach ( var child in _children )
{
_childLayout.Add( child );
}
return true;
}
private bool AddReferenceControl( Layout layout )
{
if ( View.Target is not ITrackReference reference ) return false;
if ( reference is { IsBound: true, Value: GameObject go } && (go.Flags & GameObjectFlags.Bone) != 0 && View.Parent is not null ) return false;
// Add control to retarget a scene reference (Component / GameObject)
_controlWidget = null;
if ( View.Track is ProjectSequenceTrack )
{
//
}
else if ( reference is ITrackReference<GameObject> goReference )
{
_controlWidget = ControlWidget.Create( EditorTypeLibrary.CreateProperty( reference.Name,
() => goReference.Value, goReference.Bind ) );
}
else
{
var helperType = typeof( ReflectionHelper<> ).MakeGenericType( reference.TargetType );
var createControlMethod = helperType.GetMethod( nameof( ReflectionHelper<IValid>.CreateControlWidget ),
BindingFlags.Static | BindingFlags.Public )!;
_controlWidget = (ControlWidget)createControlMethod.Invoke( null, [View.Track, reference] )!;
}
if ( !_controlWidget.IsValid() ) return false;
_controlWidget.MaximumWidth = 300;
layout.Add( _controlWidget );
return true;
}
private void View_Changed( TrackView view )
{
var labelColor = new Color( 0.6f, 0.6f, 0.6f );
_collapseButton.Visible = view.Children.Count > 0;
_lockButton.Update();
_collapseButton.Update();
if ( _controlWidget is not null )
{
_controlWidget.Enabled = !View.IsLocked;
}
if ( _label is not null )
{
_label.Color = !View.IsLocked ? IsSelected ? Color.White : labelColor : labelColor.Darken( 0.25f );
}
Update();
}
private void View_ValueChanged( TrackView view )
{
_timeSinceInteraction = 0f;
Update();
}
public override void OnDestroyed()
{
View.Changed -= View_Changed;
View.ValueChanged -= View_ValueChanged;
base.OnDestroyed();
}
protected override void OnMouseClick( MouseEvent e )
{
base.OnMouseClick( e );
if ( e.LeftMouseButton )
{
View.Select();
e.Accepted = true;
}
}
protected override void OnDoubleClick( MouseEvent e )
{
View.InspectProperty();
e.Accepted = true;
}
protected override void OnMouseEnter()
{
View.IsHovered = true;
base.OnMouseEnter();
}
protected override void OnMouseLeave()
{
View.IsHovered = false;
base.OnMouseLeave();
}
protected override Vector2 SizeHint()
{
return 32;
}
public bool IsSelected => View.IsSelected;
public Color BackgroundColor
{
get
{
var canModify = !View.IsLocked;
var defaultColor = Theme.SurfaceBackground.LerpTo( Theme.ControlBackground, canModify ? 0f : 0.5f );
var hoveredColor = defaultColor.Lighten( 0.25f );
var selectedColor = Color.Lerp( defaultColor, Theme.Primary, canModify ? 0.5f : 0.2f );
var isHovered = canModify && View.IsHovered;
return IsSelected ? selectedColor
: isHovered ? hoveredColor
: defaultColor;
}
}
protected override void OnPaint()
{
Paint.Antialiasing = false;
Paint.SetBrushAndPen( BackgroundColor );
Paint.DrawRect( new Rect( LocalRect.Left + 1f, LocalRect.Top + 1f, LocalRect.Width - 2f, Timeline.TrackHeight - 2f ), 4 );
if ( _timeSinceInteraction < 2.0f )
{
var delta = _timeSinceInteraction.Relative.Remap( 2.0f, 0, 0, 1 );
Paint.SetBrush( Theme.Yellow.WithAlpha( delta ) );
Paint.DrawRect( new Rect( LocalRect.Right - 4, LocalRect.Top, 32, Timeline.TrackHeight ) );
Update();
}
}
private Menu? _menu;
protected override void OnContextMenu( ContextMenuEvent e )
{
e.Accepted = true;
ShowContextMenu();
}
public void ShowContextMenu()
{
_menu = new Menu( this );
_menu.AddHeading( $"{View.Title} Track" );
if ( View.Track is ProjectSequenceTrack sequenceTrack )
{
var rename = _menu.AddMenu( "Rename", "edit" );
rename.AddLineEdit( "Name", sequenceTrack.Name, autoFocus: true, onSubmit: OnRename );
}
_menu.AddOption( "Remove", "delete", Remove );
if ( CanMoveToRoot )
{
_menu.AddOption( "Move to Root", "subdirectory_arrow_left", MoveToRoot );
}
if ( CanMoveToParent( out var parentTrack ) )
{
_menu.AddOption( "Move to Parent", "subdirectory_arrow_right", () => MoveToParent( parentTrack ) );
}
if ( CanHaveSubTracks )
{
CreateSubTrackMenu( _menu );
}
_menu.OpenAtCursor();
}
private bool? GetAggregateLockState( IEnumerable<TrackView> trackViews )
{
var anyLocked = false;
var anyUnlocked = false;
foreach ( var view in trackViews )
{
if ( view.IsLockedSelf ) anyLocked = true;
else anyUnlocked = true;
}
return anyLocked == anyUnlocked ? null : anyLocked;
}
/// <summary>
/// True if this is a child GameObject track, and not representing a bone object.
/// </summary>
private bool CanMoveToRoot
{
get
{
if ( View is not { Target: ITrackReference<GameObject> reference, Parent: not null } )
{
return false;
}
// Don't allow moving bone tracks to root
return !reference.IsBound || (reference.Value?.Flags & GameObjectFlags.Bone) == 0;
}
}
/// <summary>
/// Should we show sub-track menu options?
/// </summary>
private bool CanHaveSubTracks => View.Track is not ProjectSequenceTrack && (View.Children.Count > 0 || TrackProperty.GetAll( View.Target ).Any());
/// <summary>
/// True if this is a GameObject track, and the object's parent also has a track that
/// this track isn't parented to.
/// </summary>
private bool CanMoveToParent( [NotNullWhen( true )] out IProjectReferenceTrack? parentTrack )
{
parentTrack = null;
if ( View is not { Target: ITrackReference<GameObject> reference } )
{
return false;
}
if ( !reference.IsBound || reference.Value is not { Parent: { } parent } )
{
return false;
}
parentTrack = TrackList.Session.GetTrack( parent ) as IProjectReferenceTrack;
if ( parentTrack is null )
{
return false;
}
// Already parented
return View.Parent?.Track != parentTrack;
}
private record AvailableTrackProperty( string Name, string Category, Type Type, Action Create );
private void CreateSubTrackMenu( Menu parent )
{
parent.AddHeading( "Sub-Tracks" );
var menu = parent.AddMenu( "Add / Remove", "playlist_add" );
var session = TrackList.Session;
var availableTracks = new List<AvailableTrackProperty>();
if ( View.Target is ITrackReference<GameObject> { IsBound: true, Value: { Components.Count: > 0 } go } )
{
foreach ( var component in go.Components.GetAll() )
{
var type = component.GetType();
availableTracks.Add( new AvailableTrackProperty( type.Name, "Components", type,
() => session.GetOrCreateTrack( component ) ) );
}
}
foreach ( var property in TrackProperty.GetAll( View.Target ) )
{
availableTracks.Add( new AvailableTrackProperty( property.Name, property.Category, property.Type,
() => session.GetOrCreateTrack( View.Track, property.Name ) ) );
}
var categories = availableTracks.GroupBy( x => x.Category ).ToArray();
Action? updateActive = null;
foreach ( var category in categories.OrderBy( x => x.Key ) )
{
var subMenu = categories.Length == 1 ? menu : menu.AddMenu( category.Key );
foreach ( var type in category.GroupBy( x => x.Type.ToSimpleString( false ) ).OrderBy( x => x.Key ) )
{
if ( category.Key != "Components" )
{
subMenu.AddHeading( type.Key ).Color = Theme.TextDisabled;
}
foreach ( var item in type.OrderBy( x => x.Name ) )
{
var option = new ToggleOption( item.Name, false, create =>
{
using var scope = session.History.Push( $"{(create ? "Create" : "Remove")} Track ({item.Name})" );
if ( create )
{
item.Create();
}
else
{
View.Children
.FirstOrDefault( x => x.Track.Name == item.Name )?
.Remove();
}
session.TrackList.Update();
session.ClipModified();
} );
updateActive += () => option.IsActive = View.Children.Any( x => x.Track.Name == item.Name );
subMenu.AddWidget( option );
}
}
}
menu.AboutToShow += () => updateActive?.Invoke();
if ( View.Children.Count > 0 )
{
parent.AddOption( "Remove Empty", "cleaning_services", RemoveEmptyChildren );
}
CreatePresetMenu( parent );
if ( View.Children.Count <= 0 ) return;
parent.AddSeparator();
var lockState = GetAggregateLockState( View.Children );
if ( lockState != true )
{
parent.AddOption( "Lock All", "lock", LockChildren );
}
if ( lockState != false )
{
parent.AddOption( "Unlock All", "lock_open", UnlockChildren );
}
}
private void CreatePresetMenu( Menu parent )
{
var session = TrackList.Session;
var matching = TrackPreset.BuiltInPresets
.Concat( session.Config.TrackPresets )
.Where( x => x.Root.Matches( View.Target ) )
.ToArray();
var canLoad = matching.Length > 0;
var canSave = View.Children.Any();
if ( !canLoad && !canSave ) return;
if ( canLoad )
{
var menu = parent.AddMenu( "Load Preset", "menu_open" );
PopulatePresetMenu( menu, matching, [View] );
}
if ( canSave )
{
var menu = parent.AddMenu( "Save Preset", "save" );
menu.AddLineEdit( "Title", autoFocus: true, onSubmit: title =>
{
if ( string.IsNullOrWhiteSpace( title ) ) return;
var preset = new TrackPreset( new TrackPresetMetadata( title ), TrackPresetNode.FromTrackView( View ) );
session.Config.TrackPresets.Add( preset );
session.SaveConfig();
} );
}
}
public static void PopulatePresetMenu( Menu menu, IReadOnlyList<TrackPreset> presets, IReadOnlyList<TrackView> targets )
{
if ( targets.Count == 0 ) return;
var session = targets[0].TrackList.Session;
Action? updateActive = null;
foreach ( var preset in presets )
{
var option = new ToggleOption( preset.Meta.Title, false, create =>
{
using var scope = session.History.Push( $"{(create ? "Create" : "Remove")} Preset Tracks ({preset.Meta.Title})" );
foreach ( var target in targets )
{
if ( create )
{
session.LoadPreset( target.Track, preset.Root );
}
else
{
session.RemovePreset( target.Track, preset.Root );
}
}
session.TrackList.Update();
session.ClipModified();
updateActive?.Invoke();
}, TrackPreset.BuiltInPresets.Contains( preset ) ? null : () =>
{
session.Config.TrackPresets.Remove( preset );
session.SaveConfig();
} );
updateActive += () =>
{
var matchingCount = targets.Min( x => preset.MatchingTrackCount( x.Track ) );
option.Title = $"{preset.Meta.Title} ({matchingCount} / {preset.TrackCount} Tracks)";
option.IsActive = targets.All( x => preset.Root.AllTracksExist( x.Track ) );
option.Update();
};
menu.AddWidget( option );
}
menu.AboutToShow += () => updateActive?.Invoke();
}
private void OnRename( string name )
{
if ( View.Track is not ProjectSequenceTrack sequenceTrack ) return;
sequenceTrack.Name = name;
if ( _label is { } label ) label.Text = name;
}
private void Remove()
{
using var scope = TrackList.Session.History.Push( "Remove Track(s)" );
View.Remove();
}
private void MoveToRoot()
{
if ( View.Track is not IProjectTrackInternal track ) return;
if ( View.Parent?.Track is not IProjectTrackInternal parentTrack ) return;
parentTrack.RemoveChild( track );
TrackList.Session.TrackList.Update();
}
private void MoveToParent( IProjectReferenceTrack parentTrack )
{
if ( View.Track is not IProjectTrackInternal track ) return;
if ( parentTrack is not IProjectTrackInternal newParentTrack ) return;
if ( View.Parent?.Track is IProjectTrackInternal currentParentTrack )
{
if ( currentParentTrack == parentTrack ) return;
currentParentTrack.RemoveChild( track );
}
newParentTrack.AddChild( track );
TrackList.Session.TrackList.Update();
}
private void RemoveEmptyChildren()
{
foreach ( var child in View.Children.ToArray() )
{
RemoveEmptyCore( child );
}
TrackList.Session.TrackList.Update();
}
private static bool RemoveEmptyCore( TrackView view )
{
var allChildrenRemoved = true;
foreach ( var child in view.Children.ToArray() )
{
allChildrenRemoved &= RemoveEmptyCore( child );
}
if ( allChildrenRemoved && view.IsEmpty )
{
view.Remove();
return true;
}
return false;
}
private void LockChildren()
{
foreach ( var child in View.Children )
{
child.IsLockedSelf = true;
}
}
private void UnlockChildren()
{
foreach ( var child in View.Children )
{
child.IsLockedSelf = false;
}
}
}
// TODO: surely there's an easier way to stop Menus from closing
file sealed class ToggleOption : Widget
{
private readonly Label _label;
private readonly Action<bool> _toggled;
public new bool Enabled
{
get => base.Enabled;
set
{
_label.Color = value ? Theme.TextControl : Theme.TextControl.Darken( 0.5f );
_label.Update();
base.Enabled = value;
}
}
private bool _isActive;
public bool IsActive
{
get => _isActive;
set
{
_isActive = value;
_label.SetStyles( value ? "font-weight: bold;" : "font-weight: regular;" );
Update();
}
}
public string Title
{
get => _label.Text;
set => _label.Text = value;
}
protected override Vector2 SizeHint()
{
// So there's enough space for the label to become bold
return base.SizeHint() * new Vector2( 1.1f, 1f );
}
public ToggleOption( string title, bool active, Action<bool> toggled, Action? deleted = null )
{
Layout = Layout.Row();
Layout.Margin = new Margin( 40f, 5f, 16f, 5f );
_label = new Label( title, this );
_toggled = toggled;
MinimumWidth = 120f;
Layout.Add( _label );
IsActive = active;
if ( deleted is not null )
{
Layout.Margin = Layout.Margin with { Right = 8f };
Layout.Add( new IconButton( "delete", () =>
{
Dialog.AskConfirm( deleted, $"Are you sure you want to delete {title}?", "Delete Confirmation" );
} )
{
Foreground = Theme.Red,
ForegroundActive = Theme.Red
} );
}
}
protected override void OnPaint()
{
if ( Paint.HasMouseOver )
{
Paint.SetBrushAndPen( Theme.SurfaceBackground );
Paint.DrawRect( LocalRect.Shrink( IsActive ? 0f : 4f, 0f, 4f, 0f ), 3f );
}
if ( IsActive )
{
Paint.SetBrushAndPen( Theme.Primary );
Paint.DrawRect( LocalRect.Contain( new Vector2( 3f, LocalRect.Height ), TextFlag.LeftCenter ) );
Paint.SetPen( Theme.Text );
Paint.DrawIcon( LocalRect with { Left = LocalRect.Left + 16f }, "done", 13f, TextFlag.LeftCenter );
}
}
protected override void OnMouseReleased( MouseEvent e )
{
base.OnMouseReleased( e );
IsActive = !IsActive;
e.Accepted = true;
_toggled.Invoke( IsActive );
Update();
}
}
file sealed class LockButton : Button
{
public TrackWidget TrackWidget { get; }
public LockButton( TrackWidget trackWidget )
{
TrackWidget = trackWidget;
FixedSize = 24f;
ToolTip = "Toggle lock";
}
protected override void OnPaint()
{
Paint.SetBrushAndPen( PaintExtensions.PaintSelectColor( Theme.ControlBackground,
Theme.ControlBackground.Darken( 0.5f ), Theme.Primary ) );
Paint.DrawRect( LocalRect, 4f );
Paint.SetPen( Theme.TextControl );
Paint.DrawIcon( LocalRect, TrackWidget.View.IsLockedSelf ? "lock" : "lock_open", 12f );
}
protected override void OnClicked()
{
using var scope = TrackWidget.TrackList.Session.History.Push( $"{(TrackWidget.View.IsLockedSelf ? "Unlocked" : "Locked")} Track" );
TrackWidget.View.IsLockedSelf = !TrackWidget.View.IsLockedSelf;
}
}
file sealed class CollapseButton : Button
{
public TrackWidget Track { get; }
public CollapseButton( TrackWidget track )
{
Track = track;
FixedSize = 24f;
ToolTip = "Toggle expanded";
}
protected override void OnPaint()
{
Paint.SetBrushAndPen( PaintExtensions.PaintSelectColor( Theme.ControlBackground,
Theme.ControlBackground.Darken( 0.5f ), Theme.Primary ) );
Paint.DrawRect( LocalRect, 4f );
Paint.SetPen( Theme.TextControl );
Paint.DrawIcon( LocalRect, Track.View.IsExpanded ? "remove" : "add", 12f );
}
protected override void OnClicked()
{
Track.View.IsExpanded = !Track.View.IsExpanded;
}
}
file sealed class ReflectionHelper<T>
where T : class, IValid
{
public static ControlWidget CreateControlWidget( IProjectReferenceTrack track, ITrackReference<T> target )
{
return ControlWidget.Create( EditorTypeLibrary.CreateProperty( target.Name,
() => target.Value, value =>
{
track.ReferenceId = value switch
{
Component cmp => cmp.Id,
GameObject go => go.Id,
_ => null
};
target.Bind( value );
} ) );
}
}