Editor/ObjectGenericControlWidget.ControlSheetRow.cs
using Sandbox.UI;
using Label = Editor.Label;
namespace Nodebox.Editor;
public partial class ObjectGenericControlWidget {
/// <summary>
/// Represents a single row in a control sheet UI, providing editing and validation functionality for a serialized
/// property.
/// </summary>
public 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<Component>().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<WideModeAttribute>(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<ValidateAttribute>().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<ValidateAttribute>().ToList();
if (validateAttributes.Any() && validateResultContainer.IsValid()) {
var owner = property.Parent.Targets.FirstOrDefault();
var typeDesc = TypeLibrary.GetType(owner.GetType());
var propertyValue = property.GetValue<object>();
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);
});
bool isPrefab = property.GetContainingGameObject()?.IsPrefabInstance ?? false;
if (isPrefab && (IsEditingComponent || IsEditingGameObject)) {
menu.AddSeparator();
var isPropertyModified = EditorUtility.Prefabs.IsPropertyOverridden(property);
object editedObject = EditedComponents.FirstOrDefault();
editedObject ??= EditedGameObjects.FirstOrDefault();
var prefabName = EditorUtility.Prefabs.GetOuterMostPrefabName(editedObject) ?? "";
var revertActionName = "Revert Change";
menu.AddOption(revertActionName, "history", () => {
using var scene = SceneEditorSession.Scope();
using (SceneEditorSession.Active.UndoScope(revertActionName).WithComponentChanges(EditedComponents).WithGameObjectChanges(EditedGameObjects, GameObjectUndoFlags.Properties).Push()) {
EditorUtility.Prefabs.RevertPropertyChange(property);
}
}).Enabled = isPropertyModified;
menu.AddOption("Apply to Prefab", "save", () => {
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<Component>().FirstOrDefault(x => x.IsValid()) is not null;
bool IsEditingGameObject => property.Parent?.Targets?.OfType<GameObject>().FirstOrDefault(x => x.IsValid()) is not null || property.Parent?.Targets?.OfType<GameTransform>().FirstOrDefault() is not null;
IEnumerable<Component> EditedComponents => property.Parent?.Targets?.OfType<Component>().Where(x => x.IsValid()) ?? Enumerable.Empty<Component>();
IEnumerable<GameObject> EditedGameObjects => (property.Parent?.Targets?.OfType<GameObject>().Where(x => x.IsValid()) ?? Enumerable.Empty<GameObject>()).Concat(property.Parent?.Targets?.OfType<GameTransform>().Where(x => x.GameObject.IsValid()).Select(x => x.GameObject) ?? Enumerable.Empty<GameObject>());
void AddComponentOptions(Menu menu) {
// Are we editing the property of a component?
if (!IsEditingComponent)
return;
var component = EditedComponents.FirstOrDefault();
// Only show if we're editing in a game session
var session = SceneEditorSession.Resolve(component?.GameObject?.Scene);
if (session is null)
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", () => {
using var scope = session.Scene.Push();
using (session.UndoScope("Apply to Scene").WithComponentChanges(targetComponent).Push()) {
prop.SetValue<object>(property.GetValue<object>());
}
});
setter.Enabled = Json.Serialize(prop.GetValue<object>()) != Json.Serialize(property.GetValue<object>());
}
}
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);
}
}
/// <summary>
/// A button to the left of the property, allows toggling NULL state on nullabe values.
/// </summary>
public 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();
}
}
}