Forge
csharpdeeb25a2
1using System.Globalization;
2using Avalonia;
3using Avalonia.Media;
4using CascadeIDE.Cockpit.Graph.Layout;
5
6namespace CascadeIDE.Views.SkiaKit.Graph;
7
8public static partial class SkiaGraphSceneDrawing
9{
10 private static void DrawNodes(DrawingContext context, GraphLayoutScene scene, SkiaGraphVisualTheme theme)
11 {
12 var useLegend = scene.UseLegendColumn;
13 foreach (var n in scene.Nodes)
14 {
15 var highlighted = scene.HighlightedNodeIds.Contains(n.Id);
16 if (n.Shape == GraphNodeShape.Condition)
17 DrawConditionBranch(context, theme, n, highlighted);
18 else if (n.Shape == GraphNodeShape.Rectangle)
19 DrawRectangleNode(context, theme, n, highlighted);
20 else
21 {
22 context.DrawEllipse(ResolveNodeFill(theme, n), theme.NodeStrokePen, n.Center, n.Radius, n.Radius);
23 if (highlighted)
24 context.DrawEllipse(null, theme.HighlightedNodePen, n.Center, n.Radius + 3, n.Radius + 3);
25 }
26
27 if (n.Shape != GraphNodeShape.Rectangle)
28 {
29 var glyph = BuildNodeGlyph(n, useLegend, scene.ShowNodeLegendGlyphs);
30 if (IsExitNode(n))
31 {
32 var arrowLen = Math.Clamp(n.Radius * 0.95, 4.5, 12);
33 DrawNorthEastExitArrowShaftCentered(context, theme.GlyphBrush, n.Center, arrowLen, 1.15);
34 }
35 else if (!string.IsNullOrEmpty(glyph))
36 {
37 var maxGlyph = Math.Max(SkiaGraphRenderInvariants.MinGlyphFontSize, n.Radius * 0.92);
38 var fontSize = n.LegendIndex is > 99
39 ? SkiaGraphRenderInvariants.MinGlyphFontSize
40 : Math.Clamp(maxGlyph, SkiaGraphRenderInvariants.MinGlyphFontSize, 11);
41 var glyphText = new FormattedText(
42 glyph,
43 CultureInfo.InvariantCulture,
44 FlowDirection.LeftToRight,
45 theme.GlyphTypeface,
46 fontSize,
47 theme.GlyphBrush);
48 var glyphOrigin = new Point(
49 n.Center.X - glyphText.Width / 2,
50 n.Center.Y - glyphText.Height / 2);
51 context.DrawText(glyphText, glyphOrigin);
52 }
53
54 var fullLabel = BuildNodeFullLabel(n, useLegend, scene);
55 if (!string.IsNullOrWhiteSpace(fullLabel))
56 {
57 var sideFont = scene.SideLabelFontSizePx ?? SkiaGraphRenderInvariants.MinSideLabelFontSize;
58 var labelText = new FormattedText(
59 fullLabel,
60 CultureInfo.InvariantCulture,
61 FlowDirection.LeftToRight,
62 theme.SideLabelTypeface,
63 sideFont,
64 theme.SideLabelBrush);
65 var labelOrigin = ResolveSideLabelOrigin(n, labelText, scene);
66 context.DrawText(labelText, labelOrigin);
67 }
68 }
69 }
70 }
71
72 /// <summary>Узел условия в control-flow: заливка и обводка ромба (классический знак ветвления).</summary>
73 private static void DrawConditionBranch(DrawingContext context, SkiaGraphVisualTheme theme, GraphLayoutNode n, bool highlighted)
74 {
75 var geo = new StreamGeometry();
76 var c = n.Center;
77 var r = n.Radius;
78 using (var ig = geo.Open())
79 {
80 ig.BeginFigure(new Point(c.X, c.Y - r), true);
81 ig.LineTo(new Point(c.X + r, c.Y));
82 ig.LineTo(new Point(c.X, c.Y + r));
83 ig.LineTo(new Point(c.X - r, c.Y));
84 ig.EndFigure(true);
85 }
86
87 context.DrawGeometry(ResolveNodeFill(theme, n), theme.NodeStrokePen, geo);
88 if (highlighted)
89 {
90 var hr = r + 3;
91 var hgeo = new StreamGeometry();
92 using (var ig = hgeo.Open())
93 {
94 ig.BeginFigure(new Point(c.X, c.Y - hr), true);
95 ig.LineTo(new Point(c.X + hr, c.Y));
96 ig.LineTo(new Point(c.X, c.Y + hr));
97 ig.LineTo(new Point(c.X - hr, c.Y));
98 ig.EndFigure(true);
99 }
100
101 context.DrawGeometry(null, theme.HighlightedNodePen, hgeo);
102 }
103 }
104
105 private static IBrush ResolveNodeFill(SkiaGraphVisualTheme theme, GraphLayoutNode node)
106 {
107 if (node.IsAnchor)
108 return theme.AnchorFill;
109 if (IsConditionNode(node))
110 return theme.ConditionFill;
111 if (IsExitNode(node))
112 return theme.ExitFill;
113 if (IsHandlerNode(node))
114 return theme.HandlerFill;
115 return theme.CallFill;
116 }
117
118 private static void DrawRectangleNode(DrawingContext context, SkiaGraphVisualTheme theme, GraphLayoutNode n, bool highlighted)
119 {
120 const double padX = 10;
121 const double padY = 6;
122 var baseSize = ResolveRectangleNodeSize(n.Radius);
123 var fontSize = Math.Clamp(baseSize.Height * 0.52, 9, 14);
124 // Prefer "card grows to fit text" (instead of forcing ellipsis early). Keep a sane upper bound to avoid absurd cards.
125 var maxCardW = 560.0;
126 var maxCardH = 150.0;
127 var maxTextW = Math.Max(36, maxCardW - padX * 2);
128
129 var raw = (n.Label ?? "").Trim();
130 if (raw.Length == 0)
131 raw = Path.GetFileName(n.FullPath);
132
133 // Try single-line first, then fallback to 2-line split.
134 var single = new FormattedText(
135 raw,
136 CultureInfo.InvariantCulture,
137 FlowDirection.LeftToRight,
138 theme.SideLabelTypeface,
139 fontSize,
140 theme.SideLabelBrush);
141
142 string l1;
143 string? l2;
144 if (single.Width <= maxTextW)
145 {
146 l1 = raw;
147 l2 = null;
148 }
149 else
150 {
151 (l1, l2) = SplitLabelIntoTwoLines(raw);
152 }
153
154 var t1 = new FormattedText(
155 l1,
156 CultureInfo.InvariantCulture,
157 FlowDirection.LeftToRight,
158 theme.SideLabelTypeface,
159 fontSize,
160 theme.SideLabelBrush);
161 FormattedText? t2 = null;
162 if (!string.IsNullOrEmpty(l2))
163 {
164 t2 = new FormattedText(
165 l2,
166 CultureInfo.InvariantCulture,
167 FlowDirection.LeftToRight,
168 theme.SideLabelTypeface,
169 fontSize,
170 theme.SideLabelBrush);
171 }
172
173 // If still too wide, ellipsize the widest line using a conservative char budget.
174 if (t1.Width > maxTextW || (t2 is not null && t2.Width > maxTextW))
175 {
176 var approxCharW = Math.Max(5.5, t1.Width / Math.Max(1, l1.Length));
177 var budget = Math.Max(10, (int)Math.Floor(maxTextW / approxCharW));
178 l1 = Ellipsize(l1, budget);
179 if (!string.IsNullOrEmpty(l2))
180 l2 = Ellipsize(l2, budget);
181 t1 = new FormattedText(
182 l1,
183 CultureInfo.InvariantCulture,
184 FlowDirection.LeftToRight,
185 theme.SideLabelTypeface,
186 fontSize,
187 theme.SideLabelBrush);
188 t2 = string.IsNullOrEmpty(l2)
189 ? null
190 : new FormattedText(
191 l2,
192 CultureInfo.InvariantCulture,
193 FlowDirection.LeftToRight,
194 theme.SideLabelTypeface,
195 fontSize,
196 theme.SideLabelBrush);
197 }
198
199 // Ensure the card really contains the label (with padding), otherwise it looks like “text without a box”.
200 var textW = Math.Max(t1.Width, t2?.Width ?? 0);
201 var textH = t1.Height + (t2 is null ? 0 : t2.Height + 2);
202 var w = Math.Clamp(Math.Max(baseSize.Width, textW + padX * 2), 42, maxCardW);
203 var h = Math.Clamp(Math.Max(baseSize.Height, textH + padY * 2), 18, maxCardH);
204 var rect = new Rect(n.Center.X - w / 2, n.Center.Y - h / 2, w, h);
205 const double corner = 6;
206 context.DrawRectangle(ResolveNodeFill(theme, n), theme.NodeStrokePen, rect, corner, corner);
207 if (highlighted)
208 {
209 var hRect = rect.Inflate(3);
210 context.DrawRectangle(null, theme.HighlightedNodePen, hRect, corner, corner);
211 }
212
213 // Label inside card (up to 2 lines).
214 if (t2 is null)
215 {
216 context.DrawText(t1, new Point(n.Center.X - t1.Width / 2, n.Center.Y - t1.Height / 2));
217 return;
218 }
219
220 var totalH = t1.Height + 2 + t2.Height;
221 var top = n.Center.Y - totalH / 2;
222 context.DrawText(t1, new Point(n.Center.X - t1.Width / 2, top));
223 context.DrawText(t2, new Point(n.Center.X - t2.Width / 2, top + t1.Height + 2));
224 }
225
226 private static (double Width, double Height) ResolveRectangleNodeSize(double radius)
227 {
228 // Keep size derived only from radius so hit-testing can match without text measurement.
229 var w = Math.Clamp(radius * 4.6, 56, 200);
230 var h = Math.Clamp(radius * 2.6, 24, 60);
231 return (w, h);
232 }
233
234 private static (string Line1, string? Line2) SplitLabelIntoTwoLines(string raw)
235 {
236 // Tokenize by separators and PascalCase boundaries.
237 var tokens = SplitLabelTokens(raw);
238 if (tokens.Count == 0)
239 return (Ellipsize(raw, 24), null);
240
241 // Prefer: 2 lines with roughly balanced length.
242 var target = Math.Max(8, tokens.Sum(t => t.Length) / 2);
243 var line1 = new List<string>();
244 var len1 = 0;
245 foreach (var t in tokens)
246 {
247 var add = (line1.Count == 0 ? 0 : 1) + t.Length;
248 if (line1.Count > 0 && len1 + add > target)
249 break;
250 line1.Add(t);
251 len1 += add;
252 }
253
254 if (line1.Count == 0)
255 line1.Add(tokens[0]);
256
257 var rest = tokens.Skip(line1.Count).ToList();
258 var l1 = string.Join(' ', line1);
259 if (rest.Count == 0)
260 return (l1, null);
261
262 var l2 = string.Join(' ', rest);
263 return (l1, l2);
264 }
265
266 private static List<string> SplitLabelTokens(string raw)
267 {
268 var cleaned = raw.Replace('_', ' ').Replace('.', ' ').Replace('-', ' ').Trim();
269 if (cleaned.Length == 0)
270 return [];
271
272 var list = new List<string>();
273 foreach (var part in cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
274 {
275 // Split PascalCase: ArchitectureForbiddenApiSyntax -> Architecture Forbidden Api Syntax
276 var start = 0;
277 for (var i = 1; i < part.Length; i++)
278 {
279 if (char.IsUpper(part[i]) && char.IsLower(part[i - 1]))
280 {
281 if (i - start >= 2)
282 list.Add(part[start..i]);
283 start = i;
284 }
285 }
286
287 if (start < part.Length)
288 list.Add(part[start..]);
289 }
290
291 return list;
292 }
293
294 private static string Ellipsize(string s, int maxChars)
295 {
296 var t = (s ?? "").Trim();
297 if (t.Length <= maxChars)
298 return t;
299 if (maxChars <= 1)
300 return "…";
301 return t[..(maxChars - 1)] + "…";
302 }
303
304 private static string BuildNodeGlyph(GraphLayoutNode node, bool useLegendColumn, bool showNodeLegendGlyphs)
305 {
306 if (node.IsAnchor)
307 return "A";
308 if (string.Equals(node.Kind, "protected_step", StringComparison.OrdinalIgnoreCase))
309 return "T";
310 if (IsExitNode(node))
311 return "";
312 if (node.LegendIndex is { } idx && (useLegendColumn || showNodeLegendGlyphs))
313 return idx.ToString(CultureInfo.InvariantCulture);
314 if (IsConditionNode(node))
315 return "?";
316 if (IsHandlerNode(node))
317 return "!";
318 return "•";
319 }
320
321 /// <summary>Подпись спутника: иерархия (related-files); CFG вертикаль — справа, CFG горизонт — под узлом (не в поток).</summary>
322 private static Point ResolveSideLabelOrigin(
323 GraphLayoutNode node,
324 FormattedText labelText,
325 GraphLayoutScene scene)
326 {
327 const double gap = 6;
328
329 if (scene.Presentation == GraphLayoutPresentation.CodeControlFlow
330 && scene.ControlFlowMainAxis == GraphControlFlowMainAxis.Horizontal
331 && !node.IsAnchor)
332 {
333 return new Point(
334 node.Center.X - labelText.Width / 2,
335 node.Center.Y + node.Radius + gap);
336 }
337
338 var anchorCenter =
339 scene.Presentation == GraphLayoutPresentation.WorkspaceRelatedFiles
340 ? scene.Nodes.FirstOrDefault(static n => n.IsAnchor)?.Center
341 : null;
342 var layout = CascadeIDE.Models.CodeNavigationMapRelatedGraphLayoutKind.Normalize(scene.RelatedFilesLayout);
343 if (layout == CascadeIDE.Models.CodeNavigationMapRelatedGraphLayoutKind.TopDown && !node.IsAnchor)
344 {
345 return new Point(
346 node.Center.X - labelText.Width / 2,
347 node.Center.Y + node.Radius + gap);
348 }
349
350 if (layout == CascadeIDE.Models.CodeNavigationMapRelatedGraphLayoutKind.BottomUp && !node.IsAnchor)
351 {
352 return new Point(
353 node.Center.X - labelText.Width / 2,
354 node.Center.Y - node.Radius - gap - labelText.Height);
355 }
356
357 if (anchorCenter is { } anchor && !node.IsAnchor
358 && layout == CascadeIDE.Models.CodeNavigationMapRelatedGraphLayoutKind.Radial)
359 {
360 var dx = node.Center.X - anchor.X;
361 var dy = node.Center.Y - anchor.Y;
362 var dist = Math.Sqrt(dx * dx + dy * dy);
363 if (dist > 4)
364 {
365 var ux = dx / dist;
366 var uy = dy / dist;
367 var edge = new Point(
368 node.Center.X + ux * (node.Radius + gap),
369 node.Center.Y + uy * (node.Radius + gap));
370 return ux >= 0
371 ? new Point(edge.X, edge.Y - labelText.Height / 2)
372 : new Point(edge.X - labelText.Width, edge.Y - labelText.Height / 2);
373 }
374 }
375
376 return new Point(
377 node.Center.X + node.Radius + gap,
378 node.Center.Y - labelText.Height / 2);
379 }
380
381 private static string? BuildNodeFullLabel(GraphLayoutNode node, bool useLegendColumn, GraphLayoutScene scene)
382 {
383 if (useLegendColumn)
384 return null;
385 if (node.IsAnchor || IsConditionNode(node) || IsExitNode(node))
386 return null;
387 if (scene.Presentation == GraphLayoutPresentation.WorkspaceRelatedFiles
388 && !CascadeIDE.Models.CodeNavigationMapRelatedGraphLayoutKind.IsHierarchy(scene.RelatedFilesLayout))
389 {
390 var satellites = scene.Nodes.Count(n => !n.IsAnchor);
391 if (satellites > 8)
392 return null;
393 }
394
395 if (scene.ShowNodeLegendGlyphs && node.LegendIndex is > 0)
396 return null;
397
398 var label = node.Label?.Trim();
399 if (string.IsNullOrWhiteSpace(label))
400 return null;
401 return label;
402 }
403
404 private static bool IsExitNode(GraphLayoutNode node) =>
405 string.Equals(node.Kind, ExitStepKind, StringComparison.OrdinalIgnoreCase);
406
407 private static bool IsConditionNode(GraphLayoutNode node) =>
408 string.Equals(node.Kind, ConditionStepKind, StringComparison.OrdinalIgnoreCase);
409
410 private static bool IsHandlerNode(GraphLayoutNode node) =>
411 string.Equals(node.Kind, "handler_step", StringComparison.OrdinalIgnoreCase);
412}
413
View only · write via MCP/CIDE