| 1 | using System.Collections.Generic; |
| 2 | using System.Globalization; |
| 3 | using Avalonia; |
| 4 | using Avalonia.Media; |
| 5 | using CascadeIDE.Cockpit.Graph.Layout; |
| 6 | |
| 7 | namespace CascadeIDE.Views.SkiaKit.Graph; |
| 8 | |
| 9 | public static partial class SkiaGraphSceneDrawing |
| 10 | { |
| 11 | private static void DrawLegend(DrawingContext context, GraphLayoutScene scene, SkiaGraphVisualTheme theme, double w, double h) |
| 12 | { |
| 13 | if (!scene.UseLegendColumn || h < 40) |
| 14 | return; |
| 15 | |
| 16 | var isBelow = scene.LegendPlacement == GraphLegendBlockPlacement.BelowGraph; |
| 17 | if (isBelow) |
| 18 | { |
| 19 | if (scene.LegendBlockTopY <= 0 || scene.LegendBlockTopY >= h - 12) |
| 20 | return; |
| 21 | } |
| 22 | else if (scene.LegendColumnLeft >= w - 24) |
| 23 | { |
| 24 | return; |
| 25 | } |
| 26 | |
| 27 | var x0 = scene.LegendColumnLeft; |
| 28 | var y = isBelow ? scene.LegendBlockTopY : 8d; |
| 29 | var legendViewportH = h - y - 4; |
| 30 | if (legendViewportH < 12) |
| 31 | return; |
| 32 | |
| 33 | var captionSize = scene.SideLabelFontSizePx is { } s |
| 34 | ? Math.Clamp(s, SkiaGraphRenderInvariants.MinLegendCaptionFontSize, SkiaGraphRenderInvariants.MaxSideLabelFontSize) |
| 35 | : 12; |
| 36 | |
| 37 | var idxColW = MeasureIndexColumnWidth(theme, scene.Legend, captionSize); |
| 38 | const double colGap = 6d; |
| 39 | var textX = x0 + idxColW + colGap; |
| 40 | var textMaxW = Math.Max(24, w - textX - 4); |
| 41 | |
| 42 | captionSize = FitLegendCaptionSize(theme, scene.Legend, textMaxW, captionSize, legendViewportH); |
| 43 | |
| 44 | var lineH = Math.Max(15, captionSize * 1.2); |
| 45 | var keyRowH = Math.Max(17d, captionSize + 5); |
| 46 | const double gapBeforeKeys = 6d; |
| 47 | |
| 48 | foreach (var row in scene.Legend) |
| 49 | { |
| 50 | if (y + lineH > h - 4) |
| 51 | return; |
| 52 | var idxTxt = row.Index.ToString(CultureInfo.InvariantCulture); |
| 53 | var idxFt = new FormattedText( |
| 54 | idxTxt, |
| 55 | CultureInfo.InvariantCulture, |
| 56 | FlowDirection.LeftToRight, |
| 57 | theme.SideLabelTypeface, |
| 58 | captionSize, |
| 59 | theme.SideLabelBrush); |
| 60 | context.DrawText(idxFt, new Point(x0 + idxColW - idxFt.Width, y)); |
| 61 | |
| 62 | var body = row.Text.Replace('\r', ' ').Replace('\n', ' '); |
| 63 | while (body.Contains(" ", StringComparison.Ordinal)) |
| 64 | body = body.Replace(" ", " ", StringComparison.Ordinal); |
| 65 | body = body.Trim(); |
| 66 | if (body.Length > 400) |
| 67 | body = body[..397] + "…"; |
| 68 | |
| 69 | var bodyFt = new FormattedText( |
| 70 | body, |
| 71 | CultureInfo.InvariantCulture, |
| 72 | FlowDirection.LeftToRight, |
| 73 | theme.SideLabelTypeface, |
| 74 | captionSize, |
| 75 | theme.SideLabelBrush); |
| 76 | context.DrawText(bodyFt, new Point(textX, y)); |
| 77 | y += lineH; |
| 78 | } |
| 79 | |
| 80 | var hasShapeKeys = scene.ShowLegendReturnKey || scene.ShowLegendConditionKey || scene.ShowLegendExceptionFlowKey; |
| 81 | var hasAnyKeys = hasShapeKeys || scene.ShowLegendEdgeStyleKey; |
| 82 | if (!hasAnyKeys) |
| 83 | return; |
| 84 | |
| 85 | if (scene.Legend.Count > 0 && (scene.ShowLegendEdgeStyleKey || hasShapeKeys)) |
| 86 | y += gapBeforeKeys; |
| 87 | |
| 88 | if (scene.ShowLegendEdgeStyleKey) |
| 89 | { |
| 90 | y = DrawLegendEdgeStyleKeyBlock( |
| 91 | context, |
| 92 | theme, |
| 93 | x0, |
| 94 | y, |
| 95 | keyRowH, |
| 96 | captionSize, |
| 97 | h, |
| 98 | scene.Edges); |
| 99 | } |
| 100 | |
| 101 | if (hasShapeKeys && scene.ShowLegendEdgeStyleKey) |
| 102 | y += gapBeforeKeys; |
| 103 | |
| 104 | if (scene.ShowLegendReturnKey) |
| 105 | { |
| 106 | if (y + keyRowH > h - 4) |
| 107 | return; |
| 108 | DrawLegendReturnKeyRow(context, theme, x0, y, keyRowH, captionSize); |
| 109 | y += keyRowH + 2; |
| 110 | } |
| 111 | |
| 112 | if (scene.ShowLegendConditionKey) |
| 113 | { |
| 114 | if (y + keyRowH > h - 4) |
| 115 | return; |
| 116 | DrawLegendConditionKeyRow(context, theme, x0, y, keyRowH, captionSize); |
| 117 | y += keyRowH + 2; |
| 118 | } |
| 119 | |
| 120 | if (scene.ShowLegendExceptionFlowKey) |
| 121 | { |
| 122 | if (y + keyRowH > h - 4) |
| 123 | return; |
| 124 | DrawLegendExceptionFlowKeyRow(context, theme, x0, y, keyRowH, captionSize); |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | private static double MeasureIndexColumnWidth(SkiaGraphVisualTheme theme, IReadOnlyList<GraphLegendEntry> rows, double captionSize) |
| 129 | { |
| 130 | var idxColW = 0d; |
| 131 | foreach (var row in rows) |
| 132 | { |
| 133 | var idxTxt = row.Index.ToString(CultureInfo.InvariantCulture); |
| 134 | var idxFt = new FormattedText( |
| 135 | idxTxt, |
| 136 | CultureInfo.InvariantCulture, |
| 137 | FlowDirection.LeftToRight, |
| 138 | theme.SideLabelTypeface, |
| 139 | captionSize, |
| 140 | theme.SideLabelBrush); |
| 141 | idxColW = Math.Max(idxColW, idxFt.Width); |
| 142 | } |
| 143 | |
| 144 | return idxColW + 4; |
| 145 | } |
| 146 | |
| 147 | private static double FitLegendCaptionSize( |
| 148 | SkiaGraphVisualTheme theme, |
| 149 | IReadOnlyList<GraphLegendEntry> rows, |
| 150 | double textMaxW, |
| 151 | double captionSize, |
| 152 | double viewportH) |
| 153 | { |
| 154 | var size = captionSize; |
| 155 | const double floor = 9; |
| 156 | while (size >= floor) |
| 157 | { |
| 158 | var lineH = Math.Max(15, size * 1.2); |
| 159 | var used = 8d + rows.Count * lineH; |
| 160 | if (used > viewportH - 8 && rows.Count > 0) |
| 161 | { |
| 162 | size -= 0.5; |
| 163 | continue; |
| 164 | } |
| 165 | |
| 166 | var fits = true; |
| 167 | foreach (var row in rows) |
| 168 | { |
| 169 | var t = row.Text.Replace('\r', ' ').Replace('\n', ' '); |
| 170 | while (t.Contains(" ", StringComparison.Ordinal)) |
| 171 | t = t.Replace(" ", " ", StringComparison.Ordinal); |
| 172 | t = t.Trim(); |
| 173 | if (t.Length > 400) |
| 174 | t = t[..397] + "…"; |
| 175 | var w = new FormattedText( |
| 176 | t, |
| 177 | CultureInfo.InvariantCulture, |
| 178 | FlowDirection.LeftToRight, |
| 179 | theme.SideLabelTypeface, |
| 180 | size, |
| 181 | theme.SideLabelBrush).Width; |
| 182 | if (w > textMaxW) |
| 183 | { |
| 184 | fits = false; |
| 185 | break; |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | if (fits) |
| 190 | return size; |
| 191 | size -= 0.5; |
| 192 | } |
| 193 | |
| 194 | return floor; |
| 195 | } |
| 196 | |
| 197 | private static void DrawLegendReturnKeyRow(DrawingContext context, SkiaGraphVisualTheme theme, double x0, double y, double rowH, double captionSize) |
| 198 | { |
| 199 | const double iconR = 5.5; |
| 200 | var cy = y + rowH / 2; |
| 201 | var cx = x0 + iconR + 1; |
| 202 | context.DrawEllipse(theme.ExitFill, theme.NodeStrokePen, new Point(cx, cy), iconR, iconR); |
| 203 | var arrowLen = Math.Max(4.5, Math.Min(iconR * 1.35, captionSize * 0.55)); |
| 204 | DrawNorthEastExitArrowShaftCentered(context, theme.GlyphBrush, new Point(cx, cy), arrowLen, 1.2); |
| 205 | |
| 206 | var cap = new FormattedText( |
| 207 | "return", |
| 208 | CultureInfo.InvariantCulture, |
| 209 | FlowDirection.LeftToRight, |
| 210 | theme.SideLabelTypeface, |
| 211 | captionSize, |
| 212 | theme.SideLabelBrush); |
| 213 | context.DrawText(cap, new Point(x0 + iconR * 2 + 10, y + (rowH - cap.Height) / 2)); |
| 214 | } |
| 215 | |
| 216 | private static void DrawLegendConditionKeyRow(DrawingContext context, SkiaGraphVisualTheme theme, double x0, double y, double rowH, double captionSize) |
| 217 | { |
| 218 | const double r = 5.5; |
| 219 | var cy = y + rowH / 2; |
| 220 | var cx = x0 + r + 1; |
| 221 | var geo = new StreamGeometry(); |
| 222 | using (var ig = geo.Open()) |
| 223 | { |
| 224 | ig.BeginFigure(new Point(cx, cy - r), true); |
| 225 | ig.LineTo(new Point(cx + r, cy)); |
| 226 | ig.LineTo(new Point(cx, cy + r)); |
| 227 | ig.LineTo(new Point(cx - r, cy)); |
| 228 | ig.EndFigure(true); |
| 229 | } |
| 230 | |
| 231 | context.DrawGeometry(theme.ConditionFill, theme.NodeStrokePen, geo); |
| 232 | |
| 233 | var cap = new FormattedText( |
| 234 | "условие", |
| 235 | CultureInfo.InvariantCulture, |
| 236 | FlowDirection.LeftToRight, |
| 237 | theme.SideLabelTypeface, |
| 238 | captionSize, |
| 239 | theme.SideLabelBrush); |
| 240 | context.DrawText(cap, new Point(x0 + r * 2 + 10, y + (rowH - cap.Height) / 2)); |
| 241 | } |
| 242 | |
| 243 | private static void DrawLegendExceptionFlowKeyRow(DrawingContext context, SkiaGraphVisualTheme theme, double x0, double y, double rowH, double captionSize) |
| 244 | { |
| 245 | const double iconR = 5.5; |
| 246 | var cy = y + rowH / 2; |
| 247 | var cx = x0 + iconR + 1; |
| 248 | context.DrawEllipse(theme.HandlerFill, theme.NodeStrokePen, new Point(cx, cy), iconR, iconR); |
| 249 | var ex = new FormattedText( |
| 250 | "!", |
| 251 | CultureInfo.InvariantCulture, |
| 252 | FlowDirection.LeftToRight, |
| 253 | theme.GlyphTypeface, |
| 254 | Math.Max(7, captionSize - 1), |
| 255 | theme.GlyphBrush); |
| 256 | context.DrawText(ex, new Point(cx - ex.Width / 2, cy - ex.Height / 2)); |
| 257 | |
| 258 | var cap = new FormattedText( |
| 259 | "catch / handler", |
| 260 | CultureInfo.InvariantCulture, |
| 261 | FlowDirection.LeftToRight, |
| 262 | theme.SideLabelTypeface, |
| 263 | captionSize, |
| 264 | theme.SideLabelBrush); |
| 265 | context.DrawText(cap, new Point(x0 + iconR * 2 + 10, y + (rowH - cap.Height) / 2)); |
| 266 | } |
| 267 | |
| 268 | private static double DrawLegendEdgeStyleKeyBlock( |
| 269 | DrawingContext context, |
| 270 | SkiaGraphVisualTheme theme, |
| 271 | double x0, |
| 272 | double y, |
| 273 | double keyRowH, |
| 274 | double captionSize, |
| 275 | double viewportBottom, |
| 276 | IReadOnlyList<GraphLayoutEdge> edges) |
| 277 | { |
| 278 | static bool KindContains(string? kind, string needle) => |
| 279 | !string.IsNullOrEmpty(kind) && kind.Contains(needle, StringComparison.OrdinalIgnoreCase); |
| 280 | |
| 281 | var hasNonSolid = edges.Any(e => |
| 282 | KindContains(e.Kind, "conditional") |
| 283 | || KindContains(e.Kind, "exception") |
| 284 | || KindContains(e.Kind, "multibranch") |
| 285 | || KindContains(e.Kind, "loop")); |
| 286 | if (!hasNonSolid) |
| 287 | return y; |
| 288 | |
| 289 | const double lineLen = 34d; |
| 290 | var lineLeft = x0 + 2; |
| 291 | var textX = lineLeft + lineLen + 10; |
| 292 | |
| 293 | void OneRow(Pen pen, string text, ref double yRow) |
| 294 | { |
| 295 | if (yRow + keyRowH > viewportBottom - 4) |
| 296 | return; |
| 297 | var cy = yRow + keyRowH / 2; |
| 298 | context.DrawLine( |
| 299 | pen, |
| 300 | new Point(lineLeft, cy), |
| 301 | new Point(lineLeft + lineLen, cy)); |
| 302 | var cap = new FormattedText( |
| 303 | text, |
| 304 | CultureInfo.InvariantCulture, |
| 305 | FlowDirection.LeftToRight, |
| 306 | theme.SideLabelTypeface, |
| 307 | captionSize, |
| 308 | theme.SideLabelBrush); |
| 309 | context.DrawText(cap, new Point(textX, yRow + (keyRowH - cap.Height) / 2)); |
| 310 | yRow += keyRowH + 2; |
| 311 | } |
| 312 | |
| 313 | // Совпадает с <see cref="SkiaGraphSceneDrawing.Edges"/>: solid — основной поток. |
| 314 | OneRow(theme.BaseEdgePen, "основной поток (вызовы, return, последовательно)", ref y); |
| 315 | if (edges.Any(e => KindContains(e.Kind, "conditional") || KindContains(e.Kind, "exception"))) |
| 316 | OneRow(theme.ConditionalEdgePen, "длинные штрихи — ветвления, catch, исключения", ref y); |
| 317 | if (edges.Any(e => KindContains(e.Kind, "multibranch"))) |
| 318 | OneRow(theme.MultiBranchEdgePen, "короткие штрихи — switch", ref y); |
| 319 | if (edges.Any(e => KindContains(e.Kind, "loop"))) |
| 320 | OneRow(theme.LoopEdgePen, "толстая линия / кольцо — тело цикла", ref y); |
| 321 | return y; |
| 322 | } |
| 323 | } |
| 324 | |