Static PrismTheme class for the editor UI. Declares every colour, layout metric and typography token as mutable static fields, provides helpers to map types/severities/categories to colours, and supports loading/applying/exporting themes from JSON files with parsing of numbers and colour formats and an event when theme changes.
using Editor.Prism.Core;
using System.Globalization;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Editor.Prism.Ui;
/// <summary>
/// Every colour, metric and typography token Prism draws with.
/// <para>
/// Opinionated, dark, high-contrast, one accent. Prism deliberately does not inherit the editor
/// theme's pink selection colour — <c>NodeUI.SelectionOutline</c> is a public field we assign, and
/// <c>Connection.SelectedColor</c> is bypassed by overriding the connection's paint.
/// </para>
/// <para>
/// Nothing outside this file may hard-code a colour. Painting code reads tokens from here so a
/// retheme is one file, not a search-and-replace across forty widgets.
/// </para>
/// <para>
/// Every token is a <b>mutable static field</b> rather than a constant, which is what makes
/// <see cref="Load"/> possible: a <c><project>/.sbox/prism/theme.json</c> naming any subset of
/// the token names below retint the whole tool, and <see cref="Reset"/> puts the built-in palette
/// back. Widgets that cache a colour must subscribe to <see cref="Changed"/>; everything that reads a
/// token inside <c>OnPaint</c> gets the new value for free on the next repaint.
/// </para>
/// </summary>
public static class PrismTheme
{
static Color Hex( uint rgba ) => Color.FromRgba( rgba );
// ---- surfaces --------------------------------------------------------
/// <summary>Graph background.</summary>
public static Color Canvas = Hex( 0x0E1013FF );
/// <summary>16 px grid lines.</summary>
public static Color GridMinor = Hex( 0x15181DFF );
/// <summary>Every eighth grid line, at 128 px.</summary>
public static Color GridMajor = Hex( 0x1B1F26FF );
/// <summary>Dock backgrounds.</summary>
public static Color Panel = Hex( 0x14171CFF );
/// <summary>List row alternation and toolbars.</summary>
public static Color PanelAlt = Hex( 0x191D23FF );
/// <summary>Popups, menus, tooltips and inline editors.</summary>
public static Color Elevated = Hex( 0x1F242BFF );
/// <summary>Node card body.</summary>
public static Color NodeBody = Hex( 0x171B21FF );
/// <summary>Node card header.</summary>
public static Color NodeHeader = Hex( 0x1E232BFF );
/// <summary>1 px dividers.</summary>
public static Color BorderSubtle = Hex( 0x232830FF );
/// <summary>Card outlines and input borders.</summary>
public static Color BorderStrong = Hex( 0x2E353FFF );
/// <summary>Drop shadows.</summary>
public static Color Shadow = Hex( 0x00000066 );
// ---- text ------------------------------------------------------------
/// <summary>Primary text.</summary>
public static Color TextPrimary = Hex( 0xE6EAF0FF );
/// <summary>Secondary text: port labels, metadata.</summary>
public static Color TextSecondary = Hex( 0x9AA5B4FF );
/// <summary>Muted text: section headers, hints.</summary>
public static Color TextMuted = Hex( 0x626C7AFF );
/// <summary>Disabled text.</summary>
public static Color TextDisabled = Hex( 0x454C57FF );
/// <summary>Text drawn on top of the accent colour.</summary>
public static Color TextOnAccent = Hex( 0x0B0E12FF );
// ---- accent and semantics --------------------------------------------
/// <summary>Selection, focus and the active tab.</summary>
public static Color Accent = Hex( 0x4C8DFFFF );
/// <summary>Accent at fill strength.</summary>
public static Color AccentSoft = Hex( 0x4C8DFF26 );
/// <summary>Secondary highlights and brand marks.</summary>
public static Color Accent2 = Hex( 0x7C5CFFFF );
/// <summary>Errors.</summary>
public static Color Error = Hex( 0xFF5C5CFF );
/// <summary>Warnings.</summary>
public static Color Warning = Hex( 0xFFB020FF );
/// <summary>Success states.</summary>
public static Color Success = Hex( 0x3DD68CFF );
/// <summary>Informational states.</summary>
public static Color Info = Hex( 0x4C8DFFFF );
// ---- type colours ----------------------------------------------------
/// <summary><c>float</c></summary>
public static Color TypeFloat = Hex( 0x8FD46AFF );
/// <summary><c>float2</c></summary>
public static Color TypeFloat2 = Hex( 0x4FC3F7FF );
/// <summary><c>float3</c></summary>
public static Color TypeFloat3 = Hex( 0x7B8CFFFF );
/// <summary><c>float4</c></summary>
public static Color TypeFloat4 = Hex( 0xC792EAFF );
/// <summary><c>bool</c></summary>
public static Color TypeBool = Hex( 0xFF7597FF );
/// <summary><c>int</c> and <c>uint</c></summary>
public static Color TypeInt = Hex( 0xE5C07BFF );
/// <summary>Texture objects of every dimensionality.</summary>
public static Color TypeTexture = Hex( 0x56D4C4FF );
/// <summary>Samplers.</summary>
public static Color TypeSampler = Hex( 0x8A93A5FF );
/// <summary>A float4 tagged as a colour.</summary>
public static Color TypeColor = Hex( 0xFFB86CFF );
/// <summary>Matrices.</summary>
public static Color TypeMatrix = Hex( 0xF07178FF );
/// <summary>An unresolved type variable. Drawn hollow.</summary>
public static Color TypeGeneric = Hex( 0x9AA5B4FF );
/// <summary>A broken or invalid type. Drawn dashed.</summary>
public static Color TypeInvalid = Hex( 0xFF5C5CFF );
/// <summary>
/// The colour of a handle, wire or value pill for a type. This is how a user reads a graph at a
/// glance, so it is the single most important palette in the editor.
/// </summary>
public static Color ForType( ShaderType type )
{
if ( type.IsVoid ) return TypeGeneric;
if ( type.IsMatrix ) return TypeMatrix;
if ( type.IsSampler ) return TypeSampler;
if ( type.IsObject ) return TypeTexture;
if ( type.IsBoolean ) return TypeBool;
if ( type.IsIntegral ) return TypeInt;
return type.Components switch
{
1 => TypeFloat,
2 => TypeFloat2,
3 => TypeFloat3,
_ => TypeFloat4
};
}
/// <summary>The colour for a diagnostic severity.</summary>
public static Color ForSeverity( DiagnosticSeverity severity ) => severity switch
{
DiagnosticSeverity.Error => Error,
DiagnosticSeverity.Warning => Warning,
_ => Info
};
// ---- category colours ------------------------------------------------
/// <summary>Math and logic nodes.</summary>
public static Color CategoryMath = Hex( 0x8FD46AFF );
/// <summary>Texture and sampling nodes.</summary>
public static Color CategoryTexture = Hex( 0x56D4C4FF );
/// <summary>Scene and vertex input nodes.</summary>
public static Color CategoryInput = Hex( 0x4C8DFFFF );
/// <summary>Master and output nodes.</summary>
public static Color CategoryOutput = Hex( 0xFFB86CFF );
/// <summary>Reroutes, notes and structural nodes.</summary>
public static Color CategoryUtility = Hex( 0x8A93A5FF );
/// <summary>Custom code and subgraph nodes.</summary>
public static Color CategoryCustom = Hex( 0x7C5CFFFF );
/// <summary>Procedural, noise and SDF nodes.</summary>
public static Color CategoryProcedural = Hex( 0xC792EAFF );
/// <summary>Colour and blending nodes.</summary>
public static Color CategoryColor = Hex( 0xFF7597FF );
/// <summary>Lighting nodes.</summary>
public static Color CategoryLighting = Hex( 0xFFD166FF );
/// <summary>Parameter and keyword nodes.</summary>
public static Color CategoryParameter = Hex( 0xE5C07BFF );
/// <summary>
/// The accent bar colour for a node category path, e.g. <c>Math/Basic</c>.
/// <para>
/// The leading segment is read in place rather than with <c>Split</c>. This is called once per card
/// on every repaint, and the obvious spelling allocates an array and a substring each time — several
/// hundred objects per frame while panning a large graph, for a lookup that produces a struct.
/// </para>
/// </summary>
public static Color ForCategory( string category )
{
if ( string.IsNullOrEmpty( category ) ) return CategoryUtility;
var slash = category.IndexOf( '/' );
var head = ( slash < 0 ? category.AsSpan() : category.AsSpan( 0, slash ) ).Trim();
if ( Is( head, "Math" ) || Is( head, "Logic" ) || Is( head, "Vector" ) || Is( head, "Channel" ) )
return CategoryMath;
if ( Is( head, "Texture" ) || Is( head, "UV" ) || Is( head, "Uv" ) ) return CategoryTexture;
if ( Is( head, "Input" ) || Is( head, "Scene" ) || Is( head, "Geometry" ) || Is( head, "Camera" )
|| Is( head, "Time" ) ) return CategoryInput;
if ( Is( head, "Output" ) || Is( head, "Master" ) ) return CategoryOutput;
if ( Is( head, "Utility" ) || Is( head, "Structure" ) ) return CategoryUtility;
if ( Is( head, "Custom" ) || Is( head, "Subgraph" ) ) return CategoryCustom;
if ( Is( head, "Procedural" ) || Is( head, "Noise" ) || Is( head, "SDF" ) || Is( head, "Shape" ) )
return CategoryProcedural;
if ( Is( head, "Color" ) || Is( head, "Colour" ) || Is( head, "Blend" ) || Is( head, "Artistic" ) )
return CategoryColor;
if ( Is( head, "Lighting" ) || Is( head, "Light" ) ) return CategoryLighting;
if ( Is( head, "Parameter" ) || Is( head, "Constants" ) || Is( head, "Keyword" ) )
return CategoryParameter;
return CategoryUtility;
}
static bool Is( ReadOnlySpan<char> value, string name ) =>
value.Equals( name.AsSpan(), StringComparison.Ordinal );
// ---- geometry --------------------------------------------------------
/// <summary>Base spacing unit.</summary>
public static float Spacing = 4f;
/// <summary>Layout rhythm between blocks.</summary>
public static float Rhythm = 8f;
/// <summary>Node grid size. Also the graph view's grid size.</summary>
public static float GridSize = PrismConstants.GridSize;
/// <summary>Corner radius of a node card.</summary>
public static float RadiusNode = 8f;
/// <summary>Corner radius of a chip or value pill.</summary>
public static float RadiusChip = 4f;
/// <summary>Corner radius of a panel.</summary>
public static float RadiusPanel = 6f;
/// <summary>Corner radius of a popup.</summary>
public static float RadiusPopup = 10f;
/// <summary>Radius of a port handle circle.</summary>
public static float HandleRadius = 5f;
/// <summary>Diameter of a port handle.</summary>
public static float HandleDiameter = 10f;
/// <summary>Diameter of a hovered port handle.</summary>
public static float HandleHoverDiameter = 12f;
/// <summary>How far a handle overhangs the card edge.</summary>
public static float HandleOverhang = 5f;
/// <summary>Ring drawn around a handle so it reads against a crossing wire.</summary>
public static float HandleRing = 1.5f;
/// <summary>Minimum node card width.</summary>
public static float NodeMinWidth = 168f;
/// <summary>Maximum auto-sized node card width.</summary>
public static float NodeMaxWidth = 340f;
/// <summary>Node header height.</summary>
public static float NodeHeaderHeight = 30f;
/// <summary>Vertical pitch between port rows.</summary>
public static float PortPitch = 24f;
/// <summary>Width of the category accent bar on the left of a header.</summary>
public static float AccentBarWidth = 3f;
/// <summary>Side of the node thumbnail.</summary>
public static float ThumbnailSize = 96f;
/// <summary>Idle wire stroke width.</summary>
public static float WireWidth = 2f;
/// <summary>Hovered wire stroke width.</summary>
public static float WireWidthHover = 3f;
/// <summary>Halo stroke drawn beneath a wire so crossings stay readable.</summary>
public static float WireHaloWidth = 3.5f;
/// <summary>Radius of the dot drawn at each wire endpoint.</summary>
public static float WireEndpointRadius = 4f;
/// <summary>Selection outline width.</summary>
public static float SelectionWidth = 1.5f;
/// <summary>Minimap size.</summary>
public static float MinimapWidth = 220f;
/// <summary>Minimap size.</summary>
public static float MinimapHeight = 150f;
// ---- typography ------------------------------------------------------
/// <summary>
/// UI font family override read from a theme file. Empty means "follow the editor theme", which is
/// what <see cref="FontFamily"/> resolves to.
/// </summary>
public static string FontOverride = string.Empty;
/// <summary>Monospace font family override read from a theme file.</summary>
public static string MonospaceOverride = string.Empty;
/// <summary>
/// UI font family. Resolved lazily because the editor theme fills its font fields during startup,
/// which may happen after this class is first touched.
/// </summary>
public static string FontFamily
{
get
{
if ( !string.IsNullOrEmpty( FontOverride ) ) return FontOverride;
return string.IsNullOrEmpty( Theme.DefaultFont ) ? "Inter" : Theme.DefaultFont;
}
}
/// <summary>Monospace font family for code and numeric fields.</summary>
public static string MonospaceFamily
{
get
{
if ( !string.IsNullOrEmpty( MonospaceOverride ) ) return MonospaceOverride;
return string.IsNullOrEmpty( Theme.MonospaceFont ) ? "Consolas" : Theme.MonospaceFont;
}
}
/// <summary>Node title size in pixels.</summary>
public static int NodeTitleSize = 12;
/// <summary>
/// Node title weight. <c>Paint.SetFont</c> silently remaps weight as <c>400 + weight / 10</c>,
/// so only round values in the 400..600 range behave predictably.
/// </summary>
public static int NodeTitleWeight = 600;
/// <summary>Port label size in pixels.</summary>
public static int PortLabelSize = 11;
/// <summary>Port label weight.</summary>
public static int PortLabelWeight = 400;
/// <summary>Port group header size in pixels. Drawn uppercase with extra letter spacing.</summary>
public static int GroupHeaderSize = 11;
/// <summary>Port group header weight.</summary>
public static int GroupHeaderWeight = 700;
/// <summary>Inline value pill size in pixels.</summary>
public static int InlineValueSize = 11;
/// <summary>Inline value pill weight.</summary>
public static int InlineValueWeight = 500;
/// <summary>Panel section header size in pixels.</summary>
public static int PanelHeaderSize = 11;
/// <summary>Panel section header weight.</summary>
public static int PanelHeaderWeight = 700;
/// <summary>Body text size in pixels.</summary>
public static int BodySize = 12;
/// <summary>Code editor font size in pixels.</summary>
public static int CodeSize = 13;
/// <summary>Extra letter spacing applied to uppercase headers, as a fraction of the em.</summary>
public static float HeaderLetterSpacing = 0.06f;
/// <summary>
/// Syntax colours for the code editor and the generated-code panel. One entry per token class the
/// lexers produce.
/// </summary>
public static class Code
{
/// <summary>Editor background.</summary>
public static Color Background = Hex( 0x0E1013FF );
/// <summary>Default token colour.</summary>
public static Color Text = Hex( 0xE6EAF0FF );
/// <summary>Gutter background.</summary>
public static Color Gutter = Hex( 0x14171CFF );
/// <summary>Line numbers.</summary>
public static Color LineNumber = Hex( 0x454C57FF );
/// <summary>Line number of the caret line.</summary>
public static Color LineNumberActive = Hex( 0x9AA5B4FF );
/// <summary>Current-line highlight.</summary>
public static Color CurrentLine = Hex( 0x171B21FF );
/// <summary>Selection fill.</summary>
public static Color Selection = Hex( 0x4C8DFF40 );
/// <summary>Occurrence highlight for the word under the caret.</summary>
public static Color Occurrence = Hex( 0x4C8DFF26 );
/// <summary>Indent guides.</summary>
public static Color IndentGuide = Hex( 0x232830FF );
/// <summary>Matching bracket box.</summary>
public static Color Bracket = Hex( 0x2E353FFF );
/// <summary>Declaration keywords.</summary>
public static Color Keyword = Hex( 0xC792EAFF );
/// <summary>Control-flow keywords.</summary>
public static Color ControlKeyword = Hex( 0xF78C6CFF );
/// <summary>Built-in types.</summary>
public static Color BuiltinType = Hex( 0x4FC3F7FF );
/// <summary>User-declared types.</summary>
public static Color UserType = Hex( 0x56D4C4FF );
/// <summary>Intrinsic functions.</summary>
public static Color Intrinsic = Hex( 0x82AAFFFF );
/// <summary>Function declarations. Drawn bold.</summary>
public static Color FunctionDecl = Hex( 0x82AAFFFF );
/// <summary>Parameters.</summary>
public static Color Parameter = Hex( 0xE6EAF0FF );
/// <summary>Numeric literals.</summary>
public static Color Number = Hex( 0xF78C6CFF );
/// <summary>String literals.</summary>
public static Color String = Hex( 0xC3E88DFF );
/// <summary>Comments. Drawn italic.</summary>
public static Color Comment = Hex( 0x5A6472FF );
/// <summary>Preprocessor directives.</summary>
public static Color Preprocessor = Hex( 0x89DDFFFF );
/// <summary>VFX block keywords such as <c>HEADER</c> and <c>PS</c>. Drawn bold.</summary>
public static Color VfxBlock = Hex( 0xFFB86CFF );
/// <summary>Annotation metadata inside <c>< ... ></c>.</summary>
public static Color Annotation = Hex( 0xFFCB6BFF );
/// <summary>Semantics such as <c>: SV_Target0</c>.</summary>
public static Color Semantic = Hex( 0xFF7597FF );
/// <summary>Engine globals such as <c>g_flTime</c>.</summary>
public static Color EngineGlobal = Hex( 0x8FD46AFF );
/// <summary>Operators and punctuation.</summary>
public static Color Operator = Hex( 0x9AA5B4FF );
/// <summary>Error squiggle.</summary>
public static Color ErrorUnderline = Hex( 0xFF5C5CFF );
/// <summary>Warning squiggle.</summary>
public static Color WarningUnderline = Hex( 0xFFB020FF );
}
// ---- theme loading ---------------------------------------------------
/// <summary>The name the built-in palette answers to. Anything else is resolved from a theme file.</summary>
public const string BuiltInName = "Prism";
static readonly List<FieldInfo> s_tokens = new();
static readonly List<object> s_defaults = new();
static readonly Dictionary<string, int> s_index = new( StringComparer.OrdinalIgnoreCase );
static PrismTheme()
{
// Static field initialisers above have already run at this point, so what is captured here is
// genuinely the built-in palette and Reset() can never drift from it.
Collect( typeof( PrismTheme ), null );
Collect( typeof( Code ), "code" );
}
/// <summary>
/// The name of the palette currently in force. <see cref="BuiltInName"/> when nothing has been
/// loaded, otherwise the theme file's own <c>name</c> or its file name.
/// </summary>
public static string ActiveTheme { get; private set; } = BuiltInName;
/// <summary>
/// Raised after the palette changes, so widgets that cached a colour — the canvas grid pixmap, the
/// framework's handle-config cache, any painted list — can rebuild. Handlers must be cheap and must
/// never throw.
/// </summary>
public static event Action Changed;
/// <summary>Every token name a theme file may set, including the <c>code.</c>-prefixed ones.</summary>
public static IEnumerable<string> TokenNames => s_index.Keys;
/// <summary>The conventional per-project theme file: <c><project>/.sbox/prism/theme.json</c>.</summary>
public static string DefaultThemePath
{
get
{
var root = PrismLog.Guard<string>( "Locate the project root",
() => Project.Current?.GetRootPath(), null );
if ( string.IsNullOrWhiteSpace( root ) ) return null;
return System.IO.Path.Combine( root, PrismConstants.ThemeFile.Replace( '/', System.IO.Path.DirectorySeparatorChar ) );
}
}
/// <summary>
/// Put every token back to the built-in palette and raise <see cref="Changed"/>. Cheap enough to
/// call unconditionally; it does not raise the event when nothing actually moved.
/// </summary>
public static void Reset()
{
var moved = false;
for ( int i = 0; i < s_tokens.Count; i++ )
{
var current = s_tokens[i].GetValue( null );
if ( Equals( current, s_defaults[i] ) ) continue;
s_tokens[i].SetValue( null, s_defaults[i] );
moved = true;
}
var wasNamed = !string.Equals( ActiveTheme, BuiltInName, StringComparison.Ordinal );
ActiveTheme = BuiltInName;
if ( moved || wasNamed ) Raise();
}
/// <summary>
/// Load a palette from a JSON file. Unknown keys are ignored, unparseable values keep their current
/// token, and a missing file is not an error — it simply returns false and leaves the palette alone.
/// </summary>
/// <param name="jsonPath">Absolute path to a theme file.</param>
/// <returns>True when at least one token was applied.</returns>
public static bool Load( string jsonPath )
{
if ( string.IsNullOrWhiteSpace( jsonPath ) ) return false;
var text = PrismLog.Guard<string>( $"Read the theme file '{jsonPath}'",
() => System.IO.File.Exists( jsonPath ) ? System.IO.File.ReadAllText( jsonPath ) : null, null );
if ( string.IsNullOrWhiteSpace( text ) ) return false;
var name = PrismLog.Guard<string>( "Name the theme",
() => System.IO.Path.GetFileNameWithoutExtension( jsonPath ), null );
return Apply( text, name );
}
/// <summary>
/// Apply a palette from JSON text. The document is a flat object of token name to value, with an
/// optional nested <c>code</c> object for the syntax palette and an optional <c>name</c> string:
/// <code>
/// { "name": "Solar", "Accent": "#FFB020", "NodeHeader": "#22262E",
/// "PortPitch": 26, "code": { "Keyword": "#D19A66" } }
/// </code>
/// Colours accept <c>#RGB</c>, <c>#RRGGBB</c>, <c>#RRGGBBAA</c>, <c>"r,g,b,a"</c> in 0..1, or a
/// four-number array. Numbers accept anything the invariant culture parses.
/// </summary>
/// <returns>True when at least one token was applied.</returns>
public static bool Apply( string json, string name = null )
{
if ( string.IsNullOrWhiteSpace( json ) ) return false;
var root = PrismLog.Guard<JsonObject>( "Parse the theme file",
() => JsonNode.Parse( json, null, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
} ) as JsonObject, null );
if ( root is null ) return false;
// Start from the built-in palette so a theme that drops a key does not inherit whatever the
// previously loaded theme happened to put there.
for ( int i = 0; i < s_tokens.Count; i++ ) s_tokens[i].SetValue( null, s_defaults[i] );
var applied = ApplyObject( root, null );
if ( root["name"]?.GetValue<string>() is { Length: > 0 } declared ) name = declared;
ActiveTheme = string.IsNullOrWhiteSpace( name ) ? "Custom" : name;
Raise();
return applied > 0;
}
/// <summary>
/// Apply the palette named by the preferences page. <see cref="BuiltInName"/> resets to the built-in
/// dark palette; any other name loads <see cref="DefaultThemePath"/> and falls back to the built-in
/// when that file is missing or unreadable.
/// </summary>
public static bool LoadPreferred( string themeName )
{
if ( string.IsNullOrWhiteSpace( themeName )
|| string.Equals( themeName, BuiltInName, StringComparison.OrdinalIgnoreCase ) )
{
Reset();
return true;
}
if ( Load( DefaultThemePath ) ) return true;
Reset();
return false;
}
/// <summary>
/// Write the current palette out as a theme file, which is how a user gets a starting point to edit.
/// Returns false when the file could not be written.
/// </summary>
public static bool Export( string jsonPath )
{
if ( string.IsNullOrWhiteSpace( jsonPath ) ) return false;
return PrismLog.Guard( $"Export the theme to '{jsonPath}'", () =>
{
var root = new JsonObject { ["name"] = ActiveTheme };
var code = new JsonObject();
for ( int i = 0; i < s_tokens.Count; i++ )
{
var field = s_tokens[i];
var value = field.GetValue( null );
var target = field.DeclaringType == typeof( Code ) ? code : root;
target[field.Name] = Write( value );
}
root["code"] = code;
var directory = System.IO.Path.GetDirectoryName( jsonPath );
if ( !string.IsNullOrEmpty( directory ) ) System.IO.Directory.CreateDirectory( directory );
System.IO.File.WriteAllText( jsonPath,
root.ToJsonString( new JsonSerializerOptions { WriteIndented = true } ) );
return true;
}, false );
}
static JsonNode Write( object value ) => value switch
{
Color color => JsonValue.Create( color.Hex ),
float number => JsonValue.Create( number ),
int number => JsonValue.Create( number ),
string text => JsonValue.Create( text ),
_ => null
};
static int ApplyObject( JsonObject source, string prefix )
{
var applied = 0;
foreach ( var (key, node) in source )
{
if ( node is null ) continue;
if ( node is JsonObject nested )
{
applied += ApplyObject( nested, prefix is null ? key : $"{prefix}.{key}" );
continue;
}
var name = prefix is null ? key : $"{prefix}.{key}";
if ( !s_index.TryGetValue( name, out var index ) ) continue;
var field = s_tokens[index];
if ( !TryRead( node, field.FieldType, out var value ) ) continue;
field.SetValue( null, value );
applied++;
}
return applied;
}
static bool TryRead( JsonNode node, Type type, out object value )
{
value = null;
if ( type == typeof( Color ) )
{
if ( !TryReadColor( node, out var color ) ) return false;
value = color;
return true;
}
if ( type == typeof( float ) )
{
if ( !TryReadNumber( node, out var number ) ) return false;
value = number;
return true;
}
if ( type == typeof( int ) )
{
if ( !TryReadNumber( node, out var number ) ) return false;
value = (int)MathF.Round( number );
return true;
}
if ( type != typeof( string ) ) return false;
value = node.ToString();
return value is not null;
}
static bool TryReadNumber( JsonNode node, out float value )
{
value = 0f;
if ( node is JsonValue jsonValue && jsonValue.TryGetValue<float>( out var direct ) )
{
value = direct;
return true;
}
return float.TryParse( node.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value );
}
static bool TryReadColor( JsonNode node, out Color color )
{
color = default;
if ( node is JsonArray array )
{
// [ r, g, b ] or [ r, g, b, a ], in 0..1.
if ( array.Count < 3 ) return false;
var components = new float[4] { 0f, 0f, 0f, 1f };
for ( int i = 0; i < 4 && i < array.Count; i++ )
{
if ( array[i] is null || !TryReadNumber( array[i], out components[i] ) ) return false;
}
color = new Color( components[0], components[1], components[2], components[3] );
return true;
}
var text = node.ToString()?.Trim();
if ( string.IsNullOrEmpty( text ) ) return false;
if ( text.StartsWith( "#", StringComparison.Ordinal ) ) return TryReadHex( text[1..], out color );
if ( text.Contains( ',' ) )
{
var parts = text.Split( ',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries );
if ( parts.Length < 3 ) return false;
var components = new float[4] { 0f, 0f, 0f, 1f };
for ( int i = 0; i < 4 && i < parts.Length; i++ )
{
if ( !float.TryParse( parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out components[i] ) )
{
return false;
}
}
color = new Color( components[0], components[1], components[2], components[3] );
return true;
}
// Bare hex, and finally whatever the engine's own parser recognises by name.
if ( TryReadHex( text, out color ) ) return true;
var parsed = PrismLog.Guard<Color?>( $"Parse the colour '{text}'", () => Color.Parse( text ), null );
if ( parsed is null ) return false;
color = parsed.Value;
return true;
}
static bool TryReadHex( string text, out Color color )
{
color = default;
if ( text is null || text.Length is not ( 3 or 4 or 6 or 8 ) ) return false;
var nibbles = new int[8];
for ( int i = 0; i < text.Length; i++ )
{
var value = Nibble( text[i] );
if ( value < 0 ) return false;
nibbles[i] = value;
}
var shortForm = text.Length <= 4;
var hasAlpha = text.Length is 4 or 8;
float Channel( int index ) => shortForm
? nibbles[index] * 17 / 255f
: ( nibbles[index * 2] * 16 + nibbles[index * 2 + 1] ) / 255f;
color = new Color( Channel( 0 ), Channel( 1 ), Channel( 2 ), hasAlpha ? Channel( 3 ) : 1f );
return true;
}
static int Nibble( char c ) => c switch
{
>= '0' and <= '9' => c - '0',
>= 'a' and <= 'f' => c - 'a' + 10,
>= 'A' and <= 'F' => c - 'A' + 10,
_ => -1
};
static void Collect( Type type, string prefix )
{
foreach ( var field in type.GetFields( BindingFlags.Public | BindingFlags.Static ) )
{
if ( field.IsLiteral || field.IsInitOnly ) continue;
if ( field.FieldType != typeof( Color ) && field.FieldType != typeof( float )
&& field.FieldType != typeof( int ) && field.FieldType != typeof( string ) )
{
continue;
}
var name = prefix is null ? field.Name : $"{prefix}.{field.Name}";
s_index[name] = s_tokens.Count;
s_tokens.Add( field );
s_defaults.Add( field.GetValue( null ) );
}
}
static void Raise() => PrismLog.Guard( "Announce a theme change", () => Changed?.Invoke() );
}