Editor/ProjectSettingsDock.cs
using System;
using Editor;
using Sandbox;
using System.Reflection;
using Sandbox.Internal;
using static Editor.EditorEvent;
using System.Collections.Generic;
using System.Linq;
using static Editor.Label;

[Dock("Editor", "Project Settings", "manage_search")]
public class ProjectSettingsDock : Widget
{
	NavigationView view;

	public ProjectSettingsDock(Widget parent) : base(parent)
	{
		Layout = Layout.Column();
		Layout.Margin = 4;
		Layout.Spacing = 4;

		view = new NavigationView(this);
		view.MinimumSize = 0;
		//Layout.Add(View);
		view.SetSizeMode(SizeMode.Default, SizeMode.Flexible);

		var scroller = new ScrollArea(this);
		scroller.Canvas = view;
		scroller.Canvas.SetSizeMode(SizeMode.Default, SizeMode.Flexible);
		scroller.Canvas.Layout = Layout.Column();
		Layout.Add(scroller);
		scroller.Canvas.Layout.AddStretchCell();

		view.MenuTop.Add(new Label("Project Settings"));
		view.MenuTop.AddSpacingCell(16);

		Rebuild();
	}

	[Hotload(Priority = 10)]
	public void Rebuild()
	{
		var lastOptionTitle = view?.CurrentOption?.Title;

		view.ClearPages();

		var types = GlobalGameNamespace.TypeLibrary.GetTypes<ProjectSettingNonGenericBase>();
		var optionTitleToOption = new Dictionary<string, NavigationView.Option>();
		types = types.OrderBy(t => t.TargetType.Name);

		foreach (var type in types)
		{
			if (type.IsAbstract)
				continue;

			var targetType = type.TargetType;
			Type constructedType = typeof(ProjectSetting<>).MakeGenericType(targetType);
			var instanceProperty = constructedType.GetProperty("instance", BindingFlags.Static | BindingFlags.Public);

			var inst = instanceProperty.GetValue(null);
			var gameResourceInst = inst as GameResource;

			string title = FormatToReadable(targetType.Name);

			var option = view.AddPage(title, null, new ProjectSettingsInspector(this, gameResourceInst));
			optionTitleToOption[title] = option;
		}

		// Custom Widget Support
		var attributesTypes = GlobalToolsNamespace.EditorTypeLibrary.GetTypesWithAttribute<ProjectSettingsWidgetAttribute>();

		foreach (var attributeInst in attributesTypes)
		{
			if (attributeInst.Type?.TargetType == null)
				continue;

			bool isWidgetType = attributeInst.Type.TargetType.IsSubclassOf(typeof(Widget));
			if (!isWidgetType)
			{
				Log.Warning($"ProjectSettingsDock failed to create widget instance for '{attributeInst.Type.TargetType}' are you use this inherits from Widget?");
				continue;
			}

			var widgetInst = Activator.CreateInstance(attributeInst.Type.TargetType, this) as Widget;
			if (widgetInst == null)
			{
				Log.Error($"ProjectSettingsDock failed to create instance for type '{attributeInst.Type.TargetType}'");
				continue;
			}

			string title = attributeInst.Attribute.title;
			if (string.IsNullOrEmpty(title))
			{
				title = FormatToReadable(attributeInst.Type.Name);
			}

			var option = view.AddPage(title, null, widgetInst);
			optionTitleToOption[title] = option;
		}

		// Keeps the select option after a rebuild, this was really annoying me
		if (!string.IsNullOrEmpty(lastOptionTitle))
		{
			if (optionTitleToOption.TryGetValue(lastOptionTitle, out var option))
			{
				if (option != null)
				{
					view.CurrentOption = option;
				}
			}
		}
	}

	public static string FormatToReadable(string input)
	{
		// Add space before each uppercase letter that is followed by a lowercase letter or another uppercase letter group.
		string formattedInput = System.Text.RegularExpressions.Regex.Replace(input, "(?<=.)([A-Z][a-z])", " $1");

		// Add space between an acronym and the next capitalized word (e.g., "XMLParser" becomes "XML Parser")
		formattedInput = System.Text.RegularExpressions.Regex.Replace(formattedInput, "(?<=[a-z])([A-Z])", " $1");

		// Capitalize the first letter of the resulting string
		return Char.ToUpper(formattedInput[0]) + formattedInput.Substring(1);
	}
}

public class ProjectSettingsInspector : Widget
{
	public ProjectSettingsInspector(Widget parent, GameResource gameResource) : base(parent)
	{
		Layout = Layout.Column();
		SetSizeMode(SizeMode.Default, SizeMode.Flexible);

		var sheet = new ControlSheet();

		var so = gameResource.GetSerialized();

		var scroller = new ScrollArea(this);
		scroller.Canvas = new Widget(this);
		scroller.Canvas.SetSizeMode(SizeMode.Default, SizeMode.Flexible);
		scroller.Canvas.Layout = Layout.Column();
		Layout.Add(scroller);

		so.OnPropertyChanged += x =>
		{
			var asset = AssetSystem.FindByPath(gameResource.ResourcePath);
			if (asset != null)
			{
				bool saved = asset.SaveToDisk(gameResource);
				if (!saved)
				{
					Log.Error($"ProjectSettingsInspector '{gameResource?.ResourceName}' failed to save, is the file read only? Please report it");
				}
			}
			else
			{
				Log.Error($"ProjectSettingsInspector '{gameResource?.ResourceName}' failed to get asset, this should not happen! Please report it");
			}
		};

		sheet.AddObject(so);

		scroller.Canvas.Layout.Add(sheet);
		scroller.Canvas.Layout.AddStretchCell();
	}
}

// Can't do because you can't reference library editor projects
/*[AttributeUsage(AttributeTargets.Class)]
public class ProjectSettingsWidgetAttribute : Attribute
{
	public string group { get; }
	public string title { get; }

	public ProjectSettingsWidgetAttribute(string group = null, string title = null)
	{
		this.group = group;
		this.title = title;
	}
}*/