using Sandbox.UI; namespace Editor; class ControlSheetRow : Widget { public ControlWidget ControlWidget { get; private set; } SerializedProperty property; bool includeExtraInfo; ControlSheetLabel label; GridLayout gridLayout; GridLayout validateResultContainer; public ControlSheetRow( SerializedProperty property, ControlWidget editor ) { this.property = property; FocusMode = FocusMode.Click; ControlWidget = editor; property.OnFinishEdit += OnPropertyFinishEdit; gridLayout = Layout.Grid(); gridLayout.HorizontalSpacing = 0; Layout = gridLayout; } public static ControlWidget CreateEditor( SerializedProperty property ) { if ( property.PropertyType.IsAssignableTo( typeof( Resource ) ) ) { return new ResourceWrapperControlWidget( property ); } try { return ControlWidget.Create( property ); } catch ( System.Exception e ) { Log.Warning( e, $"Error creating ControlWidget for {property.Name}" ); } return default; } public static ControlSheetRow Create( SerializedProperty property, bool includeExtraInfo = false ) { ControlWidget editor = CreateEditor( property ); if ( !editor.IsValid() ) return null; return Create( property, editor, includeExtraInfo ); } public static ControlSheetRow Create( SerializedProperty property, ControlWidget editor, bool includeExtraInfo = false ) { if ( !editor.IsValid() ) return null; var row = new ControlSheetRow( property, editor ); row.includeExtraInfo = includeExtraInfo; row.ContentMargins = new Margin( 6, 2, 0, 0 ); row.Rebuild(); return row; } public bool UpdateVisibility() { var ss = property.ShouldShow(); if ( ss == !Hidden ) return false; Hidden = !ss; return true; } protected override void OnPaint() { base.OnPaint(); var isPropertyOverridden = EditorUtility.Prefabs.IsPropertyOverridden( property ) || EditorUtility.Prefabs.IsComponentAddedToInstance( property.Parent?.Targets?.OfType().FirstOrDefault() ); if ( isPropertyOverridden ) { var overrideIndicatorRect = LocalRect; overrideIndicatorRect.Width = 2f; Paint.SetBrush( Theme.Blue.Darken( 0.25f ) ); Paint.ClearPen(); Paint.DrawRect( overrideIndicatorRect ); } } public void OnPropertyFinishEdit( SerializedProperty property ) { UpdateValidation(); } public void Rebuild() { if ( property is null ) return; float spaceAbove = 0; if ( property.TryGetAttribute( out SpaceAttribute spaceAttr ) ) { spaceAbove = spaceAttr.Height; } var hasLabel = ControlWidget.IncludeLabel; var isExpanded = ControlWidget.IsWideMode; if ( property.TryGetAttribute( out var wideMode ) ) { isExpanded = true; hasLabel = ControlWidget.IncludeLabel && wideMode.HasLabel; } gridLayout.Margin = new Margin( 0, spaceAbove, 4, 0 ); if ( property.TryGetAttribute( out InfoBoxAttribute infoAttribute ) ) { var header = new InfoBoxWidget( infoAttribute.Message, infoAttribute.Tint, infoAttribute.Icon ); gridLayout.AddCell( 0, 0, header, 10, 1 ); } if ( property.TryGetAttribute( out HeaderAttribute headerAttribute ) ) { var header = new Label.Header( headerAttribute.Title ); gridLayout.AddCell( 0, 1, header, 10, 1 ); } ToolTip = ControlSheetFormatter.GetPropertyToolTip( property, includeExtraInfo ); HorizontalSizeMode = SizeMode.CanShrink; label = new ControlSheetLabel( property ); ControlWidget.HorizontalSizeMode = SizeMode.Flexible; if ( property.IsNullable ) { gridLayout.AddCell( 1, 5, new PropertyButton( property, ControlWidget ), 1, 1, TextFlag.LeftTop ); ControlWidget.Enabled = !property.IsNull; } if ( hasLabel ) { label.ContentMargins = isExpanded ? new( 0, 0, 0, 4 ) : new( 0, 0, 4, 0 ); gridLayout.AddCell( 2, 5, label, xSpan: (isExpanded ? 2 : 1), alignment: TextFlag.LeftTop ); gridLayout.AddCell( 3 - (isExpanded ? 1 : 0), 5 + (isExpanded ? 1 : 0), ControlWidget, alignment: TextFlag.LeftTop ); } else { gridLayout.AddCell( 2, 5, ControlWidget, xSpan: 2, alignment: TextFlag.LeftTop ); } var validateAttributes = property.GetAttributes().ToList(); if ( validateAttributes.Any() ) { validateResultContainer = (GridLayout)gridLayout.AddCell( 0, 6, new GridLayout(), 10, 1 ); } else if ( validateResultContainer.IsValid() ) { validateResultContainer.Destroy(); } gridLayout.SetColumnStretch( 0, 0, 0, 1 ); gridLayout.SetMinimumColumnWidth( 0, 0 ); gridLayout.SetMinimumColumnWidth( 1, 0 ); gridLayout.SetMinimumColumnWidth( 2, 140 ); UpdateValidation(); } private void UpdateValidation() { var validateAttributes = property.GetAttributes().ToList(); if ( validateAttributes.Any() && validateResultContainer.IsValid() ) { var owner = property.Parent.Targets.FirstOrDefault(); var typeDesc = TypeLibrary.GetType( owner.GetType() ); var propertyValue = property.GetValue(); int rowOffset = 0; validateResultContainer.Clear( true ); // Process each validation attribute foreach ( var validateAttribute in validateAttributes ) { var validationResult = validateAttribute.Validate( owner, typeDesc, propertyValue ); // Only display non-success results if ( !validationResult.Success ) { var tint = validationResult.Status switch { LogLevel.Warn => EditorTint.Yellow, LogLevel.Error => EditorTint.Red, LogLevel.Info => EditorTint.Blue, LogLevel.Trace => EditorTint.Blue, _ => EditorTint.Blue }; var icon = validationResult.Status switch { LogLevel.Warn => "warning", LogLevel.Error => "error", LogLevel.Info => "info", LogLevel.Trace => "info", _ => "info" }; var header = new InfoBoxWidget( validationResult.Message, tint, icon ); validateResultContainer.AddCell( 0, rowOffset, header, 10, 1 ); rowOffset++; } } } } protected override void OnContextMenu( ContextMenuEvent e ) { e.Accepted = true; var menu = new ContextMenu( this ); menu.AddOption( $"Copy {property.DisplayName}", "content_copy", () => { string str = ControlWidget.ToClipboardString(); EditorUtility.Clipboard.Copy( str ); } ); menu.AddOption( $"Paste as {property.DisplayName}", "content_paste", () => { string str = EditorUtility.Clipboard.Paste(); ControlWidget.FromClipboardString( str ); } ); menu.AddOption( "Reset to Default", "restart_alt", () => { property.Parent.NoteStartEdit( property ); property.SetValue( property.GetDefault() ); property.Parent.NoteFinishEdit( property ); } ); if ( IsEditingComponent || IsEditingGameObject ) { menu.AddSeparator(); var isPropertyModified = EditorUtility.Prefabs.IsPropertyOverridden( property ); object editedObject = EditedComponents.FirstOrDefault(); editedObject ??= EditedGameObjects.FirstOrDefault(); var prefabName = EditorUtility.Prefabs.GetOuterMostPrefabName( editedObject ) ?? ""; var revertChangesActionName = $"Revert Property instance change"; menu.AddOption( revertChangesActionName, "history", () => { using var scene = SceneEditorSession.Scope(); using ( SceneEditorSession.Active.UndoScope( revertChangesActionName ).WithComponentChanges( EditedComponents ).WithGameObjectChanges( EditedGameObjects, GameObjectUndoFlags.Properties ).Push() ) { EditorUtility.Prefabs.RevertPropertyChange( property ); } } ).Enabled = isPropertyModified; var applyChangesActionName = $"Apply Property instance change to prefab \"{prefabName}\""; menu.AddOption( applyChangesActionName, "update", () => { using var scene = SceneEditorSession.Scope(); using ( SceneEditorSession.Active.UndoScope( applyChangesActionName ).WithComponentChanges( EditedComponents ).WithGameObjectChanges( EditedGameObjects, GameObjectUndoFlags.Properties ).Push() ) { EditorUtility.Prefabs.ApplyPropertyChange( property ); } } ).Enabled = isPropertyModified; } if ( CodeEditor.CanOpenFile( property.SourceFile ) ) { menu.AddSeparator(); var filename = System.IO.Path.GetFileName( property.SourceFile ); menu.AddOption( $"Jump to code", "code", action: () => CodeEditor.OpenFile( property.SourceFile, property.SourceLine ) ); } AddComponentOptions( menu ); menu.OpenAt( e.ScreenPosition, false ); } bool IsEditingComponent => property.Parent?.Targets?.OfType().FirstOrDefault( x => x.IsValid() ) is not null; bool IsEditingGameObject => property.Parent?.Targets?.OfType().FirstOrDefault( x => x.IsValid() ) is not null || property.Parent?.Targets?.OfType().FirstOrDefault() is not null; IEnumerable EditedComponents => property.Parent?.Targets?.OfType().Where( x => x.IsValid() ) ?? Enumerable.Empty(); IEnumerable EditedGameObjects => (property.Parent?.Targets?.OfType().Where( x => x.IsValid() ) ?? Enumerable.Empty()).Concat( property.Parent?.Targets?.OfType().Where( x => x.GameObject.IsValid() ).Select( x => x.GameObject ) ?? Enumerable.Empty() ); void AddComponentOptions( Menu menu ) { // Are we editing the property of a component? if ( !IsEditingComponent ) return; var component = EditedComponents.FirstOrDefault(); // Are we editing in a game session? var session = SceneEditorSession.Resolve( component?.GameObject?.Scene ); if ( session is null ) return; // Only show "Apply To Scene" if we're editing in an active game session if ( !session.IsPlaying || component.GameObject.Scene != session.Scene ) return; // try to find the version of this component in the editor session var targetComponent = session.Scene.Directory.FindComponentByGuid( component.Id ); if ( !targetComponent.IsValid() ) return; // get a serialized version of this property from that session var so = targetComponent.GetSerialized(); var prop = so.GetProperty( property.Name ); // add option to apply this value to that scene menu.AddSeparator(); var setter = menu.AddOption( "Apply To Scene..", "save", () => { // No idea what Apply To Scene does behind the scenes, cheaper undo is likely possible if it ever becomes an issue using ( SceneEditorSession.Active.UndoScope( "Apply To Scene" ).WithGameObjectChanges( session.Scene, GameObjectUndoFlags.All ).Push() ) { prop.SetValue( property.GetValue() ); } } ); setter.Enabled = Json.Serialize( prop.GetValue() ) != Json.Serialize( property.GetValue() ); } } internal class InfoBoxWidget : Widget { private string message; private EditorTint tint; private string icon; public InfoBoxWidget( string message, EditorTint tint, string icon ) { this.message = message; this.tint = tint; this.icon = icon; SetSizeMode( SizeMode.Flexible, SizeMode.Expand ); Layout = Layout.Row(); Layout.Margin = new Margin( 12, 12, 12, 20 ); Layout.AddSpacingCell( 32 ); Layout.Add( new Label( message ) { WordWrap = true } ); } protected override Vector2 SizeHint() { return 1000; } protected override void OnPaint() { base.OnPaint(); Paint.SetBrushAndPen( Theme.GetTint( tint ).Darken( 0.3f ) ); Paint.DrawRect( LocalRect.Shrink( 3 ).Shrink( 0, 0, 0, 8 ), 5 ); Paint.SetPen( Theme.GetTint( tint ).Desaturate( 0.5f ).Lighten( 5 ) ); Paint.DrawIcon( new Rect( 10, 18 ), icon, 18, TextFlag.Center ); } } /// /// A button to the left of the property, allows toggling NULL state on nullabe values. /// file class PropertyButton : Widget { SerializedProperty property; ControlWidget controlWidget; public PropertyButton( SerializedProperty property, ControlWidget controlWidget ) { this.property = property; this.controlWidget = controlWidget; FixedHeight = Theme.RowHeight; FixedWidth = Theme.RowHeight; HorizontalSizeMode = SizeMode.Flexible; Cursor = CursorShape.Finger; ToolTip = "Has Value"; } protected override void OnPaint() { var icon = "eject"; var size = 15; Color color = Theme.TextControl.WithAlpha( 0.3f ); Paint.TextAntialiasing = true; // Nullable - and null var isMultiple = property.IsMultipleDifferentValues; if ( property.IsNull && !isMultiple ) { icon = "radio_button_unchecked"; size = 10; color = Theme.TextControl.WithAlpha( 0.3f ); } else { icon = "circle"; size = 10; color = isMultiple ? Theme.MultipleValues : Theme.Blue; color = color.WithAlpha( 0.3f ); } Paint.Pen = color; Paint.DrawIcon( LocalRect, icon, size ); } protected override void OnMouseClick( MouseEvent e ) { property.SetNullState( !property.IsNull ); controlWidget.Enabled = !property.IsNull; Update(); } }