Forge
csharpdeeb25a2
1using System.Reflection;
2using System.Text.Json;
3using System.Text.Json.Serialization;
4using Avalonia;
5using Avalonia.Controls;
6using Avalonia.Controls.ApplicationLifetimes;
7using Avalonia.Input;
8using Avalonia.Media;
9using Avalonia.Media.TextFormatting;
10using Avalonia.VisualTree;
11
12namespace CascadeIDE.Services;
13
14/// <summary>
15/// Снимок эффективного вида любого контрола: тип, имя, границы, видимость, содержимое (текст),
16/// фон, цвет текста, шрифт. Для ide_get_control_appearance — по курсору или по имени.
17/// Вызывать из UI-потока.
18/// </summary>
19public 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
View only · write via MCP/CIDE