Files
HellionChat/HellionChat/Themes/ThemeJsonLoader.cs
T
JonKazama-Hellion 620dfe9ea0 feat(themes): bump JSON schema to v2 with typography roundtrip
Loader returns Theme? — null is the silent hard-cut skip for v1 files so
the v2.x refactor stays free of legacy-mapping code. v2 adds an optional
typography{} block with overrideGlobalFontSizePt and overrideSymbolsFontSizePt
slots, both nullable. ThemeRegistry.RefreshCustomCache gets a null guard
so the yield path drops skipped files cleanly. Writer emits typography{}
with explicit nulls so hand-edited files show the available knobs.
2026-05-23 17:38:23 +02:00

190 lines
7.8 KiB
C#

using System.Text.Json;
using HellionChat.Util;
namespace HellionChat.Themes;
internal static class ThemeJsonLoader
{
public const int SupportedSchemaVersion = 2;
// Returns null when the file declares an older schemaVersion. Hard-cut
// policy from the v2.x style refactor: v1 user themes are not migrated,
// they're silently ignored so the loader stays free of legacy mapping
// code. Any other malformed input still throws FormatException.
public static Theme? LoadFromString(string json)
{
if (string.IsNullOrWhiteSpace(json))
throw new FormatException("Theme JSON is empty");
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json);
}
catch (JsonException ex)
{
throw new FormatException("Theme JSON is not valid JSON", ex);
}
using (doc)
{
var root = doc.RootElement;
var schemaVersion = ReadInt(root, "schemaVersion");
if (schemaVersion < SupportedSchemaVersion)
return null;
if (schemaVersion > SupportedSchemaVersion)
throw new FormatException(
$"Unsupported schemaVersion {schemaVersion}; this build reads up to {SupportedSchemaVersion}"
);
var slug = ReadString(root, "slug");
var name = ReadString(root, "name");
var author = ReadString(root, "author");
var description = ReadString(root, "description");
var colors = ReadColors(root.GetProperty("colors"));
var layout = ReadLayout(root.GetProperty("layout"));
var typography = ReadTypography(root);
ThemeChatColors? chatColors = null;
if (
root.TryGetProperty("chatChannels", out var ch)
&& ch.ValueKind == JsonValueKind.Object
)
chatColors = ReadChatColors(ch);
return new Theme(
slug,
name,
author,
description,
colors,
layout,
typography,
IsBuiltIn: false,
ChatColors: chatColors
);
}
}
private static ThemeChatColors ReadChatColors(JsonElement el)
{
var dict = new Dictionary<HellionChat.Code.ChatType, uint>();
foreach (var prop in el.EnumerateObject())
{
// Property name is the ChatType name (e.g. "Say", "Tell"), value is hex like theme colours.
// Unknown channel names are silently skipped for forward-compat with future SE channels.
if (
!Enum.TryParse<HellionChat.Code.ChatType>(
prop.Name,
ignoreCase: true,
out var channel
)
)
continue;
if (prop.Value.ValueKind != JsonValueKind.String)
continue;
var hex = prop.Value.GetString();
if (string.IsNullOrWhiteSpace(hex))
continue;
dict[channel] = HellionChat.Util.ColourUtil.HexToRgba(hex);
}
return new ThemeChatColors(dict);
}
public static Theme? LoadFromFile(string path)
{
// FileShare.Read lets concurrent readers and well-behaved editors share
// the handle; atomic-replace editors still raise IOException, caught upstream.
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
return LoadFromString(json);
}
private static ThemeColors ReadColors(JsonElement el) =>
new(
PrimaryDark: ColourUtil.HexToRgba(ReadString(el, "primaryDark")),
Primary: ColourUtil.HexToRgba(ReadString(el, "primary")),
PrimaryLight: ColourUtil.HexToRgba(ReadString(el, "primaryLight")),
PrimaryGlow: ColourUtil.HexToRgba(ReadString(el, "primaryGlow")),
AccentDark: ColourUtil.HexToRgba(ReadString(el, "accentDark")),
Accent: ColourUtil.HexToRgba(ReadString(el, "accent")),
AccentLight: ColourUtil.HexToRgba(ReadString(el, "accentLight")),
Identity: ColourUtil.HexToRgba(ReadString(el, "identity")),
WindowBg: ColourUtil.HexToRgba(ReadString(el, "windowBg")),
ChildBg: ColourUtil.HexToRgba(ReadString(el, "childBg")),
FrameBg: ColourUtil.HexToRgba(ReadString(el, "frameBg")),
Surface: ColourUtil.HexToRgba(ReadString(el, "surface")),
SurfaceHover: ColourUtil.HexToRgba(ReadString(el, "surfaceHover")),
Border: ColourUtil.HexToRgba(ReadString(el, "border")),
TextPrimary: ColourUtil.HexToRgba(ReadString(el, "textPrimary")),
TextMuted: ColourUtil.HexToRgba(ReadString(el, "textMuted")),
TextDim: ColourUtil.HexToRgba(ReadString(el, "textDim")),
StatusSuccess: ColourUtil.HexToRgba(ReadString(el, "statusSuccess")),
StatusDanger: ColourUtil.HexToRgba(ReadString(el, "statusDanger")),
StatusWarning: ColourUtil.HexToRgba(ReadString(el, "statusWarning")),
StatusInfo: ColourUtil.HexToRgba(ReadString(el, "statusInfo"))
);
private static ThemeLayout ReadLayout(JsonElement el) =>
new(
WindowRounding: ReadFloat(el, "windowRounding"),
ChildRounding: ReadFloat(el, "childRounding"),
PopupRounding: ReadFloat(el, "popupRounding"),
FrameRounding: ReadFloat(el, "frameRounding"),
GrabRounding: ReadFloat(el, "grabRounding"),
TabRounding: ReadFloat(el, "tabRounding"),
ScrollbarRounding: ReadFloat(el, "scrollbarRounding"),
WindowBorderSize: ReadFloat(el, "windowBorderSize"),
FrameBorderSize: ReadFloat(el, "frameBorderSize")
);
// Optional in v2 — themes without a typography block default to the
// record's parameterless construction (both override slots null). A
// present-but-empty object also yields the default.
private static ThemeTypography ReadTypography(JsonElement root)
{
if (!root.TryGetProperty("typography", out var el) || el.ValueKind != JsonValueKind.Object)
return new ThemeTypography();
return new ThemeTypography(
OverrideGlobalFontSizePt: ReadOptionalFloat(el, "overrideGlobalFontSizePt"),
OverrideSymbolsFontSizePt: ReadOptionalFloat(el, "overrideSymbolsFontSizePt")
);
}
private static string ReadString(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.String)
throw new FormatException($"Theme JSON missing string property '{name}'");
return v.GetString() ?? throw new FormatException($"Theme JSON property '{name}' is null");
}
private static int ReadInt(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.Number)
throw new FormatException($"Theme JSON missing number property '{name}'");
return v.GetInt32();
}
private static float ReadFloat(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.Number)
throw new FormatException($"Theme JSON missing number property '{name}'");
return (float)v.GetDouble();
}
private static float? ReadOptionalFloat(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var v))
return null;
if (v.ValueKind == JsonValueKind.Null)
return null;
if (v.ValueKind != JsonValueKind.Number)
throw new FormatException($"Theme JSON property '{name}' must be a number or null");
return (float)v.GetDouble();
}
}