From 512533ed3a49e02618da220747a67f1051043a74 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 15 Jun 2026 16:46:13 +0200 Subject: [PATCH] feat(themes): default-fill missing colour/layout slots on theme load --- HellionChat/Themes/ThemeJsonLoader.cs | 161 ++++++++++++++---- HellionChat/Themes/ThemeRegistry.cs | 2 +- .../Settings/ThemeImportExportRow.cs | 2 +- 3 files changed, 126 insertions(+), 39 deletions(-) diff --git a/HellionChat/Themes/ThemeJsonLoader.cs b/HellionChat/Themes/ThemeJsonLoader.cs index 549caa8..b61db75 100644 --- a/HellionChat/Themes/ThemeJsonLoader.cs +++ b/HellionChat/Themes/ThemeJsonLoader.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using HellionChat.Themes.Builtin; using HellionChat.Util; +using Microsoft.Extensions.Logging; namespace HellionChat.Themes; @@ -11,7 +13,8 @@ internal static class ThemeJsonLoader // 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) + // B4b-2: 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"); @@ -43,8 +46,22 @@ internal static class ThemeJsonLoader var author = ReadString(root, "author"); var description = ReadString(root, "description"); - var colors = ReadColors(root.GetProperty("colors")); - var layout = ReadLayout(root.GetProperty("layout")); + // 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; @@ -93,52 +110,72 @@ internal static class ThemeJsonLoader return new ThemeChatColors(dict); } - public static Theme? LoadFromFile(string path) + 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); + return LoadFromString(json, logger); } - private static ThemeColors ReadColors(JsonElement el) => + private static ThemeColors ReadColors(JsonElement el, ThemeColors fallback, ILogger? logger) => 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")) + 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) => + private static ThemeLayout ReadLayout(JsonElement el, ThemeLayout fallback, ILogger? logger) => 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") + 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 @@ -186,4 +223,54 @@ internal static class ThemeJsonLoader 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(); + } } diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs index f86c5bb..5089c6d 100644 --- a/HellionChat/Themes/ThemeRegistry.cs +++ b/HellionChat/Themes/ThemeRegistry.cs @@ -626,7 +626,7 @@ public sealed class ThemeRegistry { try { - theme = ThemeJsonLoader.LoadFromFile(path); + theme = ThemeJsonLoader.LoadFromFile(path, _logger); // 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) diff --git a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs index 25045d3..17a9743 100644 --- a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs +++ b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs @@ -150,7 +150,7 @@ internal sealed class ThemeImportExportRow Theme? theme; try { - theme = ThemeJsonLoader.LoadFromString(json); + theme = ThemeJsonLoader.LoadFromString(json, _logger); } catch (FormatException) {