Forge
csharpdeeb25a2
1#nullable enable
2
3namespace CascadeIDE.Views.Chat.Skia;
4
5internal enum SkiaMarkdownStyle
6{
7 Plain = 0,
8 Bold = 1,
9 Italic = 2,
10 Code = 3,
11 Link = 4
12}
13
14internal readonly record struct SkiaMarkdownRun(string Text, SkiaMarkdownStyle Style);
15
16internal readonly record struct SkiaMarkdownLine(IReadOnlyList<SkiaMarkdownRun> Runs);
17
18/// <summary>Inline Markdown subset v1: **bold**, *italic*, `code` (ADR 0123 фаза 3).</summary>
19internal static class SkiaMarkdownLayout
20{
21 public static IReadOnlyList<SkiaMarkdownRun> ParseInline(string text)
22 {
23 if (string.IsNullOrEmpty(text))
24 return [new SkiaMarkdownRun("", SkiaMarkdownStyle.Plain)];
25
26 var runs = new List<SkiaMarkdownRun>();
27 var i = 0;
28 while (i < text.Length)
29 {
30 if (TryBracketLink(text, i, out var bracketEnd, out var bracketInner))
31 {
32 AppendRun(runs, bracketInner, SkiaMarkdownStyle.Link);
33 i = bracketEnd;
34 continue;
35 }
36
37 if (text[i] == '[')
38 {
39 var close = text.IndexOf(']', i + 1);
40 if (close > i)
41 {
42 AppendRun(runs, text[i..(close + 1)], SkiaMarkdownStyle.Plain);
43 i = close + 1;
44 continue;
45 }
46 }
47
48 if (TryDelimited(text, i, "**", out var boldEnd, out var boldInner))
49 {
50 AppendRun(runs, boldInner, SkiaMarkdownStyle.Bold);
51 i = boldEnd;
52 continue;
53 }
54
55 if (text[i] == '`' && TryDelimited(text, i, "`", out var codeEnd, out var codeInner))
56 {
57 AppendRun(runs, codeInner, SkiaMarkdownStyle.Code);
58 i = codeEnd;
59 continue;
60 }
61
62 if (TryEmphasis(text, i, '*', out var starEnd, out var starInner))
63 {
64 AppendRun(runs, starInner, SkiaMarkdownStyle.Italic);
65 i = starEnd;
66 continue;
67 }
68
69 if (TryEmphasis(text, i, '_', out var underEnd, out var underInner))
70 {
71 AppendRun(runs, underInner, SkiaMarkdownStyle.Italic);
72 i = underEnd;
73 continue;
74 }
75
76 var plainStart = i;
77 while (i < text.Length
78 && !StartsWith(text, i, "**")
79 && text[i] != '`'
80 && text[i] != '*'
81 && text[i] != '_'
82 && text[i] != '[')
83 i++;
84
85 AppendRun(runs, text[plainStart..i], SkiaMarkdownStyle.Plain);
86 }
87
88 return runs.Count == 0 ? [new SkiaMarkdownRun("", SkiaMarkdownStyle.Plain)] : runs;
89 }
90
91 public static IReadOnlyList<SkiaMarkdownLine> WrapLines(IReadOnlyList<SkiaMarkdownRun> runs, int maxChars)
92 {
93 maxChars = Math.Max(8, maxChars);
94 var lines = new List<SkiaMarkdownLine>();
95 var current = new List<SkiaMarkdownRun>();
96 var currentLen = 0;
97
98 void FlushLine()
99 {
100 if (current.Count == 0)
101 current.Add(new SkiaMarkdownRun("", SkiaMarkdownStyle.Plain));
102 lines.Add(new SkiaMarkdownLine(current.ToArray()));
103 current.Clear();
104 currentLen = 0;
105 }
106
107 foreach (var run in runs)
108 {
109 var normalized = run.Text.Replace("\r", "").Replace('\n', ' ');
110 if (normalized.Length == 0)
111 continue;
112
113 var words = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
114 if (words.Length == 0)
115 words = [normalized];
116
117 foreach (var word in words)
118 {
119 var addLen = currentLen == 0 ? word.Length : word.Length + 1;
120 if (currentLen > 0 && currentLen + addLen > maxChars)
121 FlushLine();
122
123 if (word.Length > maxChars)
124 {
125 var offset = 0;
126 while (offset < word.Length)
127 {
128 if (currentLen > 0 && currentLen >= maxChars)
129 FlushLine();
130 var take = Math.Min(maxChars - currentLen, word.Length - offset);
131 if (take <= 0)
132 {
133 FlushLine();
134 take = Math.Min(maxChars, word.Length - offset);
135 }
136
137 AppendWord(current, ref currentLen, word.Substring(offset, take), run.Style);
138 offset += take;
139 if (currentLen >= maxChars)
140 FlushLine();
141 }
142
143 continue;
144 }
145
146 AppendWord(current, ref currentLen, word, run.Style);
147 }
148 }
149
150 if (currentLen > 0 || lines.Count == 0)
151 FlushLine();
152
153 return lines;
154 }
155
156 public static string ToPlainText(IReadOnlyList<SkiaMarkdownLine> lines)
157 {
158 var sb = new System.Text.StringBuilder();
159 for (var li = 0; li < lines.Count; li++)
160 {
161 if (li > 0)
162 sb.AppendLine();
163 var line = lines[li];
164 for (var ri = 0; ri < line.Runs.Count; ri++)
165 {
166 if (ri > 0 && sb.Length > 0 && sb[^1] != '\n' && sb[^1] != ' ')
167 sb.Append(' ');
168 sb.Append(line.Runs[ri].Text);
169 }
170 }
171
172 return sb.ToString();
173 }
174
175 private static void AppendWord(List<SkiaMarkdownRun> line, ref int lineLen, string word, SkiaMarkdownStyle style)
176 {
177 if (lineLen > 0)
178 {
179 MergeLast(line, " ", style);
180 lineLen++;
181 }
182
183 MergeLast(line, word, style);
184 lineLen += word.Length;
185 }
186
187 private static void MergeLast(List<SkiaMarkdownRun> line, string text, SkiaMarkdownStyle style)
188 {
189 if (line.Count > 0 && line[^1].Style == style)
190 line[^1] = new SkiaMarkdownRun(line[^1].Text + text, style);
191 else
192 line.Add(new SkiaMarkdownRun(text, style));
193 }
194
195 private static void AppendRun(List<SkiaMarkdownRun> runs, string text, SkiaMarkdownStyle style)
196 {
197 if (text.Length == 0)
198 return;
199 if (runs.Count > 0 && runs[^1].Style == style)
200 runs[^1] = new SkiaMarkdownRun(runs[^1].Text + text, style);
201 else
202 runs.Add(new SkiaMarkdownRun(text, style));
203 }
204
205 private static bool TryDelimited(string text, int start, string delimiter, out int end, out string inner)
206 {
207 end = start;
208 inner = "";
209 if (!StartsWith(text, start, delimiter))
210 return false;
211
212 var close = text.IndexOf(delimiter, start + delimiter.Length, StringComparison.Ordinal);
213 if (close < 0)
214 return false;
215
216 inner = text[(start + delimiter.Length)..close];
217 end = close + delimiter.Length;
218 return true;
219 }
220
221 private static bool TryEmphasis(string text, int start, char marker, out int end, out string inner)
222 {
223 end = start;
224 inner = "";
225 if (text[start] != marker)
226 return false;
227
228 if (start + 1 < text.Length && text[start + 1] == marker)
229 return false;
230
231 var close = text.IndexOf(marker, start + 1);
232 if (close <= start + 1)
233 return false;
234
235 inner = text[(start + 1)..close];
236 if (inner.Length == 0)
237 return false;
238
239 end = close + 1;
240 return true;
241 }
242
243 private static bool TryBracketLink(string text, int start, out int end, out string span)
244 {
245 end = start;
246 span = "";
247 if (start >= text.Length || text[start] != '[')
248 return false;
249
250 var close = text.IndexOf(']', start + 1);
251 if (close < 0)
252 return false;
253
254 var inner = text[(start + 1)..close];
255 if (inner.Length == 0 || inner.Contains('`', StringComparison.Ordinal))
256 return false;
257
258 if (!looksLikeCodeReference(inner))
259 return false;
260
261 span = text[start..(close + 1)];
262 end = close + 1;
263 return true;
264 }
265
266 private static bool looksLikeCodeReference(string inner) =>
267 inner.Contains(':', StringComparison.Ordinal)
268 || inner.Contains(".cs", StringComparison.OrdinalIgnoreCase)
269 || inner.Contains("F:", StringComparison.OrdinalIgnoreCase)
270 || inner.Contains("M:", StringComparison.OrdinalIgnoreCase);
271
272 private static bool StartsWith(string text, int index, string value) =>
273 index >= 0 && index + value.Length <= text.Length
274 && text.AsSpan(index, value.Length).SequenceEqual(value.AsSpan());
275}
276
View only · write via MCP/CIDE