Forge
csharpdeeb25a2
1#nullable enable
2
3using CascadeIDE.Views.SkiaKit;
4using SkiaSharp;
5using Topten.RichTextKit;
6
7// SkiaPlainTextLayout — единый measure/caret/hit-test для plain-текста.
8
9namespace CascadeIDE.Views.Chat.Skia;
10
11/// <summary>RichTextKit layout/paint for inline and block markdown subsets.</summary>
12internal sealed class SkiaRichTextKitBodyLayout
13{
14 public required string Body { get; init; }
15 public float MaxWidth { get; init; }
16 public float FontSize { get; init; }
17 public int MaxBodyLines { get; init; }
18 public float LineHeight { get; init; }
19 public float BodyHeight { get; init; }
20 public bool IsDocument { get; init; }
21 public bool ForwardHost { get; init; }
22 public string FontFamily { get; init; } = "Segoe UI";
23
24 public string MonoFamily { get; init; } = "Cascadia Mono,Consolas";
25}
26
27internal static class SkiaRichTextKitMarkdown
28{
29 public static SkiaRichTextKitBodyLayout? TryMeasure(
30 string body,
31 float maxWidth,
32 float fontSize,
33 SKColor contentColor,
34 SKColor codeColor,
35 int maxBodyLines,
36 float lineHeight,
37 string fontFamily = "Segoe UI",
38 string? monoFamily = null)
39 {
40 var mono = ResolveMonoFamily(monoFamily);
41 var rs = TryBuildRichString(body, maxWidth, fontSize, contentColor, codeColor, maxBodyLines, lineHeight, fontFamily, mono);
42 if (rs is null)
43 return null;
44
45 var height = rs.MeasuredHeight;
46 if (height <= 0f)
47 height = lineHeight;
48
49 return new SkiaRichTextKitBodyLayout
50 {
51 Body = body,
52 MaxWidth = maxWidth,
53 FontSize = fontSize,
54 MaxBodyLines = maxBodyLines,
55 LineHeight = lineHeight,
56 BodyHeight = height,
57 FontFamily = fontFamily,
58 MonoFamily = mono,
59 };
60 }
61
62 public static SkiaRichTextKitBodyLayout? TryMeasureDocument(
63 string body,
64 float maxWidth,
65 float baseFontSize,
66 SKColor contentColor,
67 SKColor codeColor,
68 int maxRows,
69 float lineHeight,
70 bool forwardHost,
71 string fontFamily = "Segoe UI",
72 string? monoFamily = null)
73 {
74 if (maxWidth < 8f || string.IsNullOrWhiteSpace(body))
75 return null;
76
77 var mono = ResolveMonoFamily(monoFamily);
78 var maxChars = Math.Max(8, (int)(maxWidth / 6.5f));
79 var rows = SkiaMarkdownDocument.Layout(body, maxChars);
80 var rs = BuildRichStringFromDocument(rows, maxWidth, baseFontSize, contentColor, codeColor, forwardHost, fontFamily, mono);
81 if (rs is null)
82 return null;
83
84 if (maxRows > 0 && maxRows < int.MaxValue)
85 rs.MaxHeight = maxRows * lineHeight;
86
87 var height = rs.MeasuredHeight;
88 if (height <= 0f)
89 height = lineHeight;
90
91 return new SkiaRichTextKitBodyLayout
92 {
93 Body = body,
94 MaxWidth = maxWidth,
95 FontSize = baseFontSize,
96 MaxBodyLines = maxRows,
97 LineHeight = lineHeight,
98 BodyHeight = height,
99 IsDocument = true,
100 ForwardHost = forwardHost,
101 FontFamily = fontFamily,
102 MonoFamily = mono,
103 };
104 }
105
106 public static SkiaRichTextKitBodyLayout? TryMeasurePlain(
107 string text,
108 float maxWidth,
109 float fontSize,
110 SKColor color,
111 int maxLines,
112 float lineHeight,
113 string fontFamily = "Segoe UI") =>
114 SkiaPlainTextLayout.TryMeasure(text, maxWidth, fontSize, color, maxLines, lineHeight, fontFamily);
115
116 /// <summary>Координаты каретки в той же вёрстке, что <see cref="TryMeasurePlain"/> / Paint plain.</summary>
117 public static bool TryGetPlainCaretLine(
118 string text,
119 int caretIndex,
120 float maxWidth,
121 float fontSize,
122 float lineHeight,
123 SKPoint origin,
124 out float x,
125 out float yTop,
126 out float yBottom,
127 string fontFamily = "Segoe UI") =>
128 SkiaPlainTextLayout.TryGetCaretLine(
129 text,
130 caretIndex,
131 maxWidth,
132 fontSize,
133 lineHeight,
134 origin,
135 out x,
136 out yTop,
137 out yBottom,
138 int.MaxValue,
139 fontFamily);
140
141 public static void Paint(SKCanvas canvas, SKPoint origin, SkiaRichTextKitBodyLayout layout, SKColor contentColor, SKColor codeColor)
142 {
143 RichString? rs;
144 if (layout.IsDocument)
145 {
146 var maxChars = Math.Max(8, (int)(layout.MaxWidth / 6.5f));
147 var rows = SkiaMarkdownDocument.Layout(layout.Body, maxChars);
148 rs = BuildRichStringFromDocument(
149 rows,
150 layout.MaxWidth,
151 layout.FontSize,
152 contentColor,
153 codeColor,
154 layout.ForwardHost,
155 layout.FontFamily,
156 layout.MonoFamily);
157 if (rs is not null && layout.MaxBodyLines > 0 && layout.MaxBodyLines < int.MaxValue)
158 rs.MaxHeight = layout.MaxBodyLines * layout.LineHeight;
159 }
160 else
161 {
162 rs = TryBuildRichString(
163 layout.Body,
164 layout.MaxWidth,
165 layout.FontSize,
166 contentColor,
167 codeColor,
168 layout.MaxBodyLines,
169 layout.LineHeight,
170 layout.FontFamily,
171 layout.MonoFamily);
172 }
173
174 rs?.Paint(canvas, origin);
175 }
176
177 private static RichString? TryBuildRichString(
178 string body,
179 float maxWidth,
180 float fontSize,
181 SKColor contentColor,
182 SKColor codeColor,
183 int maxBodyLines,
184 float lineHeight,
185 string fontFamily,
186 string monoFamily)
187 {
188 if (maxWidth < 8f || string.IsNullOrEmpty(body))
189 return null;
190
191 var runs = SkiaMarkdownLayout.ParseInline(body);
192 if (runs.Count == 0)
193 return null;
194
195 var rs = new RichString { MaxWidth = maxWidth };
196 rs.FontFamily(fontFamily).FontSize(fontSize).TextColor(contentColor);
197
198 foreach (var run in runs)
199 {
200 if (run.Text.Length == 0)
201 continue;
202
203 switch (run.Style)
204 {
205 case SkiaMarkdownStyle.Bold:
206 rs.Add(run.Text, fontWeight: 700);
207 break;
208 case SkiaMarkdownStyle.Italic:
209 rs.Add(run.Text, fontItalic: true);
210 break;
211 case SkiaMarkdownStyle.Code:
212 rs.FontFamily(PrimaryFamily(monoFamily))
213 .TextColor(codeColor)
214 .Add(run.Text);
215 rs.FontFamily(fontFamily).TextColor(contentColor).FontSize(fontSize);
216 break;
217 case SkiaMarkdownStyle.Link:
218 rs.TextColor(contentColor).Add(run.Text);
219 break;
220 default:
221 rs.Add(run.Text);
222 break;
223 }
224 }
225
226 if (maxBodyLines > 0 && maxBodyLines < int.MaxValue)
227 rs.MaxHeight = maxBodyLines * lineHeight;
228
229 return rs;
230 }
231
232 private static RichString? BuildRichStringFromDocument(
233 IReadOnlyList<SkiaMarkdownRow> rows,
234 float maxWidth,
235 float baseFontSize,
236 SKColor contentColor,
237 SKColor codeColor,
238 bool forwardHost,
239 string fontFamily,
240 string monoFamily)
241 {
242 if (maxWidth < 8f || rows.Count == 0)
243 return null;
244
245 var rs = new RichString { MaxWidth = maxWidth };
246 var first = true;
247 foreach (var row in rows)
248 {
249 if (!first && row.Kind != SkiaMarkdownBlockKind.Blank)
250 rs.Add("\n");
251 first = false;
252
253 switch (row.Kind)
254 {
255 case SkiaMarkdownBlockKind.Blank:
256 rs.Add("\n");
257 break;
258 case SkiaMarkdownBlockKind.HorizontalRule:
259 rs.FontFamily(fontFamily)
260 .FontSize(baseFontSize)
261 .TextColor(SkiaKitColor.Blend(contentColor, codeColor, 0.5f))
262 .Add(forwardHost ? "—" : "────────");
263 break;
264 case SkiaMarkdownBlockKind.Heading1:
265 AppendInlineRuns(rs, row.Runs, baseFontSize * 1.28f, contentColor, codeColor, fontFamily, monoFamily, boldDefault: true);
266 break;
267 case SkiaMarkdownBlockKind.Heading2:
268 AppendInlineRuns(rs, row.Runs, baseFontSize * 1.14f, contentColor, codeColor, fontFamily, monoFamily, boldDefault: true);
269 break;
270 case SkiaMarkdownBlockKind.Heading3:
271 AppendInlineRuns(rs, row.Runs, baseFontSize * 1.06f, contentColor, codeColor, fontFamily, monoFamily, boldDefault: true);
272 break;
273 case SkiaMarkdownBlockKind.Bullet:
274 case SkiaMarkdownBlockKind.Paragraph:
275 default:
276 AppendInlineRuns(rs, row.Runs, baseFontSize, contentColor, codeColor, fontFamily, monoFamily, boldDefault: false);
277 break;
278 }
279 }
280
281 return rs;
282 }
283
284 private static void AppendInlineRuns(
285 RichString rs,
286 IReadOnlyList<SkiaMarkdownRun> runs,
287 float fontSize,
288 SKColor contentColor,
289 SKColor codeColor,
290 string fontFamily,
291 string monoFamily,
292 bool boldDefault)
293 {
294 rs.FontFamily(fontFamily).FontSize(fontSize).TextColor(contentColor);
295 foreach (var run in runs)
296 {
297 if (run.Text.Length == 0)
298 continue;
299
300 switch (run.Style)
301 {
302 case SkiaMarkdownStyle.Bold:
303 rs.Add(run.Text, fontWeight: 700);
304 break;
305 case SkiaMarkdownStyle.Italic:
306 rs.Add(run.Text, fontItalic: true);
307 break;
308 case SkiaMarkdownStyle.Code:
309 rs.FontFamily(PrimaryFamily(monoFamily)).TextColor(codeColor).Add(run.Text);
310 rs.FontFamily(fontFamily).TextColor(contentColor).FontSize(fontSize);
311 break;
312 default:
313 if (boldDefault)
314 rs.Add(run.Text, fontWeight: 700);
315 else
316 rs.Add(run.Text);
317 break;
318 }
319 }
320 }
321
322 private static string ResolveMonoFamily(string? monoFamily) =>
323 string.IsNullOrWhiteSpace(monoFamily) ? "Cascadia Mono,Consolas" : monoFamily.Trim();
324
325 private static string PrimaryFamily(string familyList)
326 {
327 var idx = familyList.IndexOf(',');
328 return (idx < 0 ? familyList : familyList[..idx]).Trim();
329 }
330}
331
View only · write via MCP/CIDE