Forge
csharpdeeb25a2
1#nullable enable
2using CascadeIDE.Features.Chat;
3using CascadeIDE.Views.Chat.Skia;
4using SkiaSharp;
5
6namespace CascadeIDE.Views.SkiaKit;
7
8/// <summary>Cockpit Command Line (TCI): RichTextKit caret/hit-test, selection, horizontal scroll. ADR 0138.</summary>
9internal static class SkiaCommandLineStrip
10{
11 public const float HorizontalPadding = 10f;
12 public const float VerticalPadding = 8f;
13 public const string MonoFontFamily = "Cascadia Mono";
14
15 private const float InputLineHeightPerPt = 22f / 12f;
16
17 /// <summary>Высота строки ввода при 12pt (тесты, fallback).</summary>
18 public static float InputLineHeightAt12Pt => InputLineHeightFor(12f);
19
20 public static float InputLineHeightFor(float fontSize) => fontSize * InputLineHeightPerPt;
21
22 public static SkiaTciTextField.Typography ResolveTypography(float fontSize) =>
23 new(fontSize, InputLineHeightFor(fontSize), MonoFontFamily);
24
25 public static float MinHeight(float fontSize = 12f) =>
26 VerticalPadding * 2 + InputLineHeightFor(fontSize);
27
28 public static float MeasureHeight(string? previewText = null, float fontSize = 12f, float previewFontSize = 10f) =>
29 MinHeight(fontSize);
30
31 public static bool ShouldReserveLeadingChip(SlashCommandPreviewKind previewKind, string? bufferText) =>
32 SkiaSlashCommandChip.IconPlacement == SkiaStatusChipIconPlacement.Left
33 && !string.IsNullOrEmpty(bufferText)
34 && SkiaSlashCommandChip.ShouldDraw(previewKind, bufferText);
35
36 public static SkiaTciTextField.Region ComputeInputRegion(
37 SKRect bounds,
38 float fontSize,
39 bool reserveLeadingChip = false)
40 {
41 var lineHeight = InputLineHeightFor(fontSize);
42 var leadingGutter = reserveLeadingChip ? SkiaStatusChip.LeadingChipLayoutGutter : 0f;
43 var textBounds = new SKRect(
44 bounds.Left + HorizontalPadding + leadingGutter,
45 bounds.Top + VerticalPadding,
46 bounds.Right - HorizontalPadding,
47 bounds.Top + VerticalPadding + lineHeight);
48 var inset = ComputeTextTopInset(fontSize, lineHeight, textBounds.Width);
49 return new SkiaTciTextField.Region(textBounds, Math.Max(40f, textBounds.Width), inset);
50 }
51
52 public static float MaxHorizontalScroll(string bufferText, float contentWidth, float fontSize) =>
53 SkiaTciTextField.MaxHorizontalScroll(
54 bufferText,
55 contentWidth,
56 ResolveTypography(fontSize));
57
58 public static bool TryHitTestCaretAtPoint(
59 SKRect stripBounds,
60 string bufferText,
61 float pointX,
62 float pointY,
63 float fontSize,
64 float scrollOffsetX,
65 out int caretIndex,
66 SlashCommandPreviewKind previewKind = SlashCommandPreviewKind.None)
67 {
68 caretIndex = 0;
69 var region = ComputeInputRegion(stripBounds, fontSize, ShouldReserveLeadingChip(previewKind, bufferText));
70 var typo = ResolveTypography(fontSize);
71 return SkiaTciTextField.TryHitTestCaret(
72 region,
73 bufferText,
74 typo,
75 pointX,
76 pointY,
77 scrollOffsetX,
78 0f,
79 maxLines: 1,
80 out caretIndex);
81 }
82
83 public static bool TryGetCaretRect(
84 SKRect stripBounds,
85 string bufferText,
86 int caretIndex,
87 float fontSize,
88 float scrollOffsetX,
89 out SKRect caretRect,
90 SlashCommandPreviewKind previewKind = SlashCommandPreviewKind.None)
91 {
92 caretRect = default;
93 var region = ComputeInputRegion(stripBounds, fontSize, ShouldReserveLeadingChip(previewKind, bufferText));
94 var typo = ResolveTypography(fontSize);
95 var origin = new SKPoint(region.TextBounds.Left, region.TextBounds.Top + region.TextTopInset);
96 if (!SkiaTciTextField.TryGetCaretRect(
97 region,
98 bufferText,
99 caretIndex,
100 typo,
101 origin,
102 scrollOffsetX,
103 0f,
104 out caretRect))
105 return false;
106
107 if (caretRect.Height <= 0f)
108 {
109 caretRect = new SKRect(
110 caretRect.Left,
111 region.TextBounds.Top + 2f,
112 caretRect.Right,
113 region.TextBounds.Bottom - 2f);
114 }
115
116 return true;
117 }
118
119 public static void Draw(
120 SKCanvas canvas,
121 SKRect bounds,
122 ISkiaKitPaintTheme theme,
123 string bufferText,
124 string? previewText,
125 SlashCommandPreviewKind previewKind,
126 string placeholder,
127 bool isEnabled,
128 int caretIndex,
129 int selectionAnchor,
130 bool showCaret,
131 bool caretVisible,
132 float scrollOffsetX,
133 float fontSize = 12f,
134 float previewFontSize = 10f)
135 {
136 using var fill = new SKPaint { Color = SkiaKitColor.Blend(theme.Surface, new SKColor(90, 140, 220), 0.08f), IsAntialias = true };
137 canvas.DrawRect(bounds, fill);
138 using var border = new SKPaint
139 {
140 Color = new SKColor(100, 160, 230, 180),
141 IsAntialias = true,
142 Style = SKPaintStyle.Stroke,
143 StrokeWidth = 1f,
144 };
145 canvas.DrawLine(bounds.Left, bounds.Top + 0.5f, bounds.Right, bounds.Top + 0.5f, border);
146
147 var reserveChip = ShouldReserveLeadingChip(previewKind, bufferText);
148 drawInputLine(
149 canvas,
150 theme,
151 ComputeInputRegion(bounds, fontSize, reserveChip),
152 bufferText,
153 placeholder,
154 isEnabled,
155 caretIndex,
156 selectionAnchor,
157 showCaret,
158 caretVisible,
159 scrollOffsetX,
160 fontSize,
161 previewKind);
162 }
163
164 private static float ComputeTextTopInset(float fontSize, float lineHeight, float contentWidth)
165 {
166 var bodyHeight = SkiaPlainTextLayout.MeasureBodyHeight(
167 "Mg/",
168 Math.Max(40f, contentWidth),
169 fontSize,
170 lineHeight,
171 maxLines: 1,
172 MonoFontFamily);
173 return Math.Max(0f, (lineHeight - bodyHeight) * 0.5f);
174 }
175
176 private static void drawInputLine(
177 SKCanvas canvas,
178 ISkiaKitPaintTheme theme,
179 SkiaTciTextField.Region region,
180 string bufferText,
181 string placeholder,
182 bool isEnabled,
183 int caretIndex,
184 int selectionAnchor,
185 bool showCaret,
186 bool caretVisible,
187 float scrollOffsetX,
188 float fontSize,
189 SlashCommandPreviewKind previewKind)
190 {
191 var typo = ResolveTypography(fontSize);
192 var textBounds = region.TextBounds;
193 var isEmpty = string.IsNullOrEmpty(bufferText);
194 var display = isEmpty && !string.IsNullOrEmpty(placeholder) ? placeholder : bufferText;
195 var contentColor = isEmpty && !string.IsNullOrEmpty(placeholder)
196 ? theme.EmptyHint
197 : isEnabled ? theme.Content : theme.EmptyHint;
198 var linkColor = SkiaSlashPreviewChrome.ChipColors(theme, previewKind).Accent;
199
200 var clipRect = textBounds;
201 SKRect chipRect = default;
202 var drawChip = !isEmpty && SkiaSlashCommandChip.ShouldDraw(previewKind, bufferText);
203 if (drawChip)
204 {
205 var labelW = SkiaSlashCommandChip.MeasureLabelWidth(bufferText, fontSize);
206 chipRect = SkiaSlashCommandChip.ComputeChipRect(
207 textBounds.Left + scrollOffsetX,
208 textBounds.Top,
209 typo.LineHeight,
210 labelW);
211 var chipClip = chipRect;
212 chipClip.Offset(-scrollOffsetX, 0f);
213 clipRect = SkiaStatusChip.ExpandClipForChip(textBounds, chipClip);
214 }
215
216 canvas.Save();
217 canvas.ClipRect(clipRect);
218 canvas.Translate(-scrollOffsetX, 0f);
219
220 var textLeft = textBounds.Left + scrollOffsetX;
221 var origin = new SKPoint(textLeft, textBounds.Top + region.TextTopInset);
222 if (drawChip)
223 SkiaSlashCommandChip.Draw(canvas, chipRect, theme, previewKind, fontSize);
224
225 if (!isEmpty)
226 {
227 SkiaTciTextField.DrawSelection(
228 canvas,
229 bufferText,
230 selectionAnchor,
231 caretIndex,
232 region,
233 typo,
234 origin,
235 scrollOffsetX,
236 0f,
237 maxLines: 1);
238
239 var layout = SkiaPlainTextLayout.TryMeasure(
240 bufferText,
241 region.ContentWidth,
242 typo.FontSize,
243 contentColor,
244 maxLines: 1,
245 typo.LineHeight,
246 typo.FontFamily);
247 if (layout is not null)
248 {
249 var textColor = SkiaSlashCommandChip.ShouldDraw(previewKind, bufferText) ? linkColor : contentColor;
250 SkiaTciTextField.PaintBody(canvas, origin, layout, textColor, textColor);
251 }
252 }
253 else if (!string.IsNullOrEmpty(placeholder))
254 {
255 using var font = SkiaKitFonts.CreateMono(fontSize);
256 using var paint = new SKPaint { IsAntialias = true, Color = contentColor };
257 canvas.DrawText(placeholder, origin.X, origin.Y, SKTextAlign.Left, font, paint);
258 }
259
260 canvas.Restore();
261
262 if (!showCaret || !caretVisible || isEmpty)
263 return;
264
265 var caret = Math.Clamp(caretIndex, 0, bufferText.Length);
266 var originCaret = new SKPoint(textBounds.Left, textBounds.Top + region.TextTopInset);
267 if (SkiaTciTextField.TryGetCaretRect(
268 region,
269 bufferText,
270 caret,
271 typo,
272 originCaret,
273 scrollOffsetX,
274 0f,
275 out var caretRect))
276 SkiaTciTextField.DrawCaretLine(canvas, caretRect.Left, caretRect.Top, caretRect.Bottom);
277 }
278}
279
View only · write via MCP/CIDE