Codes like POP-1c or B4b-2 name a task in a planning document, not anything in the code. A reader has no way to resolve them and they age into noise the moment the document is closed. Where a code was used as a reference, the sentence now names the function it meant.
277 lines
11 KiB
C#
277 lines
11 KiB
C#
using System.Text.Json;
|
|
using HellionChat.Themes.Builtin;
|
|
using HellionChat.Util;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
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.
|
|
// Callers must pass the logger or the default-fill warnings go silent.
|
|
public static Theme? LoadFromString(string json, ILogger? logger = null)
|
|
{
|
|
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");
|
|
|
|
// Missing colours/layout object stays fatal, but as FormatException so the
|
|
// import path catches it — GetProperty's KeyNotFoundException would crash.
|
|
if (
|
|
!root.TryGetProperty("colors", out var colorsEl)
|
|
|| colorsEl.ValueKind != JsonValueKind.Object
|
|
)
|
|
throw new FormatException("Theme JSON missing 'colors' object");
|
|
if (
|
|
!root.TryGetProperty("layout", out var layoutEl)
|
|
|| layoutEl.ValueKind != JsonValueKind.Object
|
|
)
|
|
throw new FormatException("Theme JSON missing 'layout' object");
|
|
|
|
var fallback = HellionArctic.Build();
|
|
var colors = ReadColors(colorsEl, fallback.Colors, logger);
|
|
var layout = ReadLayout(layoutEl, fallback.Layout, logger);
|
|
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, ILogger? logger = null)
|
|
{
|
|
// 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, logger);
|
|
}
|
|
|
|
private static ThemeColors ReadColors(JsonElement el, ThemeColors fallback, ILogger? logger) =>
|
|
new(
|
|
PrimaryDark: ReadColorOrDefault(el, "primaryDark", fallback.PrimaryDark, logger),
|
|
Primary: ReadColorOrDefault(el, "primary", fallback.Primary, logger),
|
|
PrimaryLight: ReadColorOrDefault(el, "primaryLight", fallback.PrimaryLight, logger),
|
|
PrimaryGlow: ReadColorOrDefault(el, "primaryGlow", fallback.PrimaryGlow, logger),
|
|
AccentDark: ReadColorOrDefault(el, "accentDark", fallback.AccentDark, logger),
|
|
Accent: ReadColorOrDefault(el, "accent", fallback.Accent, logger),
|
|
AccentLight: ReadColorOrDefault(el, "accentLight", fallback.AccentLight, logger),
|
|
Identity: ReadColorOrDefault(el, "identity", fallback.Identity, logger),
|
|
WindowBg: ReadColorOrDefault(el, "windowBg", fallback.WindowBg, logger),
|
|
ChildBg: ReadColorOrDefault(el, "childBg", fallback.ChildBg, logger),
|
|
FrameBg: ReadColorOrDefault(el, "frameBg", fallback.FrameBg, logger),
|
|
Surface: ReadColorOrDefault(el, "surface", fallback.Surface, logger),
|
|
SurfaceHover: ReadColorOrDefault(el, "surfaceHover", fallback.SurfaceHover, logger),
|
|
Border: ReadColorOrDefault(el, "border", fallback.Border, logger),
|
|
TextPrimary: ReadColorOrDefault(el, "textPrimary", fallback.TextPrimary, logger),
|
|
TextMuted: ReadColorOrDefault(el, "textMuted", fallback.TextMuted, logger),
|
|
TextDim: ReadColorOrDefault(el, "textDim", fallback.TextDim, logger),
|
|
StatusSuccess: ReadColorOrDefault(el, "statusSuccess", fallback.StatusSuccess, logger),
|
|
StatusDanger: ReadColorOrDefault(el, "statusDanger", fallback.StatusDanger, logger),
|
|
StatusWarning: ReadColorOrDefault(el, "statusWarning", fallback.StatusWarning, logger),
|
|
StatusInfo: ReadColorOrDefault(el, "statusInfo", fallback.StatusInfo, logger)
|
|
);
|
|
|
|
private static ThemeLayout ReadLayout(JsonElement el, ThemeLayout fallback, ILogger? logger) =>
|
|
new(
|
|
WindowRounding: ReadFloatOrDefault(
|
|
el,
|
|
"windowRounding",
|
|
fallback.WindowRounding,
|
|
logger
|
|
),
|
|
ChildRounding: ReadFloatOrDefault(el, "childRounding", fallback.ChildRounding, logger),
|
|
PopupRounding: ReadFloatOrDefault(el, "popupRounding", fallback.PopupRounding, logger),
|
|
FrameRounding: ReadFloatOrDefault(el, "frameRounding", fallback.FrameRounding, logger),
|
|
GrabRounding: ReadFloatOrDefault(el, "grabRounding", fallback.GrabRounding, logger),
|
|
TabRounding: ReadFloatOrDefault(el, "tabRounding", fallback.TabRounding, logger),
|
|
ScrollbarRounding: ReadFloatOrDefault(
|
|
el,
|
|
"scrollbarRounding",
|
|
fallback.ScrollbarRounding,
|
|
logger
|
|
),
|
|
WindowBorderSize: ReadFloatOrDefault(
|
|
el,
|
|
"windowBorderSize",
|
|
fallback.WindowBorderSize,
|
|
logger
|
|
),
|
|
FrameBorderSize: ReadFloatOrDefault(
|
|
el,
|
|
"frameBorderSize",
|
|
fallback.FrameBorderSize,
|
|
logger
|
|
)
|
|
);
|
|
|
|
// 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();
|
|
}
|
|
|
|
// Missing / wrong-typed / unparseable colour slot -> built-in default + one warning.
|
|
private static uint ReadColorOrDefault(
|
|
JsonElement el,
|
|
string name,
|
|
uint fallback,
|
|
ILogger? logger
|
|
)
|
|
{
|
|
if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.String)
|
|
{
|
|
logger?.LogWarning(
|
|
"Theme JSON colour slot '{Slot}' missing or not a string, using built-in default",
|
|
name
|
|
);
|
|
return fallback;
|
|
}
|
|
|
|
try
|
|
{
|
|
return ColourUtil.HexToRgba(v.GetString()!);
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
logger?.LogWarning(
|
|
"Theme JSON colour slot '{Slot}' has an invalid hex value, using built-in default",
|
|
name
|
|
);
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
private static float ReadFloatOrDefault(
|
|
JsonElement el,
|
|
string name,
|
|
float fallback,
|
|
ILogger? logger
|
|
)
|
|
{
|
|
if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.Number)
|
|
{
|
|
logger?.LogWarning(
|
|
"Theme JSON layout slot '{Slot}' missing or not a number, using built-in default",
|
|
name
|
|
);
|
|
return fallback;
|
|
}
|
|
|
|
return (float)v.GetDouble();
|
|
}
|
|
}
|