Forge
csharpdeeb25a2
1#nullable enable
2using SkiaSharp;
3
4namespace CascadeIDE.Views.SkiaKit;
5
6/// <summary>Всплывающий список подсказок (slash) над composer. ADR 0123 фаза 2.</summary>
7internal 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
View only · write via MCP/CIDE