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.
This commit is contained in:
2026-05-23 17:38:23 +02:00
parent cd9e43c183
commit 620dfe9ea0
3 changed files with 69 additions and 8 deletions
+38 -6
View File
@@ -5,9 +5,13 @@ namespace HellionChat.Themes;
internal static class ThemeJsonLoader internal static class ThemeJsonLoader
{ {
public const int SupportedSchemaVersion = 1; public const int SupportedSchemaVersion = 2;
public static Theme LoadFromString(string json) // 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)) if (string.IsNullOrWhiteSpace(json))
throw new FormatException("Theme JSON is empty"); throw new FormatException("Theme JSON is empty");
@@ -27,9 +31,11 @@ internal static class ThemeJsonLoader
var root = doc.RootElement; var root = doc.RootElement;
var schemaVersion = ReadInt(root, "schemaVersion"); var schemaVersion = ReadInt(root, "schemaVersion");
if (schemaVersion != SupportedSchemaVersion) if (schemaVersion < SupportedSchemaVersion)
return null;
if (schemaVersion > SupportedSchemaVersion)
throw new FormatException( throw new FormatException(
$"Unsupported schemaVersion {schemaVersion}; expected {SupportedSchemaVersion}" $"Unsupported schemaVersion {schemaVersion}; this build reads up to {SupportedSchemaVersion}"
); );
var slug = ReadString(root, "slug"); var slug = ReadString(root, "slug");
@@ -39,6 +45,7 @@ internal static class ThemeJsonLoader
var colors = ReadColors(root.GetProperty("colors")); var colors = ReadColors(root.GetProperty("colors"));
var layout = ReadLayout(root.GetProperty("layout")); var layout = ReadLayout(root.GetProperty("layout"));
var typography = ReadTypography(root);
ThemeChatColors? chatColors = null; ThemeChatColors? chatColors = null;
if ( if (
@@ -54,7 +61,7 @@ internal static class ThemeJsonLoader
description, description,
colors, colors,
layout, layout,
new ThemeTypography(), typography,
IsBuiltIn: false, IsBuiltIn: false,
ChatColors: chatColors ChatColors: chatColors
); );
@@ -86,7 +93,7 @@ internal static class ThemeJsonLoader
return new ThemeChatColors(dict); return new ThemeChatColors(dict);
} }
public static Theme LoadFromFile(string path) public static Theme? LoadFromFile(string path)
{ {
// FileShare.Read lets concurrent readers and well-behaved editors share // FileShare.Read lets concurrent readers and well-behaved editors share
// the handle; atomic-replace editors still raise IOException, caught upstream. // the handle; atomic-replace editors still raise IOException, caught upstream.
@@ -134,6 +141,20 @@ internal static class ThemeJsonLoader
FrameBorderSize: ReadFloat(el, "frameBorderSize") 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) private static string ReadString(JsonElement el, string name)
{ {
if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.String) if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.String)
@@ -154,4 +175,15 @@ internal static class ThemeJsonLoader
throw new FormatException($"Theme JSON missing number property '{name}'"); throw new FormatException($"Theme JSON missing number property '{name}'");
return (float)v.GetDouble(); 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();
}
} }
+24
View File
@@ -52,6 +52,22 @@ internal static class ThemeJsonWriter
writer.WriteNumber("frameBorderSize", theme.Layout.FrameBorderSize); writer.WriteNumber("frameBorderSize", theme.Layout.FrameBorderSize);
writer.WriteEndObject(); writer.WriteEndObject();
// Typography always written so a hand-edited file shows the
// available knobs even when the user has not picked any
// override yet.
writer.WriteStartObject("typography");
WriteOptionalFloat(
writer,
"overrideGlobalFontSizePt",
theme.Typography.OverrideGlobalFontSizePt
);
WriteOptionalFloat(
writer,
"overrideSymbolsFontSizePt",
theme.Typography.OverrideSymbolsFontSizePt
);
writer.WriteEndObject();
if (theme.ChatColors is { Channels.Count: > 0 } cc) if (theme.ChatColors is { Channels.Count: > 0 } cc)
{ {
writer.WriteStartObject("chatChannels"); writer.WriteStartObject("chatChannels");
@@ -70,4 +86,12 @@ internal static class ThemeJsonWriter
{ {
writer.WriteString(key, $"#{rgba:X8}"); writer.WriteString(key, $"#{rgba:X8}");
} }
private static void WriteOptionalFloat(Utf8JsonWriter writer, string key, float? value)
{
if (value.HasValue)
writer.WriteNumber(key, value.Value);
else
writer.WriteNull(key);
}
} }
+7 -2
View File
@@ -299,8 +299,13 @@ public sealed class ThemeRegistry
try try
{ {
theme = ThemeJsonLoader.LoadFromFile(path); theme = ThemeJsonLoader.LoadFromFile(path);
theme.RecomputeAbgrCache(); // null = hard-cut policy skipped a legacy v1 file. Leave
_customCache[key] = (theme, stamp); // theme null so the yield-guard below drops the entry.
if (theme is not null)
{
theme.RecomputeAbgrCache();
_customCache[key] = (theme, stamp);
}
} }
catch (Exception ex) when (IsRecoverableFileLock(ex)) catch (Exception ex) when (IsRecoverableFileLock(ex))
{ {