| 1 | using System.Globalization; |
| 2 | using System.Text.Json; |
| 3 | using System.Text.Json.Nodes; |
| 4 | using System.Text.Json.Serialization; |
| 5 | using Avalonia; |
| 6 | using Avalonia.Controls; |
| 7 | using Avalonia.Media; |
| 8 | using Avalonia.Threading; |
| 9 | |
| 10 | namespace CascadeIDE.Services; |
| 11 | |
| 12 | /// <summary> |
| 13 | /// Снимок цветов UI для агента (<c>ide_get_ui_theme</c>): читает <see cref="Application.Current"/>.<see cref="StyledElement.Resources"/> |
| 14 | /// (те же ключи <c>CascadeTheme.*</c>, что и <see cref="UiThemeApply"/> / <c>App.axaml</c>). |
| 15 | /// </summary> |
| 16 | public static class UiThemeSnapshot |
| 17 | { |
| 18 | private static readonly JsonSerializerOptions Options = new() |
| 19 | { |
| 20 | WriteIndented = true, |
| 21 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull |
| 22 | }; |
| 23 | |
| 24 | /// <summary>JSON для MCP: актуальные кисти из Application.Resources (с UI-потока).</summary> |
| 25 | public static string GetJson() |
| 26 | { |
| 27 | if (Application.Current is null) |
| 28 | return SerializeStaticFallback(); |
| 29 | |
| 30 | if (UiScheduler.Default.CheckAccess()) |
| 31 | return CaptureAndSerialize(Application.Current); |
| 32 | |
| 33 | return UiScheduler.Default.Invoke(() => |
| 34 | { |
| 35 | if (Application.Current is null) |
| 36 | return SerializeStaticFallback(); |
| 37 | return CaptureAndSerialize(Application.Current); |
| 38 | }); |
| 39 | } |
| 40 | |
| 41 | private static string CaptureAndSerialize(Application app) |
| 42 | { |
| 43 | var res = app.Resources; |
| 44 | var variantName = app.ActualThemeVariant.Key.ToString(); |
| 45 | var requestedName = app.RequestedThemeVariant?.Key.ToString() ?? "Default"; |
| 46 | |
| 47 | static string? H(IResourceDictionary d, string k) => TryBrushString(d, k); |
| 48 | |
| 49 | var mainBg = H(res, UiThemeApply.Keys.MainWindowBackground); |
| 50 | var menuBg = H(res, UiThemeApply.Keys.MenuBackground); |
| 51 | var menuFg = H(res, UiThemeApply.Keys.MenuForeground); |
| 52 | |
| 53 | var theme = new |
| 54 | { |
| 55 | _snapshot = new |
| 56 | { |
| 57 | source = "application.resources", |
| 58 | actual_theme_variant = variantName, |
| 59 | requested_theme_variant = requestedName |
| 60 | }, |
| 61 | main_window = new { background = mainBg }, |
| 62 | menu = new { background = menuBg, foreground = menuFg }, |
| 63 | menu_item = new { foreground = menuFg }, |
| 64 | button = new |
| 65 | { |
| 66 | background = H(res, UiThemeApply.Keys.ButtonBackground), |
| 67 | foreground = H(res, UiThemeApply.Keys.ButtonForeground), |
| 68 | border_brush = H(res, UiThemeApply.Keys.ButtonBorderBrush), |
| 69 | border_thickness = 1, |
| 70 | hover_background = H(res, UiThemeApply.Keys.ButtonHoverBackground), |
| 71 | disabled_background = H(res, UiThemeApply.Keys.ButtonDisabledBackground), |
| 72 | disabled_foreground = H(res, UiThemeApply.Keys.ButtonDisabledForeground) |
| 73 | }, |
| 74 | toolbar = new { background = H(res, UiThemeApply.Keys.ToolbarBackground) }, |
| 75 | toolbar_text = new |
| 76 | { |
| 77 | foreground = H(res, UiThemeApply.Keys.ToolbarTextForeground), |
| 78 | error_foreground = H(res, UiThemeApply.Keys.ToolbarErrorForeground) |
| 79 | }, |
| 80 | editor = new |
| 81 | { |
| 82 | background = H(res, UiThemeApply.Keys.EditorBackground), |
| 83 | foreground = H(res, UiThemeApply.Keys.EditorForeground), |
| 84 | selection_brush = H(res, UiThemeApply.Keys.EditorSelectionBrush), |
| 85 | selection_foreground = H(res, UiThemeApply.Keys.EditorSelectionForeground), |
| 86 | font_family = "Consolas, Cascadia Code, monospace", |
| 87 | font_size = 13 |
| 88 | }, |
| 89 | editor_column = new |
| 90 | { |
| 91 | border_brush = H(res, UiThemeApply.Keys.EditorColumnBorderBrush), |
| 92 | background = H(res, UiThemeApply.Keys.EditorColumnBackground), |
| 93 | current_file_foreground = H(res, UiThemeApply.Keys.CurrentFileForeground) |
| 94 | }, |
| 95 | workspace_layout = new |
| 96 | { |
| 97 | border_brush = H(res, UiThemeApply.Keys.WorkspacePanelBorderBrush) |
| 98 | }, |
| 99 | markdown_preview_panel = new |
| 100 | { |
| 101 | background = H(res, UiThemeApply.Keys.MarkdownPreviewPanelBackground), |
| 102 | border_brush = H(res, UiThemeApply.Keys.MarkdownPreviewPanelBorderBrush) |
| 103 | }, |
| 104 | solution_explorer = new |
| 105 | { |
| 106 | border_brush = H(res, UiThemeApply.Keys.SolutionExplorerBorderBrush), |
| 107 | header_foreground = H(res, UiThemeApply.Keys.SolutionExplorerHeaderForeground), |
| 108 | tree_item_foreground = H(res, UiThemeApply.Keys.SolutionExplorerHeaderForeground) |
| 109 | }, |
| 110 | panel_chrome = new |
| 111 | { |
| 112 | title_foreground = H(res, UiThemeApply.Keys.PanelChromeTitleForeground), |
| 113 | accent_brush = H(res, UiThemeApply.Keys.PanelTitleAccentBrush), |
| 114 | header_background = H(res, UiThemeApply.Keys.PanelChromeHeaderBackground), |
| 115 | header_separator = H(res, UiThemeApply.Keys.PanelChromeHeaderSeparatorBrush), |
| 116 | menu_glyph_foreground = H(res, UiThemeApply.Keys.PanelChromeMenuGlyphForeground) |
| 117 | }, |
| 118 | build_output = new |
| 119 | { |
| 120 | background = H(res, UiThemeApply.Keys.BuildOutputBackground), |
| 121 | foreground = H(res, UiThemeApply.Keys.TerminalForeground), |
| 122 | border_brush = H(res, UiThemeApply.Keys.BuildOutputBorderBrush) |
| 123 | }, |
| 124 | chat_panel = new |
| 125 | { |
| 126 | background = H(res, UiThemeApply.Keys.ChatPanelBackground), |
| 127 | label_foreground = H(res, UiThemeApply.Keys.ChatLabelForeground), |
| 128 | secondary_foreground = H(res, UiThemeApply.Keys.ChatLabelForeground), |
| 129 | message_bubble_background = H(res, UiThemeApply.Keys.ChatMessageBubbleBackground), |
| 130 | message_role_foreground = H(res, UiThemeApply.Keys.ChatLabelForeground), |
| 131 | message_content_foreground = H(res, UiThemeApply.Keys.ChatMessageContentForeground), |
| 132 | send_button_background = H(res, UiThemeApply.Keys.SendButtonBackground), |
| 133 | send_button_foreground = H(res, UiThemeApply.Keys.SendButtonForeground), |
| 134 | checkbox_foreground = H(res, UiThemeApply.Keys.ChatLabelForeground) |
| 135 | }, |
| 136 | terminal = new |
| 137 | { |
| 138 | background = H(res, UiThemeApply.Keys.TerminalBackground), |
| 139 | foreground = H(res, UiThemeApply.Keys.TerminalForeground), |
| 140 | input_background = H(res, UiThemeApply.Keys.TerminalInputBackground), |
| 141 | font_family = "Consolas, monospace", |
| 142 | font_size = 12 |
| 143 | }, |
| 144 | mcp_banner = new |
| 145 | { |
| 146 | background = H(res, UiThemeApply.Keys.McpBannerBackground), |
| 147 | foreground = H(res, UiThemeApply.Keys.McpBannerForeground) |
| 148 | }, |
| 149 | preview_window = new |
| 150 | { |
| 151 | background = H(res, UiThemeApply.Keys.PreviewWindowBackground), |
| 152 | content_area_background = H(res, UiThemeApply.Keys.PreviewWindowBackground) |
| 153 | }, |
| 154 | power_cockpit = new |
| 155 | { |
| 156 | neon_border = H(res, UiThemeApply.Keys.PowerNeonBorder), |
| 157 | neon_accent = H(res, UiThemeApply.Keys.PowerNeonAccent), |
| 158 | panel_background = H(res, UiThemeApply.Keys.PowerCockpitPanelBackground), |
| 159 | safety_dock_background = H(res, UiThemeApply.Keys.PowerSafetyDockBackground), |
| 160 | workspace_health_strip_background = H(res, UiThemeApply.Keys.PowerWorkspaceHealthStripBackground), |
| 161 | safety_observe = H(res, UiThemeApply.Keys.PowerSafetyObserve), |
| 162 | safety_confirm = H(res, UiThemeApply.Keys.PowerSafetyConfirm), |
| 163 | safety_autonomous = H(res, UiThemeApply.Keys.PowerSafetyAutonomous), |
| 164 | emergency = H(res, UiThemeApply.Keys.PowerEmergency) |
| 165 | }, |
| 166 | solution_explorer_tree_power = new |
| 167 | { |
| 168 | panel_background = H(res, "CascadeTheme.PowerSolutionTreePanelBackground"), |
| 169 | tree_foreground = H(res, "CascadeTheme.PowerSolutionTreeForeground"), |
| 170 | item_selected_background = H(res, "CascadeTheme.PowerSolutionTreeItemSelectedBackground"), |
| 171 | item_pointer_over_background = H(res, "CascadeTheme.PowerSolutionTreeItemPointerOverBackground"), |
| 172 | panel_border_subtle = H(res, "CascadeTheme.PowerSolutionPanelBorderSubtle"), |
| 173 | task_queue_bubble_background = H(res, "CascadeTheme.PowerSolutionTaskQueueBubbleBackground") |
| 174 | }, |
| 175 | power_island_frame_brushes = new |
| 176 | { |
| 177 | editor = H(res, "CascadeTheme.PowerEditorIslandFrameBrush"), |
| 178 | chat = H(res, "CascadeTheme.PowerChatIslandFrameBrush"), |
| 179 | solution = H(res, "CascadeTheme.PowerSolutionIslandFrameBrush") |
| 180 | } |
| 181 | }; |
| 182 | |
| 183 | var node = JsonSerializer.SerializeToNode(theme, Options) as JsonObject; |
| 184 | if (node is not null) |
| 185 | UiThemeDeepSnapshot.MergeIntoJsonRoot(node, app); |
| 186 | return node?.ToJsonString(Options) ?? JsonSerializer.Serialize(theme, Options); |
| 187 | } |
| 188 | |
| 189 | /// <summary>Строка для JSON: solid как <c>#RGB</c>/<c>#AARRGGBB</c>, <see cref="LinearGradientBrush"/> как <c>linear(...)</c>.</summary> |
| 190 | public static string? FormatBrushForJson(IBrush? brush) => |
| 191 | brush is null ? null : FormatBrushForJson((object)brush); |
| 192 | |
| 193 | /// <summary>Как <see cref="FormatBrushForJson(IBrush?)"/>, для произвольного объекта из ресурсов.</summary> |
| 194 | public static string? FormatBrushForJson(object? raw) => FormatBrushObject(raw); |
| 195 | |
| 196 | /// <summary>Статический шаблон, если приложение ещё не поднято (тесты, сравнение формата).</summary> |
| 197 | private static string SerializeStaticFallback() |
| 198 | { |
| 199 | var theme = new |
| 200 | { |
| 201 | _snapshot = new { source = "static_fallback", detail = "Application.Current is null" }, |
| 202 | main_window = new { background = "#F5F5F5" }, |
| 203 | menu = new { background = "#F0F0F0", foreground = "#1A1A1A" }, |
| 204 | menu_item = new { foreground = "#1A1A1A" }, |
| 205 | button = new |
| 206 | { |
| 207 | background = "#E0E0E0", |
| 208 | foreground = "#1A1A1A", |
| 209 | border_brush = "#BBB", |
| 210 | border_thickness = 1, |
| 211 | hover_background = "#D0D0D0", |
| 212 | disabled_background = "#EEE", |
| 213 | disabled_foreground = "#888" |
| 214 | }, |
| 215 | toolbar = new { background = "#E8E8E8" }, |
| 216 | toolbar_text = new { foreground = "#333", error_foreground = "#C00" }, |
| 217 | editor = new |
| 218 | { |
| 219 | background = "#FFF", |
| 220 | foreground = "#1A1A1A", |
| 221 | selection_brush = "#55225679", |
| 222 | selection_foreground = "#1A1A1A", |
| 223 | font_family = "Consolas, Cascadia Code, monospace", |
| 224 | font_size = 13 |
| 225 | }, |
| 226 | editor_column = new { border_brush = "#CCC", background = "#FFF", current_file_foreground = "#666" }, |
| 227 | workspace_layout = new { border_brush = "#CCC" }, |
| 228 | markdown_preview_panel = new { background = "#FAFAFA", border_brush = "#E0E0E0" }, |
| 229 | solution_explorer = new { border_brush = "#CCC", header_foreground = "#1A1A1A", tree_item_foreground = "#1A1A1A" }, |
| 230 | panel_chrome = new |
| 231 | { |
| 232 | title_foreground = "#1A1A1A", |
| 233 | accent_brush = "#5B4FC9", |
| 234 | header_background = "#E8E8EC", |
| 235 | header_separator = "#C8C8D0", |
| 236 | menu_glyph_foreground = "#666666" |
| 237 | }, |
| 238 | build_output = new { background = "#F8F8F8", foreground = "#CCC", border_brush = "#CCC" }, |
| 239 | chat_panel = new |
| 240 | { |
| 241 | background = "#FFF", |
| 242 | label_foreground = "#333", |
| 243 | secondary_foreground = "#666", |
| 244 | message_bubble_background = "#F0F0F0", |
| 245 | message_role_foreground = "#555", |
| 246 | message_content_foreground = "#1A1A1A", |
| 247 | send_button_background = "#4CAF50", |
| 248 | send_button_foreground = "White", |
| 249 | checkbox_foreground = "#333" |
| 250 | }, |
| 251 | terminal = new |
| 252 | { |
| 253 | background = "#1E1E1E", |
| 254 | foreground = "#CCC", |
| 255 | input_background = "#2D2D2D", |
| 256 | font_family = "Consolas, monospace", |
| 257 | font_size = 12 |
| 258 | }, |
| 259 | mcp_banner = new { background = "#E3F2FD", foreground = "#1565C0" }, |
| 260 | preview_window = new { background = "#F0F0F0", content_area_background = "#F0F0F0" }, |
| 261 | power_cockpit = new |
| 262 | { |
| 263 | neon_border = "#00F0FF", |
| 264 | neon_accent = "#E090FF", |
| 265 | panel_background = "#070E18", |
| 266 | safety_dock_background = "#020814", |
| 267 | workspace_health_strip_background = "#040A14", |
| 268 | safety_observe = "#1A5088", |
| 269 | safety_confirm = "#9A7818", |
| 270 | safety_autonomous = "#6230B0", |
| 271 | emergency = "#FF3355" |
| 272 | }, |
| 273 | solution_explorer_tree_power = (object?)null, |
| 274 | power_island_frame_brushes = (object?)null |
| 275 | }; |
| 276 | var fallbackNode = JsonSerializer.SerializeToNode(theme, Options) as JsonObject; |
| 277 | if (fallbackNode is not null) |
| 278 | { |
| 279 | fallbackNode["cascade_theme_resolved"] = null; |
| 280 | fallbackNode["window_frame"] = null; |
| 281 | fallbackNode["layout_regions"] = null; |
| 282 | fallbackNode["dock_text_editors"] = null; |
| 283 | fallbackNode["dock_open_documents"] = null; |
| 284 | fallbackNode["top_levels"] = null; |
| 285 | } |
| 286 | |
| 287 | return fallbackNode?.ToJsonString(Options) ?? JsonSerializer.Serialize(theme, Options); |
| 288 | } |
| 289 | |
| 290 | private static string? TryBrushString(IResourceDictionary res, string key) |
| 291 | { |
| 292 | if (!res.TryGetValue(key, out var raw) || raw is null) |
| 293 | return null; |
| 294 | return FormatBrushObject(raw); |
| 295 | } |
| 296 | |
| 297 | private static string? FormatBrushObject(object? raw) |
| 298 | { |
| 299 | switch (raw) |
| 300 | { |
| 301 | case null: |
| 302 | return null; |
| 303 | case LinearGradientBrush lg: |
| 304 | return SerializeLinearGradient(lg); |
| 305 | case ISolidColorBrush sc: |
| 306 | return ColorToHex(sc.Color); |
| 307 | default: |
| 308 | return null; |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | private static string ColorToHex(Color c) |
| 313 | { |
| 314 | if (c.A == byte.MaxValue) |
| 315 | return FormattableString.Invariant($"#{c.R:X2}{c.G:X2}{c.B:X2}"); |
| 316 | return FormattableString.Invariant($"#{c.A:X2}{c.R:X2}{c.G:X2}{c.B:X2}"); |
| 317 | } |
| 318 | |
| 319 | private static string SerializeLinearGradient(LinearGradientBrush lg) |
| 320 | { |
| 321 | var sp = lg.StartPoint.Point; |
| 322 | var ep = lg.EndPoint.Point; |
| 323 | var stops = new List<string>(); |
| 324 | foreach (var gs in lg.GradientStops) |
| 325 | stops.Add(string.Create(CultureInfo.InvariantCulture, $"{gs.Offset:R}:{ColorToHex(gs.Color)}")); |
| 326 | return string.Create(CultureInfo.InvariantCulture, |
| 327 | $"linear({sp.X:R},{sp.Y:R}->{ep.X:R},{ep.Y:R};{string.Join(",", stops)})"); |
| 328 | } |
| 329 | |
| 330 | /// <summary>Цвета страницы «Вывод сборки» в MFD (фон журнала и текст в духе <c>TerminalForeground</c> хоста стека).</summary> |
| 331 | public static (string background, string foreground) GetBuildOutputTheme() |
| 332 | { |
| 333 | const string defBg = "#F8F8F8"; |
| 334 | const string defFg = "#CCC"; |
| 335 | |
| 336 | if (Application.Current is null) |
| 337 | return (defBg, defFg); |
| 338 | |
| 339 | if (UiScheduler.Default.CheckAccess()) |
| 340 | return ReadBuildOutput(Application.Current.Resources); |
| 341 | |
| 342 | return UiScheduler.Default.Invoke(() => |
| 343 | Application.Current is { } app |
| 344 | ? ReadBuildOutput(app.Resources) |
| 345 | : (defBg, defFg)); |
| 346 | } |
| 347 | |
| 348 | private static (string background, string foreground) ReadBuildOutput(IResourceDictionary res) |
| 349 | { |
| 350 | const string defBg = "#F8F8F8"; |
| 351 | const string defFg = "#CCC"; |
| 352 | var bg = TrySolidHexOnly(res, UiThemeApply.Keys.BuildOutputBackground) ?? defBg; |
| 353 | var fg = TrySolidHexOnly(res, UiThemeApply.Keys.TerminalForeground) ?? defFg; |
| 354 | return (bg, fg); |
| 355 | } |
| 356 | |
| 357 | private static string? TrySolidHexOnly(IResourceDictionary res, string key) |
| 358 | { |
| 359 | if (!res.TryGetValue(key, out var raw) || raw is null) |
| 360 | return null; |
| 361 | return raw switch |
| 362 | { |
| 363 | ISolidColorBrush s => ColorToHex(s.Color), |
| 364 | _ => null |
| 365 | }; |
| 366 | } |
| 367 | } |
| 368 | |