From 620dfe9ea08b27739c3f78f0afdf4aae10d67e37 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sat, 23 May 2026 17:38:23 +0200 Subject: [PATCH] feat(themes): bump JSON schema to v2 with typography roundtrip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- HellionChat/Themes/ThemeJsonLoader.cs | 44 +++++++++++++++++++++++---- HellionChat/Themes/ThemeJsonWriter.cs | 24 +++++++++++++++ HellionChat/Themes/ThemeRegistry.cs | 9 ++++-- 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/HellionChat/Themes/ThemeJsonLoader.cs b/HellionChat/Themes/ThemeJsonLoader.cs index 88a4c32..549caa8 100644 --- a/HellionChat/Themes/ThemeJsonLoader.cs +++ b/HellionChat/Themes/ThemeJsonLoader.cs @@ -5,9 +5,13 @@ namespace HellionChat.Themes; 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)) throw new FormatException("Theme JSON is empty"); @@ -27,9 +31,11 @@ internal static class ThemeJsonLoader var root = doc.RootElement; var schemaVersion = ReadInt(root, "schemaVersion"); - if (schemaVersion != SupportedSchemaVersion) + if (schemaVersion < SupportedSchemaVersion) + return null; + if (schemaVersion > SupportedSchemaVersion) throw new FormatException( - $"Unsupported schemaVersion {schemaVersion}; expected {SupportedSchemaVersion}" + $"Unsupported schemaVersion {schemaVersion}; this build reads up to {SupportedSchemaVersion}" ); var slug = ReadString(root, "slug"); @@ -39,6 +45,7 @@ internal static class ThemeJsonLoader var colors = ReadColors(root.GetProperty("colors")); var layout = ReadLayout(root.GetProperty("layout")); + var typography = ReadTypography(root); ThemeChatColors? chatColors = null; if ( @@ -54,7 +61,7 @@ internal static class ThemeJsonLoader description, colors, layout, - new ThemeTypography(), + typography, IsBuiltIn: false, ChatColors: chatColors ); @@ -86,7 +93,7 @@ internal static class ThemeJsonLoader 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 // the handle; atomic-replace editors still raise IOException, caught upstream. @@ -134,6 +141,20 @@ internal static class ThemeJsonLoader 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) @@ -154,4 +175,15 @@ internal static class ThemeJsonLoader 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(); + } } diff --git a/HellionChat/Themes/ThemeJsonWriter.cs b/HellionChat/Themes/ThemeJsonWriter.cs index f693a49..356c5ed 100644 --- a/HellionChat/Themes/ThemeJsonWriter.cs +++ b/HellionChat/Themes/ThemeJsonWriter.cs @@ -52,6 +52,22 @@ internal static class ThemeJsonWriter writer.WriteNumber("frameBorderSize", theme.Layout.FrameBorderSize); 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) { writer.WriteStartObject("chatChannels"); @@ -70,4 +86,12 @@ internal static class ThemeJsonWriter { 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); + } } diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs index cbac2c1..c706e34 100644 --- a/HellionChat/Themes/ThemeRegistry.cs +++ b/HellionChat/Themes/ThemeRegistry.cs @@ -299,8 +299,13 @@ public sealed class ThemeRegistry try { theme = ThemeJsonLoader.LoadFromFile(path); - theme.RecomputeAbgrCache(); - _customCache[key] = (theme, stamp); + // null = hard-cut policy skipped a legacy v1 file. Leave + // 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)) {