using System.Text.Json; using XFESystem.Core; namespace XFESystem.Desktop; public enum DesktopThemeMode { Light, Dark } /// Immutable guest palette. Custom accent ink is adjusted for legibility; its stored RGB is preserved. public sealed class DesktopTheme { public const uint DefaultAccent = 0x657DE2; public const string SettingsDirectory = "X:\\System\\Settings"; public const string SettingsPath = SettingsDirectory + "\\Appearance.json"; public DesktopThemeMode Mode { get; } public uint Accent { get; } public string AccentHex => "#" + Accent.ToString("X6"); public bool IsDark => Mode == DesktopThemeMode.Dark; public uint Background => IsDark ? 0x09121Bu : 0xEEEFF8u; public uint Panel => IsDark ? 0x102538u : 0xFAFAFEu; public uint Surface => IsDark ? 0x09121Bu : 0xFFFFFFu; public uint Text => IsDark ? 0xE7F3FCu : 0x343C56u; public uint Muted => IsDark ? 0x8EA5BAu : 0x606B82u; public uint Shadow => IsDark ? 0x050B10u : 0xD9DEEDu; public uint Border => IsDark ? 0x285171u : 0xD9DDEEu; public uint AccentInk { get; } public uint AccentStroke { get; } public uint Selection { get; } public uint SoftAccent { get; } public uint ActiveBorder { get; } public uint ActiveTitle { get; } public uint AccentText => IsDark && Accent == DefaultAccent ? 0x75C9F5u : AccentInk; public DesktopTheme(DesktopThemeMode mode = DesktopThemeMode.Dark, uint accent = DefaultAccent) { if (!Enum.IsDefined(mode)) throw new ArgumentOutOfRangeException(nameof(mode)); if (accent > 0xFFFFFF) throw new ArgumentOutOfRangeException(nameof(accent)); Mode = mode; Accent = accent; uint displayedAccent = IsDark && accent == DefaultAccent ? 0x1CC0E8u : accent; AccentInk = Legible(displayedAccent, IsDark ? Panel : Background, 4.5); AccentStroke = Legible(displayedAccent, Surface, 3); Selection = Mix(Panel, accent, IsDark ? 25 : 14); SoftAccent = Mix(Panel, accent, IsDark ? 12 : 8); ActiveBorder = IsDark && accent == DefaultAccent ? 0x285171u : Mix(Border, accent, 38); ActiveTitle = IsDark ? (accent == DefaultAccent ? 0x285171u : Mix(Panel, accent, 30)) : Panel; } public uint SyntaxColor(uint original) => IsDark ? original : original switch { 0xDCEAF4 => Text, 0x739D81 => 0x477759, 0xD6BB80 => 0x906119, 0xB99AEE => 0x6D4CAB, 0x77D7CE => 0x237A81, _ => original }; public static int PresetCount => 5; public static (string Name, uint Color) Preset(int index) => index switch { 0 => ("Blue", DefaultAccent), 1 => ("Purple", 0x9898E7), 2 => ("Teal", 0x239F99), 3 => ("Rose", 0xCE6A93), 4 => ("Amber", 0xC38A2C), _ => throw new ArgumentOutOfRangeException(nameof(index)) }; public static bool TryParseAccent(string value, out uint accent) { accent = 0; if (value.Length != 7 || value[0] != '#') return false; for (int i = 1; i < value.Length; i++) { char c = value[i]; int digit = c is >= '0' and <= '9' ? c - '0' : c is >= 'A' and <= 'F' ? c - 'A' + 10 : c is >= 'a' and <= 'f' ? c - 'a' + 10 : -1; if (digit < 0) return false; accent = accent * 16 + (uint)digit; } return true; } public string ToSettingsJson() => "{\"Version\":1,\"Theme\":\"" + Mode.ToString() + "\",\"Accent\":\"" + AccentHex + "\"}"; public static DesktopTheme ParseSettings(string text) { if (text.Length > 1024) throw new FormatException("Appearance settings exceed the supported size."); using var document = JsonDocument.Parse(text, new JsonDocumentOptions { MaxDepth = 4 }); if (document.RootElement.ValueKind != JsonValueKind.Object) throw new FormatException("Appearance settings must be an object."); int fields = 0; DesktopThemeMode mode = DesktopThemeMode.Light; uint accent = DefaultAccent; foreach (var property in document.RootElement.EnumerateObject()) { int flag = property.Name switch { "Version" => 1, "Theme" => 2, "Accent" => 4, _ => throw new FormatException("Unknown appearance setting.") }; if ((fields & flag) != 0) throw new FormatException("Duplicate appearance setting."); fields |= flag; if (flag == 1 && (property.Value.ValueKind != JsonValueKind.Number || !property.Value.TryGetInt32(out int version) || version != 1)) throw new FormatException("Unsupported appearance settings version."); if (flag == 2) { string? value = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null; mode = value switch { "Light" => DesktopThemeMode.Light, "Dark" => DesktopThemeMode.Dark, _ => throw new FormatException("Unsupported theme.") }; } if (flag == 4 && (property.Value.ValueKind != JsonValueKind.String || !TryParseAccent(property.Value.GetString()!, out accent))) throw new FormatException("Invalid appearance accent."); } if (fields != 7) throw new FormatException("Incomplete appearance settings."); return new(mode, accent); } public static DesktopTheme Load(IWorkspaceStorage storage, out string? error) { error = null; try { return storage.Exists(SettingsPath) ? ParseSettings(storage.ReadText(SettingsPath)) : new(); } catch (Exception failure) when (failure is IOException or UnauthorizedAccessException or FormatException or JsonException or InvalidOperationException) { error = "Appearance settings could not be loaded: " + failure.Message; return new(); } } public void Save(IWorkspaceStorage storage) { storage.CreateDirectory(SettingsDirectory); storage.WriteText(SettingsPath, ToSettingsJson()); } public static uint Mix(uint background, uint foreground, int percent) { int inverse = 100 - percent; uint red = (uint)(((background >> 16 & 255) * inverse + (foreground >> 16 & 255) * percent + 50) / 100); uint green = (uint)(((background >> 8 & 255) * inverse + (foreground >> 8 & 255) * percent + 50) / 100); uint blue = (uint)(((background & 255) * inverse + (foreground & 255) * percent + 50) / 100); return red << 16 | green << 8 | blue; } public static double Contrast(uint first, uint second) { double a = Luminance(first), b = Luminance(second); return (Math.Max(a, b) + 0.05) / (Math.Min(a, b) + 0.05); } private static double Luminance(uint color) { static double Channel(uint value) { double c = value / 255.0; return c <= 0.04045 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4); } return Channel(color >> 16 & 255) * 0.2126 + Channel(color >> 8 & 255) * 0.7152 + Channel(color & 255) * 0.0722; } private static uint Legible(uint color, uint background, double minimum) { if (Contrast(color, background) >= minimum) return color; uint endpoint = Contrast(0, background) >= Contrast(0xFFFFFF, background) ? 0u : 0xFFFFFFu; for (int percent = 5; percent <= 100; percent += 5) { uint candidate = Mix(color, endpoint, percent); if (Contrast(candidate, background) >= minimum) return candidate; } return endpoint; } }