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:
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,9 +299,14 @@ public sealed class ThemeRegistry
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
theme = ThemeJsonLoader.LoadFromFile(path);
|
theme = ThemeJsonLoader.LoadFromFile(path);
|
||||||
|
// 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();
|
theme.RecomputeAbgrCache();
|
||||||
_customCache[key] = (theme, stamp);
|
_customCache[key] = (theme, stamp);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (Exception ex) when (IsRecoverableFileLock(ex))
|
catch (Exception ex) when (IsRecoverableFileLock(ex))
|
||||||
{
|
{
|
||||||
// Editor mid-save: keep last known good, retry on next refresh.
|
// Editor mid-save: keep last known good, retry on next refresh.
|
||||||
|
|||||||
Reference in New Issue
Block a user