| 1 | using System.Reflection; |
| 2 | using System.Text.Json; |
| 3 | using System.Text.Json.Serialization; |
| 4 | using Avalonia; |
| 5 | using Avalonia.Controls; |
| 6 | using Avalonia.Controls.ApplicationLifetimes; |
| 7 | using Avalonia.Input; |
| 8 | using Avalonia.Media; |
| 9 | using Avalonia.Media.TextFormatting; |
| 10 | using Avalonia.VisualTree; |
| 11 | |
| 12 | namespace CascadeIDE.Services; |
| 13 | |
| 14 | /// <summary> |
| 15 | /// Снимок эффективного вида любого контрола: тип, имя, границы, видимость, содержимое (текст), |
| 16 | /// фон, цвет текста, шрифт. Для ide_get_control_appearance — по курсору или по имени. |
| 17 | /// Вызывать из UI-потока. |
| 18 | /// </summary> |
| 19 | public static class UiControlAppearance |
| 20 | { |
| 21 | private static readonly JsonSerializerOptions Options = new() |
| 22 | { |
| 23 | WriteIndented = true, |
| 24 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull |
| 25 | }; |
| 26 | |
| 27 | /// <param name="topLevel">Окно (TopLevel).</param> |
| 28 | /// <param name="controlName">Если задано — ищем контрол по имени в дереве; иначе — элемент под курсором.</param> |
| 29 | public static string GetJson(TopLevel topLevel, string? controlName) |
| 30 | { |
| 31 | Control? control; |
| 32 | if (!string.IsNullOrWhiteSpace(controlName)) |
| 33 | { |
| 34 | control = topLevel is Window mw |
| 35 | ? FindControlByNameAcrossAllWindows(mw, controlName) |
| 36 | : FindControlByName(topLevel, controlName.Trim()); |
| 37 | if (control is null) |
| 38 | return JsonSerializer.Serialize(new { error = "Контрол с именем не найден.", name = controlName }, Options); |
| 39 | } |
| 40 | else |
| 41 | { |
| 42 | control = UiPointerClientPosition.TryGetControlUnderPointer(topLevel); |
| 43 | if (control is null) |
| 44 | return JsonSerializer.Serialize(new { hint = "Нет контрола под курсором. Укажите name из ide_get_ui_layout." }, Options); |
| 45 | } |
| 46 | |
| 47 | var root = (TopLevel.GetTopLevel(control) as Visual) ?? (topLevel as Visual); |
| 48 | var snapshot = BuildSnapshot(control, root); |
| 49 | return JsonSerializer.Serialize(snapshot, Options); |
| 50 | } |
| 51 | |
| 52 | /// <summary>Найти первый контрол в дереве с заданным именем (для применения layout и т.д.).</summary> |
| 53 | public static Control? FindControlByName(Visual root, string name) |
| 54 | { |
| 55 | if (root is StyledElement se && string.Equals(se.Name, name, StringComparison.Ordinal) && root is Control c) |
| 56 | return c; |
| 57 | foreach (var child in root.GetVisualChildren()) |
| 58 | { |
| 59 | var found = FindControlByName(child, name); |
| 60 | if (found is not null) |
| 61 | return found; |
| 62 | } |
| 63 | return null; |
| 64 | } |
| 65 | |
| 66 | /// <summary> |
| 67 | /// Поиск по имени во всех окнах процесса: сначала в <paramref name="mainWindow"/>, затем в остальных (вспомогательные окна, настройки, превью). |
| 68 | /// Одинаковые имена в разных окнах: побеждает вхождение в главном окне. |
| 69 | /// </summary> |
| 70 | public static Control? FindControlByNameAcrossAllWindows(Window mainWindow, string name) |
| 71 | { |
| 72 | var trimmed = name.Trim(); |
| 73 | var c = FindControlByName(mainWindow, trimmed); |
| 74 | if (c is not null) |
| 75 | return c; |
| 76 | |
| 77 | if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) |
| 78 | { |
| 79 | foreach (var w in desktop.Windows) |
| 80 | { |
| 81 | if (w is not Window win || ReferenceEquals(win, mainWindow)) |
| 82 | continue; |
| 83 | c = FindControlByName(win, trimmed); |
| 84 | if (c is not null) |
| 85 | return c; |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | return null; |
| 90 | } |
| 91 | |
| 92 | private static Dictionary<string, object?> BuildSnapshot(Control control, Visual? root) |
| 93 | { |
| 94 | var name = (control as StyledElement)?.Name ?? ""; |
| 95 | double x = 0, y = 0, w = control.Bounds.Width, h = control.Bounds.Height; |
| 96 | if (root is not null) |
| 97 | { |
| 98 | var topLeft = control.TranslatePoint(new Point(0, 0), root); |
| 99 | if (topLeft is { } p) |
| 100 | { |
| 101 | x = p.X; |
| 102 | y = p.Y; |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | var content = GetContent(control); |
| 107 | var background = BrushToHex(GetBrush(control, "Background")); |
| 108 | var foreground = BrushToHex(GetBrush(control, "Foreground")); |
| 109 | var (effectiveBackground, effectiveForeground) = GetEffectiveColors(control); |
| 110 | var borderBrush = BrushToHex(GetBrush(control, "BorderBrush")); |
| 111 | var fontFamily = GetString(control, "FontFamily"); |
| 112 | var fontSize = GetDouble(control, "FontSize"); |
| 113 | var borderThickness = GetThickness(control, "BorderThickness"); |
| 114 | var contentTruncated = TryDetectContentTruncated(control, content, w, fontSize, fontFamily); |
| 115 | |
| 116 | var dict = new Dictionary<string, object?> |
| 117 | { |
| 118 | ["type"] = control.GetType().Name, |
| 119 | ["name"] = name, |
| 120 | ["visible"] = control.IsVisible, |
| 121 | ["bounds"] = new Dictionary<string, double> { ["x"] = Math.Round(x, 1), ["y"] = Math.Round(y, 1), ["w"] = Math.Round(w, 1), ["h"] = Math.Round(h, 1) }, |
| 122 | ["content"] = content, |
| 123 | ["background"] = background, |
| 124 | ["foreground"] = foreground, |
| 125 | ["effective_background"] = effectiveBackground, |
| 126 | ["effective_foreground"] = effectiveForeground, |
| 127 | ["border_brush"] = borderBrush, |
| 128 | ["border_thickness"] = borderThickness, |
| 129 | ["font_family"] = fontFamily, |
| 130 | ["font_size"] = fontSize |
| 131 | }; |
| 132 | if (contentTruncated.HasValue) |
| 133 | dict["content_truncated"] = contentTruncated.Value; |
| 134 | dict["background_brush"] = UiThemeSnapshot.FormatBrushForJson(GetBrush(control, "Background")); |
| 135 | dict["border_brush_display"] = UiThemeSnapshot.FormatBrushForJson(GetBrush(control, "BorderBrush")); |
| 136 | if (control is Border br) |
| 137 | { |
| 138 | var cr = br.CornerRadius; |
| 139 | dict["corner_radius"] = new Dictionary<string, double> |
| 140 | { |
| 141 | ["top_left"] = cr.TopLeft, |
| 142 | ["top_right"] = cr.TopRight, |
| 143 | ["bottom_right"] = cr.BottomRight, |
| 144 | ["bottom_left"] = cr.BottomLeft |
| 145 | }; |
| 146 | dict["box_shadow"] = br.BoxShadow.ToString(); |
| 147 | } |
| 148 | |
| 149 | return dict; |
| 150 | } |
| 151 | |
| 152 | /// <summary>Снимок контрола по имени в дереве окна (те же поля, что у <c>ide_get_control_appearance</c>).</summary> |
| 153 | public static Dictionary<string, object?>? TryBuildNamedRegionSnapshot(TopLevel topLevel, string name) |
| 154 | { |
| 155 | if (string.IsNullOrWhiteSpace(name)) |
| 156 | return null; |
| 157 | var c = FindControlByName(topLevel, name.Trim()); |
| 158 | if (c is null) |
| 159 | return null; |
| 160 | return BuildSnapshot(c, topLevel as Visual); |
| 161 | } |
| 162 | |
| 163 | /// <summary>Реальное состояние обрезки: для TextBlock — из контрола (TextLayout.TextLines.HasOverflowed); иначе — оценка по измерению.</summary> |
| 164 | private static bool? TryDetectContentTruncated(Control control, string? content, double boundsWidth, double? fontSize, string? fontFamily) |
| 165 | { |
| 166 | if (string.IsNullOrEmpty(content) && control is not TextBlock) |
| 167 | return null; |
| 168 | |
| 169 | if (control is TextBlock tb && tb.TextLayout is { } layout) |
| 170 | { |
| 171 | foreach (var line in layout.TextLines) |
| 172 | { |
| 173 | if (line.HasOverflowed) |
| 174 | return true; |
| 175 | } |
| 176 | return false; |
| 177 | } |
| 178 | |
| 179 | if (string.IsNullOrEmpty(content) || boundsWidth <= 0) |
| 180 | return null; |
| 181 | var size = fontSize ?? 12; |
| 182 | var family = ParseFontFamilyName(fontFamily) ?? "Inter"; |
| 183 | const double reservedForChrome = 48; |
| 184 | var availableWidth = Math.Max(0, boundsWidth - reservedForChrome); |
| 185 | if (availableWidth <= 0) |
| 186 | return null; |
| 187 | try |
| 188 | { |
| 189 | var typeface = new Typeface(family); |
| 190 | using var fallbackLayout = new TextLayout( |
| 191 | content!, |
| 192 | typeface, |
| 193 | fontFeatures: null, |
| 194 | fontSize: size, |
| 195 | foreground: null, |
| 196 | textAlignment: TextAlignment.Left, |
| 197 | textWrapping: TextWrapping.NoWrap, |
| 198 | textTrimming: null, |
| 199 | textDecorations: null, |
| 200 | flowDirection: FlowDirection.LeftToRight, |
| 201 | maxWidth: double.PositiveInfinity, |
| 202 | maxHeight: double.PositiveInfinity); |
| 203 | var textWidth = fallbackLayout.Width; |
| 204 | return textWidth > availableWidth; |
| 205 | } |
| 206 | catch |
| 207 | { |
| 208 | return null; |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | private static string? ParseFontFamilyName(string? composite) |
| 213 | { |
| 214 | if (string.IsNullOrWhiteSpace(composite)) |
| 215 | return null; |
| 216 | if (composite.Contains("Inter", StringComparison.OrdinalIgnoreCase)) |
| 217 | return "Inter"; |
| 218 | if (composite.Contains("Consolas", StringComparison.OrdinalIgnoreCase)) |
| 219 | return "Consolas"; |
| 220 | var hash = composite.IndexOf('#'); |
| 221 | if (hash >= 0) |
| 222 | { |
| 223 | var part = composite[(hash + 1)..].Split(',')[0].Trim(); |
| 224 | if (!string.IsNullOrEmpty(part)) |
| 225 | return part; |
| 226 | } |
| 227 | return composite.Split(',')[0].Trim(); |
| 228 | } |
| 229 | |
| 230 | /// <summary>Эффективные цвета с учётом поддерева и предков (шаблон, выделение): то, что реально рисуется. Для ide_get_colors_under_cursor и снимка контрола.</summary> |
| 231 | public static (string? background, string? foreground) GetEffectiveColors(Control control, int maxDepth = 20, int maxAncestors = 15) |
| 232 | { |
| 233 | string? bg = null; |
| 234 | string? fg = null; |
| 235 | WalkForBrushes(control, 0, ref bg, ref fg, maxDepth); |
| 236 | if (bg is null || fg is null) |
| 237 | WalkAncestorsForBrushes(control.GetVisualParent(), 0, ref bg, ref fg, maxAncestors); |
| 238 | return (bg, fg); |
| 239 | } |
| 240 | |
| 241 | private static void WalkAncestorsForBrushes(Visual? visual, int depth, ref string? foundBg, ref string? foundFg, int maxDepth) |
| 242 | { |
| 243 | if (visual is null || depth > maxDepth) |
| 244 | return; |
| 245 | if (visual is Control c) |
| 246 | { |
| 247 | if (foundBg is null) |
| 248 | { |
| 249 | var brush = GetBrush(c, "Background"); |
| 250 | if (brush is SolidColorBrush) |
| 251 | foundBg = BrushToHex(brush); |
| 252 | } |
| 253 | if (foundFg is null) |
| 254 | { |
| 255 | var brush = GetBrush(c, "Foreground"); |
| 256 | if (brush is SolidColorBrush) |
| 257 | foundFg = BrushToHex(brush); |
| 258 | } |
| 259 | } |
| 260 | WalkAncestorsForBrushes(visual.GetVisualParent(), depth + 1, ref foundBg, ref foundFg, maxDepth); |
| 261 | } |
| 262 | |
| 263 | private static void WalkForBrushes(Visual? visual, int depth, ref string? foundBg, ref string? foundFg, int maxDepth) |
| 264 | { |
| 265 | if (visual is null || depth > maxDepth) |
| 266 | return; |
| 267 | if (visual is Control c) |
| 268 | { |
| 269 | if (foundBg is null) |
| 270 | { |
| 271 | var brush = GetBrush(c, "Background"); |
| 272 | if (brush is SolidColorBrush) |
| 273 | foundBg = BrushToHex(brush); |
| 274 | } |
| 275 | if (foundFg is null) |
| 276 | { |
| 277 | var brush = GetBrush(c, "Foreground"); |
| 278 | if (brush is SolidColorBrush) |
| 279 | foundFg = BrushToHex(brush); |
| 280 | } |
| 281 | if (foundBg is not null && foundFg is not null) |
| 282 | return; |
| 283 | } |
| 284 | foreach (var child in visual.GetVisualChildren()) |
| 285 | { |
| 286 | WalkForBrushes(child, depth + 1, ref foundBg, ref foundFg, maxDepth); |
| 287 | if (foundBg is not null && foundFg is not null) |
| 288 | return; |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | private static string? GetContent(Control? c) |
| 293 | { |
| 294 | if (c is null) return null; |
| 295 | const int maxShort = 300; |
| 296 | const int maxTextBox = 12000; |
| 297 | |
| 298 | if (c is TextBox tbx) |
| 299 | { |
| 300 | var text = tbx.Text ?? ""; |
| 301 | return text.Length <= maxTextBox ? text : text[..maxTextBox] + "\n... (обрезано)"; |
| 302 | } |
| 303 | if (c is TextBlock tb) |
| 304 | return Trunc(tb.Text ?? "", maxShort); |
| 305 | if (c is ContentControl cc && cc.Content is string contentStr) |
| 306 | return Trunc(contentStr, maxShort); |
| 307 | if (c is ContentControl cc2 && cc2.Content is not null) |
| 308 | return Trunc(cc2.Content.ToString() ?? "", maxShort); |
| 309 | if (c is Button btn) |
| 310 | return Trunc(btn.Content?.ToString() ?? "", maxShort); |
| 311 | if (c is MenuItem mi) |
| 312 | return Trunc(mi.Header?.ToString() ?? "", maxShort); |
| 313 | return null; |
| 314 | } |
| 315 | |
| 316 | private static string Trunc(string s, int max) |
| 317 | { |
| 318 | if (string.IsNullOrEmpty(s)) return s; |
| 319 | s = s.Replace("\r", " ").Replace("\n", " "); |
| 320 | return s.Length <= max ? s : s[..max] + "..."; |
| 321 | } |
| 322 | |
| 323 | private static IBrush? GetBrush(Control control, string propertyName) |
| 324 | { |
| 325 | var prop = control.GetType().GetProperty(propertyName); |
| 326 | return prop?.GetValue(control) as IBrush; |
| 327 | } |
| 328 | |
| 329 | private static string? BrushToHex(IBrush? brush) |
| 330 | { |
| 331 | if (brush is not SolidColorBrush scb) |
| 332 | return null; |
| 333 | var c = scb.Color; |
| 334 | return $"#{c.A:X2}{c.R:X2}{c.G:X2}{c.B:X2}"; |
| 335 | } |
| 336 | |
| 337 | private static string? GetString(Control control, string propertyName) |
| 338 | { |
| 339 | var prop = control.GetType().GetProperty(propertyName); |
| 340 | var v = prop?.GetValue(control); |
| 341 | return v?.ToString(); |
| 342 | } |
| 343 | |
| 344 | private static double? GetDouble(Control control, string propertyName) |
| 345 | { |
| 346 | var prop = control.GetType().GetProperty(propertyName); |
| 347 | if (prop?.GetValue(control) is not IConvertible val) |
| 348 | return null; |
| 349 | try { return val.ToDouble(null); } |
| 350 | catch { return null; } |
| 351 | } |
| 352 | |
| 353 | private static object? GetThickness(Control control, string propertyName) |
| 354 | { |
| 355 | var prop = control.GetType().GetProperty(propertyName); |
| 356 | var v = prop?.GetValue(control); |
| 357 | if (v is null) return null; |
| 358 | if (v is Thickness t) |
| 359 | return new { left = t.Left, top = t.Top, right = t.Right, bottom = t.Bottom }; |
| 360 | return v.ToString(); |
| 361 | } |
| 362 | } |
| 363 | |