| 1 | #nullable enable |
| 2 | using SkiaSharp; |
| 3 | |
| 4 | namespace CascadeIDE.Views.SkiaKit; |
| 5 | |
| 6 | /// <summary>Всплывающий список подсказок (slash) над composer. ADR 0123 фаза 2.</summary> |
| 7 | internal static class SkiaPopupList |
| 8 | { |
| 9 | public const float RowHeight = 52f; |
| 10 | public const float MaxVisibleRows = 6f; |
| 11 | public const float HorizontalPadding = 8f; |
| 12 | public const float HierarchyHeaderHeight = 34f; |
| 13 | public const float CornerRadius = 8f; |
| 14 | |
| 15 | public static int ViewportRowCount(int rowCount) => |
| 16 | rowCount <= 0 ? 0 : (int)Math.Min(MaxVisibleRows, rowCount); |
| 17 | |
| 18 | public static int MaxScrollOffset(int rowCount) => |
| 19 | Math.Max(0, rowCount - ViewportRowCount(rowCount)); |
| 20 | |
| 21 | public static int ClampScrollOffset(int scrollOffset, int rowCount) => |
| 22 | Math.Clamp(scrollOffset, 0, MaxScrollOffset(rowCount)); |
| 23 | |
| 24 | /// <summary>Сдвинуть окно так, чтобы <paramref name="selectedIndex"/> был виден (для стрелок).</summary> |
| 25 | public static int EnsureSelectionVisible(int selectedIndex, int scrollOffset, int rowCount) |
| 26 | { |
| 27 | if (rowCount <= 0 || selectedIndex < 0) |
| 28 | return 0; |
| 29 | |
| 30 | scrollOffset = ClampScrollOffset(scrollOffset, rowCount); |
| 31 | var viewport = ViewportRowCount(rowCount); |
| 32 | if (selectedIndex < scrollOffset) |
| 33 | return selectedIndex; |
| 34 | if (selectedIndex >= scrollOffset + viewport) |
| 35 | return ClampScrollOffset(selectedIndex - viewport + 1, rowCount); |
| 36 | return scrollOffset; |
| 37 | } |
| 38 | |
| 39 | public static float MeasureHeight(int rowCount, bool showHierarchyHeader = false) |
| 40 | { |
| 41 | if (rowCount <= 0) |
| 42 | return 0f; |
| 43 | |
| 44 | var header = showHierarchyHeader ? HierarchyHeaderHeight : 0f; |
| 45 | return header + ViewportRowCount(rowCount) * RowHeight + HorizontalPadding * 2; |
| 46 | } |
| 47 | |
| 48 | public static void Draw( |
| 49 | SKCanvas canvas, |
| 50 | SKRect bounds, |
| 51 | ISkiaKitPaintTheme theme, |
| 52 | IReadOnlyList<SkiaPopupListRow> rows, |
| 53 | int selectedIndex, |
| 54 | int scrollOffset, |
| 55 | float layoutScale = 1f, |
| 56 | string? hierarchyPathPrefix = null, |
| 57 | string? hierarchyNextStep = null, |
| 58 | string? hierarchyBreadcrumb = null) |
| 59 | { |
| 60 | if (rows.Count == 0) |
| 61 | return; |
| 62 | |
| 63 | scrollOffset = ClampScrollOffset(scrollOffset, rows.Count); |
| 64 | var showHeader = !string.IsNullOrWhiteSpace(hierarchyPathPrefix) |
| 65 | || !string.IsNullOrWhiteSpace(hierarchyNextStep) |
| 66 | || !string.IsNullOrWhiteSpace(hierarchyBreadcrumb); |
| 67 | |
| 68 | using var fill = new SKPaint { Color = theme.Surface, IsAntialias = true, Style = SKPaintStyle.Fill }; |
| 69 | canvas.DrawRoundRect(bounds, CornerRadius, CornerRadius, fill); |
| 70 | |
| 71 | using var border = new SKPaint |
| 72 | { |
| 73 | Color = theme.Border, |
| 74 | IsAntialias = true, |
| 75 | Style = SKPaintStyle.Stroke, |
| 76 | StrokeWidth = 1f, |
| 77 | }; |
| 78 | canvas.DrawRoundRect(bounds, CornerRadius, CornerRadius, border); |
| 79 | |
| 80 | canvas.Save(); |
| 81 | canvas.ClipRect(bounds); |
| 82 | |
| 83 | var y = bounds.Top + HorizontalPadding; |
| 84 | if (showHeader) |
| 85 | { |
| 86 | DrawHierarchyHeader( |
| 87 | canvas, |
| 88 | new SKRect(bounds.Left + 4f, y, bounds.Right - 4f, y + HierarchyHeaderHeight - 2f), |
| 89 | theme, |
| 90 | hierarchyPathPrefix, |
| 91 | hierarchyNextStep, |
| 92 | hierarchyBreadcrumb, |
| 93 | layoutScale); |
| 94 | y += HierarchyHeaderHeight; |
| 95 | } |
| 96 | |
| 97 | var visible = ViewportRowCount(rows.Count); |
| 98 | for (var slot = 0; slot < visible; slot++) |
| 99 | { |
| 100 | var rowIndex = scrollOffset + slot; |
| 101 | if (rowIndex >= rows.Count) |
| 102 | break; |
| 103 | |
| 104 | var row = rows[rowIndex]; |
| 105 | var rowRect = new SKRect(bounds.Left + 4f, y, bounds.Right - 4f, y + RowHeight - 4f); |
| 106 | if (rowIndex == selectedIndex) |
| 107 | { |
| 108 | using var sel = new SKPaint { Color = theme.HoverBorder.WithAlpha(56), IsAntialias = true }; |
| 109 | canvas.DrawRoundRect(rowRect, 4f, 4f, sel); |
| 110 | } |
| 111 | |
| 112 | if (!string.IsNullOrWhiteSpace(row.Group)) |
| 113 | { |
| 114 | using var groupFont = SkiaKitFonts.CreateUi(10); |
| 115 | using var groupPaint = SkiaKitFonts.CreateTextPaint(theme.EmptyHint); |
| 116 | SkiaKitFonts.DrawText( |
| 117 | canvas, |
| 118 | row.Group, |
| 119 | rowRect.Left + 6f, |
| 120 | rowRect.Top + 12f, |
| 121 | SKTextAlign.Left, |
| 122 | groupFont, |
| 123 | groupPaint, |
| 124 | layoutScale); |
| 125 | } |
| 126 | |
| 127 | using var titleFont = SkiaKitFonts.CreateUi(12, bold: true); |
| 128 | using var titlePaint = SkiaKitFonts.CreateTextPaint(theme.Content); |
| 129 | SkiaKitFonts.DrawText( |
| 130 | canvas, |
| 131 | row.Title, |
| 132 | rowRect.Left + 6f, |
| 133 | rowRect.Top + 28f, |
| 134 | SKTextAlign.Left, |
| 135 | titleFont, |
| 136 | titlePaint, |
| 137 | layoutScale); |
| 138 | |
| 139 | using var subFont = SkiaKitFonts.CreateUi(10); |
| 140 | using var subPaint = SkiaKitFonts.CreateTextPaint(theme.EmptyHint); |
| 141 | var subtitle = Truncate(row.Subtitle, 72); |
| 142 | SkiaKitFonts.DrawText( |
| 143 | canvas, |
| 144 | subtitle, |
| 145 | rowRect.Left + 6f, |
| 146 | rowRect.Top + 44f, |
| 147 | SKTextAlign.Left, |
| 148 | subFont, |
| 149 | subPaint, |
| 150 | layoutScale); |
| 151 | |
| 152 | y += RowHeight; |
| 153 | } |
| 154 | |
| 155 | canvas.Restore(); |
| 156 | } |
| 157 | |
| 158 | public static int HitTestRow( |
| 159 | SKRect bounds, |
| 160 | float x, |
| 161 | float y, |
| 162 | int rowCount, |
| 163 | int scrollOffset, |
| 164 | bool showHierarchyHeader = false) |
| 165 | { |
| 166 | if (rowCount <= 0 || !bounds.Contains(x, y)) |
| 167 | return -1; |
| 168 | |
| 169 | scrollOffset = ClampScrollOffset(scrollOffset, rowCount); |
| 170 | |
| 171 | var headerOffset = showHierarchyHeader ? HierarchyHeaderHeight : 0f; |
| 172 | var localY = y - bounds.Top - HorizontalPadding - headerOffset; |
| 173 | if (localY < 0) |
| 174 | return -1; |
| 175 | |
| 176 | var slot = (int)(localY / RowHeight); |
| 177 | var viewport = ViewportRowCount(rowCount); |
| 178 | if (slot < 0 || slot >= viewport) |
| 179 | return -1; |
| 180 | |
| 181 | var index = scrollOffset + slot; |
| 182 | return index >= 0 && index < rowCount ? index : -1; |
| 183 | } |
| 184 | |
| 185 | private static void DrawHierarchyHeader( |
| 186 | SKCanvas canvas, |
| 187 | SKRect headerRect, |
| 188 | ISkiaKitPaintTheme theme, |
| 189 | string? pathPrefix, |
| 190 | string? nextStep, |
| 191 | string? breadcrumb, |
| 192 | float layoutScale) |
| 193 | { |
| 194 | using var divider = new SKPaint |
| 195 | { |
| 196 | Color = theme.Border.WithAlpha(140), |
| 197 | IsAntialias = true, |
| 198 | StrokeWidth = 1f, |
| 199 | }; |
| 200 | canvas.DrawLine(headerRect.Left, headerRect.Bottom, headerRect.Right, headerRect.Bottom, divider); |
| 201 | |
| 202 | if (!string.IsNullOrWhiteSpace(breadcrumb)) |
| 203 | { |
| 204 | using var crumbFont = SkiaKitFonts.CreateUi(9); |
| 205 | using var crumbPaint = SkiaKitFonts.CreateTextPaint(theme.EmptyHint); |
| 206 | SkiaKitFonts.DrawText( |
| 207 | canvas, |
| 208 | Truncate(breadcrumb, 80), |
| 209 | headerRect.Left + 6f, |
| 210 | headerRect.Top + 10f, |
| 211 | SKTextAlign.Left, |
| 212 | crumbFont, |
| 213 | crumbPaint, |
| 214 | layoutScale); |
| 215 | } |
| 216 | |
| 217 | if (!string.IsNullOrWhiteSpace(pathPrefix)) |
| 218 | { |
| 219 | using var pathFont = SkiaKitFonts.CreateUi(11, bold: true); |
| 220 | using var pathPaint = SkiaKitFonts.CreateTextPaint(theme.Content); |
| 221 | SkiaKitFonts.DrawText( |
| 222 | canvas, |
| 223 | Truncate(pathPrefix, 48), |
| 224 | headerRect.Left + 6f, |
| 225 | headerRect.Top + 22f, |
| 226 | SKTextAlign.Left, |
| 227 | pathFont, |
| 228 | pathPaint, |
| 229 | layoutScale); |
| 230 | } |
| 231 | |
| 232 | if (!string.IsNullOrWhiteSpace(nextStep)) |
| 233 | { |
| 234 | using var stepFont = SkiaKitFonts.CreateUi(10); |
| 235 | using var stepPaint = SkiaKitFonts.CreateTextPaint(theme.EmptyHint); |
| 236 | var stepLabel = $"→ {nextStep}"; |
| 237 | SkiaKitFonts.DrawText( |
| 238 | canvas, |
| 239 | stepLabel, |
| 240 | headerRect.Right - 6f, |
| 241 | headerRect.Top + 22f, |
| 242 | SKTextAlign.Right, |
| 243 | stepFont, |
| 244 | stepPaint, |
| 245 | layoutScale); |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | private static string Truncate(string text, int maxChars) => |
| 250 | text.Length <= maxChars ? text : text[..(maxChars - 1)] + "…"; |
| 251 | } |
| 252 | |