| 1 | using System; |
| 2 | using System.Diagnostics.CodeAnalysis; |
| 3 | using System.Text.Json; |
| 4 | using Avalonia.Media; |
| 5 | using Avalonia.Threading; |
| 6 | |
| 7 | namespace CascadeIDE.Services; |
| 8 | |
| 9 | /// <summary> |
| 10 | /// Применение темы UI из JSON (тот же формат, что ide_get_ui_theme). |
| 11 | /// Обновляет Application.Resources; элементы с DynamicResource перерисуются. |
| 12 | /// </summary> |
| 13 | public static class UiThemeApply |
| 14 | { |
| 15 | private static (string path, DateTime lastWrite, string json)? _themeCache; |
| 16 | |
| 17 | /// <summary>После успешного обновления <see cref="Application.Current"/>.<c>Resources</c> (смена пресета / MCP).</summary> |
| 18 | public static event EventHandler? ThemeApplied; |
| 19 | |
| 20 | /// <summary>Загружает тему из файла рядом с приложением; при отсутствии — из встроенного ресурса (<see cref="BundledAppContent"/>). Перечитывает с диска только если изменились путь или дата модификации.</summary> |
| 21 | public static string GetThemeJsonFromFile(string filePath) |
| 22 | { |
| 23 | var path = CanonicalFilePath.Normalize(filePath); |
| 24 | if (File.Exists(path)) |
| 25 | { |
| 26 | var lastWrite = File.GetLastWriteTimeUtc(path); |
| 27 | if (_themeCache is { } c && c.path == path && c.lastWrite == lastWrite) |
| 28 | return c.json; |
| 29 | var json = File.ReadAllText(path); |
| 30 | _themeCache = (path, lastWrite, json); |
| 31 | return json; |
| 32 | } |
| 33 | |
| 34 | if (TryRelativeUnderAppBase(path, out var rel) && BundledAppContent.TryReadEmbeddedText(rel, out var embedded)) |
| 35 | return embedded; |
| 36 | |
| 37 | return "{}"; |
| 38 | } |
| 39 | |
| 40 | private static bool TryRelativeUnderAppBase(string fullPath, [NotNullWhen(true)] out string? relative) |
| 41 | { |
| 42 | relative = null; |
| 43 | try |
| 44 | { |
| 45 | var baseDir = CanonicalFilePath.Normalize(AppContext.BaseDirectory); |
| 46 | var fp = CanonicalFilePath.Normalize(fullPath); |
| 47 | if (!fp.StartsWith(baseDir, StringComparison.OrdinalIgnoreCase)) |
| 48 | return false; |
| 49 | relative = Path.GetRelativePath(baseDir, fp).Replace('\\', '/'); |
| 50 | return relative.Length > 0; |
| 51 | } |
| 52 | catch |
| 53 | { |
| 54 | return false; |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | /// <summary>Тёмная тема: Themes/dark-theme.json рядом с exe.</summary> |
| 59 | public static string GetDarkThemeJson() => |
| 60 | GetThemeJsonFromFile(Path.Combine(AppContext.BaseDirectory, "Themes", "dark-theme.json")); |
| 61 | |
| 62 | /// <summary>Светлая тема: Themes/light-theme.json рядом с exe.</summary> |
| 63 | public static string GetLightThemeJson() => |
| 64 | GetThemeJsonFromFile(Path.Combine(AppContext.BaseDirectory, "Themes", "light-theme.json")); |
| 65 | |
| 66 | /// <summary>Тема «как Cursor»: тёмная с акцентом #3794FF. Themes/cursor-like-theme.json.</summary> |
| 67 | public static string GetCursorLikeThemeJson() => |
| 68 | GetThemeJsonFromFile(Path.Combine(AppContext.BaseDirectory, "Themes", "cursor-like-theme.json")); |
| 69 | |
| 70 | /// <summary>Тема Power Mode: неоново-тёмная «космическая» палитра (циан). Themes/power-theme.json.</summary> |
| 71 | public static string GetPowerThemeJson() => |
| 72 | GetThemeJsonFromFile(Path.Combine(AppContext.BaseDirectory, "Themes", "power-theme.json")); |
| 73 | |
| 74 | /// <summary>Power cockpit по концепт-PNG: фиолет/янтарь/бирюза L1–L3, фуксийные каймы. Themes/power-cockpit-concept-theme.json.</summary> |
| 75 | public static string GetPowerCockpitConceptThemeJson() => |
| 76 | GetThemeJsonFromFile(Path.Combine(AppContext.BaseDirectory, "Themes", "power-cockpit-concept-theme.json")); |
| 77 | |
| 78 | /// <summary>Ключи ресурсов в Application.Resources. Должны совпадать с App.axaml и DynamicResource в XAML.</summary> |
| 79 | public static class Keys |
| 80 | { |
| 81 | public const string MainWindowBackground = "CascadeTheme.MainWindowBackground"; |
| 82 | public const string MenuBackground = "CascadeTheme.MenuBackground"; |
| 83 | public const string MenuForeground = "CascadeTheme.MenuForeground"; |
| 84 | public const string ButtonBackground = "CascadeTheme.ButtonBackground"; |
| 85 | public const string ButtonForeground = "CascadeTheme.ButtonForeground"; |
| 86 | public const string ButtonBorderBrush = "CascadeTheme.ButtonBorderBrush"; |
| 87 | public const string ButtonHoverBackground = "CascadeTheme.ButtonHoverBackground"; |
| 88 | public const string ButtonDisabledBackground = "CascadeTheme.ButtonDisabledBackground"; |
| 89 | public const string ButtonDisabledForeground = "CascadeTheme.ButtonDisabledForeground"; |
| 90 | public const string ToolbarBackground = "CascadeTheme.ToolbarBackground"; |
| 91 | public const string ToolbarTextForeground = "CascadeTheme.ToolbarTextForeground"; |
| 92 | public const string ToolbarErrorForeground = "CascadeTheme.ToolbarErrorForeground"; |
| 93 | public const string EditorBackground = "CascadeTheme.EditorBackground"; |
| 94 | public const string EditorForeground = "CascadeTheme.EditorForeground"; |
| 95 | public const string EditorSelectionBrush = "CascadeTheme.EditorSelectionBrush"; |
| 96 | public const string EditorSelectionForeground = "CascadeTheme.EditorSelectionForeground"; |
| 97 | public const string EditorColumnBorderBrush = "CascadeTheme.EditorColumnBorderBrush"; |
| 98 | public const string WorkspacePanelBorderBrush = "CascadeTheme.WorkspacePanelBorderBrush"; |
| 99 | public const string EditorColumnBackground = "CascadeTheme.EditorColumnBackground"; |
| 100 | public const string CurrentFileForeground = "CascadeTheme.CurrentFileForeground"; |
| 101 | public const string MarkdownPreviewPanelBackground = "CascadeTheme.MarkdownPreviewPanelBackground"; |
| 102 | public const string MarkdownPreviewPanelBorderBrush = "CascadeTheme.MarkdownPreviewPanelBorderBrush"; |
| 103 | public const string SolutionExplorerBorderBrush = "CascadeTheme.SolutionExplorerBorderBrush"; |
| 104 | public const string SolutionExplorerHeaderForeground = "CascadeTheme.SolutionExplorerHeaderForeground"; |
| 105 | public const string PanelChromeTitleForeground = "CascadeTheme.PanelChromeTitleForeground"; |
| 106 | public const string PanelTitleAccentBrush = "CascadeTheme.PanelTitleAccentBrush"; |
| 107 | public const string PanelChromeHeaderBackground = "CascadeTheme.PanelChromeHeaderBackground"; |
| 108 | public const string PanelChromeHeaderSeparatorBrush = "CascadeTheme.PanelChromeHeaderSeparatorBrush"; |
| 109 | public const string PanelChromeMenuGlyphForeground = "CascadeTheme.PanelChromeMenuGlyphForeground"; |
| 110 | public const string BuildOutputBackground = "CascadeTheme.BuildOutputBackground"; |
| 111 | public const string BuildOutputBorderBrush = "CascadeTheme.BuildOutputBorderBrush"; |
| 112 | public const string ChatPanelBackground = "CascadeTheme.ChatPanelBackground"; |
| 113 | public const string ChatLabelForeground = "CascadeTheme.ChatLabelForeground"; |
| 114 | public const string ChatMessageBubbleBackground = "CascadeTheme.ChatMessageBubbleBackground"; |
| 115 | public const string ChatMessageContentForeground = "CascadeTheme.ChatMessageContentForeground"; |
| 116 | public const string PanelBackgroundBrush = "CascadeTheme.PanelBackgroundBrush"; |
| 117 | public const string InsetSurfaceBackground = "CascadeTheme.InsetSurfaceBackground"; |
| 118 | public const string InsetSurfaceBorderBrush = "CascadeTheme.InsetSurfaceBorderBrush"; |
| 119 | public const string StatusChipBackground = "CascadeTheme.StatusChipBackground"; |
| 120 | public const string StatusChipBorderBrush = "CascadeTheme.StatusChipBorderBrush"; |
| 121 | public const string StatusChipForeground = "CascadeTheme.StatusChipForeground"; |
| 122 | public const string SendButtonBackground = "CascadeTheme.SendButtonBackground"; |
| 123 | public const string SendButtonForeground = "CascadeTheme.SendButtonForeground"; |
| 124 | public const string TerminalBackground = "CascadeTheme.TerminalBackground"; |
| 125 | public const string TerminalForeground = "CascadeTheme.TerminalForeground"; |
| 126 | public const string TerminalInputBackground = "CascadeTheme.TerminalInputBackground"; |
| 127 | public const string McpBannerBackground = "CascadeTheme.McpBannerBackground"; |
| 128 | public const string McpBannerForeground = "CascadeTheme.McpBannerForeground"; |
| 129 | public const string PreviewWindowBackground = "CascadeTheme.PreviewWindowBackground"; |
| 130 | /// <summary>Опционально: секция power_cockpit в JSON темы (неон, safety, Workspace Health).</summary> |
| 131 | public const string PowerNeonBorder = "CascadeTheme.PowerNeonBorder"; |
| 132 | public const string PowerNeonAccent = "CascadeTheme.PowerNeonAccent"; |
| 133 | public const string PowerCockpitPanelBackground = "CascadeTheme.PowerCockpitPanelBackground"; |
| 134 | public const string PowerSafetyDockBackground = "CascadeTheme.PowerSafetyDockBackground"; |
| 135 | public const string PowerWorkspaceHealthStripBackground = "CascadeTheme.PowerWorkspaceHealthStripBackground"; |
| 136 | public const string PowerSafetyObserve = "CascadeTheme.PowerSafetyObserve"; |
| 137 | public const string PowerSafetyConfirm = "CascadeTheme.PowerSafetyConfirm"; |
| 138 | public const string PowerSafetyAutonomous = "CascadeTheme.PowerSafetyAutonomous"; |
| 139 | public const string PowerEmergency = "CascadeTheme.PowerEmergency"; |
| 140 | } |
| 141 | |
| 142 | /// <summary>Применить тему из JSON (формат ide_get_ui_theme). Вызывать из UI-потока. Возвращает "OK" или сообщение об ошибке (для ответа тулу ide_set_ui_theme).</summary> |
| 143 | public static string Apply(string themeJson) |
| 144 | { |
| 145 | if (string.IsNullOrWhiteSpace(themeJson)) |
| 146 | return "OK"; |
| 147 | JsonDocument doc; |
| 148 | try |
| 149 | { |
| 150 | doc = JsonDocument.Parse(themeJson); |
| 151 | } |
| 152 | catch (JsonException ex) |
| 153 | { |
| 154 | var msg = $"Invalid JSON: {ex.Message}"; |
| 155 | System.Diagnostics.Debug.WriteLine($"ide_set_ui_theme: {msg}"); |
| 156 | return msg; |
| 157 | } |
| 158 | using (doc) |
| 159 | { |
| 160 | var root = doc.RootElement; |
| 161 | var res = GetResourceDictionary(); |
| 162 | if (res is null) |
| 163 | return "Application resources not available."; |
| 164 | |
| 165 | Set(res, Keys.MainWindowBackground, GetColor(root, "main_window", "background")); |
| 166 | Set(res, Keys.MenuBackground, GetColor(root, "menu", "background")); |
| 167 | Set(res, Keys.MenuForeground, GetColor(root, "menu", "foreground")); |
| 168 | Set(res, Keys.ButtonBackground, GetColor(root, "button", "background")); |
| 169 | Set(res, Keys.ButtonForeground, GetColor(root, "button", "foreground")); |
| 170 | Set(res, Keys.ButtonBorderBrush, GetColor(root, "button", "border_brush")); |
| 171 | Set(res, Keys.ButtonHoverBackground, GetColor(root, "button", "hover_background")); |
| 172 | Set(res, Keys.ButtonDisabledBackground, GetColor(root, "button", "disabled_background")); |
| 173 | Set(res, Keys.ButtonDisabledForeground, GetColor(root, "button", "disabled_foreground")); |
| 174 | Set(res, Keys.ToolbarBackground, GetColor(root, "toolbar", "background")); |
| 175 | Set(res, Keys.ToolbarTextForeground, GetColor(root, "toolbar_text", "foreground")); |
| 176 | Set(res, Keys.ToolbarErrorForeground, GetColor(root, "toolbar_text", "error_foreground")); |
| 177 | Set(res, Keys.EditorBackground, GetColor(root, "editor", "background")); |
| 178 | Set(res, Keys.EditorForeground, GetColor(root, "editor", "foreground")); |
| 179 | Set(res, Keys.EditorSelectionBrush, GetColor(root, "editor", "selection_brush")); |
| 180 | Set(res, Keys.EditorSelectionForeground, GetColor(root, "editor", "selection_foreground")); |
| 181 | Set(res, Keys.EditorColumnBorderBrush, GetColor(root, "editor_column", "border_brush")); |
| 182 | Set(res, Keys.WorkspacePanelBorderBrush, GetWorkspacePanelBorderBrush(root)); |
| 183 | Set(res, Keys.EditorColumnBackground, GetColor(root, "editor_column", "background")); |
| 184 | Set(res, Keys.CurrentFileForeground, GetColor(root, "editor_column", "current_file_foreground")); |
| 185 | Set(res, Keys.MarkdownPreviewPanelBackground, GetColor(root, "markdown_preview_panel", "background")); |
| 186 | Set(res, Keys.MarkdownPreviewPanelBorderBrush, GetColor(root, "markdown_preview_panel", "border_brush")); |
| 187 | Set(res, Keys.SolutionExplorerBorderBrush, GetColor(root, "solution_explorer", "border_brush")); |
| 188 | Set(res, Keys.SolutionExplorerHeaderForeground, GetColor(root, "solution_explorer", "header_foreground")); |
| 189 | Set(res, Keys.PanelChromeTitleForeground, GetColor(root, "panel_chrome", "title_foreground")); |
| 190 | Set(res, Keys.PanelTitleAccentBrush, GetColor(root, "panel_chrome", "accent_brush")); |
| 191 | Set(res, Keys.PanelChromeHeaderBackground, GetColor(root, "panel_chrome", "header_background")); |
| 192 | Set(res, Keys.PanelChromeHeaderSeparatorBrush, GetColor(root, "panel_chrome", "header_separator")); |
| 193 | Set(res, Keys.PanelChromeMenuGlyphForeground, GetColor(root, "panel_chrome", "menu_glyph_foreground")); |
| 194 | Set(res, Keys.BuildOutputBackground, GetColor(root, "build_output", "background")); |
| 195 | Set(res, Keys.BuildOutputBorderBrush, GetColor(root, "build_output", "border_brush")); |
| 196 | Set(res, Keys.ChatPanelBackground, GetColor(root, "chat_panel", "background")); |
| 197 | Set(res, Keys.ChatLabelForeground, GetColor(root, "chat_panel", "label_foreground")); |
| 198 | Set(res, Keys.ChatMessageBubbleBackground, GetColor(root, "chat_panel", "message_bubble_background")); |
| 199 | Set(res, Keys.ChatMessageContentForeground, GetColor(root, "chat_panel", "message_content_foreground")); |
| 200 | Set(res, Keys.PanelBackgroundBrush, GetIdeChromeColor(root, "panel_background") |
| 201 | ?? GetColor(root, "chat_panel", "message_bubble_background")); |
| 202 | Set(res, Keys.InsetSurfaceBackground, GetIdeChromeColor(root, "inset_surface_background")); |
| 203 | Set(res, Keys.InsetSurfaceBorderBrush, GetIdeChromeColor(root, "inset_surface_border_brush")); |
| 204 | Set(res, Keys.StatusChipBackground, GetIdeChromeColor(root, "status_chip_background")); |
| 205 | Set(res, Keys.StatusChipBorderBrush, GetIdeChromeColor(root, "status_chip_border_brush")); |
| 206 | Set(res, Keys.StatusChipForeground, GetIdeChromeColor(root, "status_chip_foreground")); |
| 207 | Set(res, Keys.SendButtonBackground, GetColor(root, "chat_panel", "send_button_background")); |
| 208 | Set(res, Keys.SendButtonForeground, GetColor(root, "chat_panel", "send_button_foreground")); |
| 209 | Set(res, Keys.TerminalBackground, GetColor(root, "terminal", "background")); |
| 210 | Set(res, Keys.TerminalForeground, GetColor(root, "terminal", "foreground")); |
| 211 | Set(res, Keys.TerminalInputBackground, GetColor(root, "terminal", "input_background")); |
| 212 | Set(res, Keys.McpBannerBackground, GetColor(root, "mcp_banner", "background")); |
| 213 | Set(res, Keys.McpBannerForeground, GetColor(root, "mcp_banner", "foreground")); |
| 214 | Set(res, Keys.PreviewWindowBackground, GetColor(root, "preview_window", "background")); |
| 215 | if (root.TryGetProperty("power_cockpit", out var pc)) |
| 216 | { |
| 217 | Set(res, Keys.PowerNeonBorder, GetColorFrom(pc, "neon_border")); |
| 218 | Set(res, Keys.PowerNeonAccent, GetColorFrom(pc, "neon_accent")); |
| 219 | Set(res, Keys.PowerCockpitPanelBackground, GetColorFrom(pc, "panel_background")); |
| 220 | Set(res, Keys.PowerSafetyDockBackground, GetColorFrom(pc, "safety_dock_background")); |
| 221 | Set(res, Keys.PowerWorkspaceHealthStripBackground, GetColorFrom(pc, "workspace_health_strip_background")); |
| 222 | Set(res, Keys.PowerSafetyObserve, GetColorFrom(pc, "safety_observe")); |
| 223 | Set(res, Keys.PowerSafetyConfirm, GetColorFrom(pc, "safety_confirm")); |
| 224 | Set(res, Keys.PowerSafetyAutonomous, GetColorFrom(pc, "safety_autonomous")); |
| 225 | Set(res, Keys.PowerEmergency, GetColorFrom(pc, "emergency")); |
| 226 | } |
| 227 | |
| 228 | ThemeApplied?.Invoke(null, EventArgs.Empty); |
| 229 | return "OK"; |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | private static string? GetColorFrom(JsonElement parent, string prop) |
| 234 | { |
| 235 | return parent.TryGetProperty(prop, out var p) ? p.GetString() : null; |
| 236 | } |
| 237 | |
| 238 | /// <summary>Запустить применение темы в UI-потоке и вернуть результат (для вызова из MCP).</summary> |
| 239 | public static async Task<string> ApplyOnUiThreadAsync(string themeJson) |
| 240 | { |
| 241 | var json = themeJson ?? ""; |
| 242 | return await UiScheduler.Default.InvokeAsync(() => Apply(json)); |
| 243 | } |
| 244 | |
| 245 | private static Avalonia.Controls.IResourceDictionary? GetResourceDictionary() |
| 246 | { |
| 247 | return Avalonia.Application.Current?.Resources; |
| 248 | } |
| 249 | |
| 250 | /// <summary>Рамки рабочей области: workspace_layout.border_brush или, если нет, editor_column.border_brush.</summary> |
| 251 | private static string? GetWorkspacePanelBorderBrush(JsonElement root) |
| 252 | { |
| 253 | var w = GetColor(root, "workspace_layout", "border_brush"); |
| 254 | return !string.IsNullOrEmpty(w) ? w : GetColor(root, "editor_column", "border_brush"); |
| 255 | } |
| 256 | |
| 257 | private static string? GetColor(JsonElement root, string obj, string prop) |
| 258 | { |
| 259 | if (root.TryGetProperty(obj, out var o) && o.TryGetProperty(prop, out var p)) |
| 260 | return p.GetString(); |
| 261 | return null; |
| 262 | } |
| 263 | |
| 264 | private static string? GetIdeChromeColor(JsonElement root, string prop) => |
| 265 | GetColor(root, "ide_chrome", prop); |
| 266 | |
| 267 | private static void Set(Avalonia.Controls.IResourceDictionary res, string key, string? hex) |
| 268 | { |
| 269 | if (string.IsNullOrEmpty(hex)) |
| 270 | return; |
| 271 | try |
| 272 | { |
| 273 | var brush = SolidColorBrush.Parse(hex); |
| 274 | res[key] = brush; |
| 275 | } |
| 276 | catch |
| 277 | { |
| 278 | // ignore invalid color |
| 279 | } |
| 280 | } |
| 281 | } |
| 282 | |