Forge
csharp4405de34
1#nullable enable
2using CascadeIDE.Features.Chat;
3using CascadeIDE.Models;
4using SkiaSharp;
5
6namespace CascadeIDE.Views.Chat.Skia;
7
8/// <summary>Боковая панель Topic Navigator: поиск + дерево тем (ADR 0127-E).</summary>
9internal static class SkiaIntercomTopicNavigator
10{
11 public const float PanelWidth = 220f;
12 public const float SearchHeight = 30f;
13 public const float RowHeight = 30f;
14 public const float RowGap = 3f;
15 public const float Pad = 8f;
16
17 /// <summary>Геометрия панели: один расчёт для отрисовки и pointer-hit (без дублирования listTop/scroll).</summary>
18 internal sealed record PanelLayout(
19 SKRect SearchBounds,
20 SKRect ListClipRect,
21 float ListTop,
22 float ContentHeight);
23
24 public sealed record RowHit(Guid ThreadId, SKRect Bounds);
25
26 public sealed record LayoutResult(
27 IReadOnlyList<RowHit> RowHits,
28 PanelLayout Layout,
29 SKRect SearchBounds,
30 float ContentHeight);
31
32 /// <summary>Единая точка: координаты строки в пространстве списка (после Translate) → координаты контрола.</summary>
33 internal static SKRect MapRowBoundsToPanel(SKRect rowInListSpace, PanelLayout layout, float scrollOffset)
34 {
35 var bounds = rowInListSpace;
36 bounds.Offset(0f, layout.ListTop - scrollOffset);
37 return bounds;
38 }
39
40 internal static PanelLayout ComputePanelLayout(float left, float top, float height, int rowCount)
41 {
42 var searchTop = top + Pad;
43 var searchBounds = new SKRect(left + Pad, searchTop, left + PanelWidth - Pad, searchTop + SearchHeight);
44 var listTop = searchBounds.Bottom + Pad;
45 var listBottom = top + height - Pad;
46 var contentHeight = rowCount > 0
47 ? Pad + SearchHeight + Pad + rowCount * RowHeight + Math.Max(0, rowCount - 1) * RowGap + Pad
48 : Pad + SearchHeight + Pad + 24f;
49 var listClip = new SKRect(left, listTop, left + PanelWidth, listBottom);
50 return new PanelLayout(searchBounds, listClip, listTop, contentHeight);
51 }
52
53 public static LayoutResult Draw(
54 SKCanvas canvas,
55 float left,
56 float top,
57 float height,
58 SkiaChatTheme theme,
59 IntercomFontsSettings fonts,
60 IReadOnlyList<ChatThreadPresentation.PickerRow> rows,
61 Guid selectedThreadId,
62 string? searchQuery,
63 float scrollOffset,
64 bool searchFocused,
65 int searchCaretIndex,
66 bool searchCaretVisible)
67 {
68 var panelLayout = ComputePanelLayout(left, top, height, rows.Count);
69 var panelRect = new SKRect(left, top, left + PanelWidth, top + height);
70 using (var panelFill = new SKPaint
71 {
72 Color = SkiaKit.SkiaKitColor.Blend(theme.Surface, theme.Border, 0.1f),
73 IsAntialias = true,
74 })
75 canvas.DrawRect(panelRect, panelFill);
76
77 using (var divider = new SKPaint { Color = theme.Border, StrokeWidth = 1, IsAntialias = true })
78 canvas.DrawLine(left + PanelWidth - 0.5f, top, left + PanelWidth - 0.5f, top + height, divider);
79
80 DrawSearchField(
81 canvas,
82 panelLayout.SearchBounds,
83 theme,
84 fonts,
85 searchQuery,
86 searchFocused,
87 searchCaretIndex,
88 searchCaretVisible);
89
90 canvas.Save();
91 canvas.ClipRect(panelLayout.ListClipRect, antialias: false);
92 canvas.Translate(0, panelLayout.ListTop - scrollOffset);
93
94 var hits = new List<RowHit>();
95 var y = 0f;
96 var rowPt = Math.Max(10f, fonts.ResolveChromeSubtitlePt());
97 using var titleFont = new SKFont(SKTypeface.FromFamilyName(fonts.ResolveProseFamily()), rowPt);
98 using var titlePaint = new SKPaint { IsAntialias = true, Color = theme.Content };
99 using var mutedPaint = new SKPaint { IsAntialias = true, Color = theme.MutedContent };
100 using var selBg = new SKPaint
101 {
102 IsAntialias = true,
103 Color = SkiaKit.SkiaKitColor.Blend(theme.BubbleUser, theme.Border, 0.22f),
104 };
105
106 foreach (var row in rows)
107 {
108 var rowInListSpace = new SKRect(left + Pad, y, left + PanelWidth - Pad, y + RowHeight);
109 if (row.ThreadId == selectedThreadId)
110 canvas.DrawRoundRect(rowInListSpace, 5, 5, selBg);
111 else
112 {
113 using var rowFill = new SKPaint
114 {
115 Color = SkiaKit.SkiaKitColor.Blend(theme.Surface, theme.Border, 0.18f),
116 IsAntialias = true,
117 };
118 canvas.DrawRoundRect(rowInListSpace, 5, 5, rowFill);
119 }
120
121 var titleX = rowInListSpace.Left + 6f + row.Depth * 12f;
122 canvas.DrawText(Truncate(row.Title, 22), titleX, rowInListSpace.MidY + 4f, SKTextAlign.Left, titleFont, titlePaint);
123 canvas.DrawText(row.Meta, rowInListSpace.Right - 6f, rowInListSpace.MidY + 4f, SKTextAlign.Right, titleFont, mutedPaint);
124 hits.Add(new RowHit(row.ThreadId, MapRowBoundsToPanel(rowInListSpace, panelLayout, scrollOffset)));
125 y += RowHeight + RowGap;
126 }
127
128 if (rows.Count == 0)
129 {
130 using var hintFont = new SKFont(SKTypeface.FromFamilyName(fonts.ResolveProseFamily()), rowPt - 1f);
131 canvas.DrawText(
132 "Нет тем",
133 left + Pad,
134 8f,
135 SKTextAlign.Left,
136 hintFont,
137 mutedPaint);
138 }
139
140 canvas.Restore();
141 return new LayoutResult(hits, panelLayout, panelLayout.SearchBounds, panelLayout.ContentHeight);
142 }
143
144 private static void DrawSearchField(
145 SKCanvas canvas,
146 SKRect bounds,
147 SkiaChatTheme theme,
148 IntercomFontsSettings fonts,
149 string? query,
150 bool searchFocused,
151 int caretIndex,
152 bool caretVisible)
153 {
154 using var fill = new SKPaint
155 {
156 Color = SkiaKit.SkiaKitColor.Blend(theme.Surface, theme.Border, 0.28f),
157 IsAntialias = true,
158 };
159 canvas.DrawRoundRect(bounds, 6, 6, fill);
160 using var stroke = new SKPaint
161 {
162 Color = searchFocused
163 ? SkiaKit.SkiaKitColor.Blend(theme.BubbleUser, theme.Border, 0.35f)
164 : theme.Border,
165 IsAntialias = true,
166 Style = SKPaintStyle.Stroke,
167 StrokeWidth = searchFocused ? 1.5f : 1f,
168 };
169 canvas.DrawRoundRect(bounds, 6, 6, stroke);
170
171 var pt = Math.Max(10f, fonts.ResolveChromeSubtitlePt());
172 using var font = new SKFont(SKTypeface.FromFamilyName(fonts.ResolveProseFamily()), pt);
173 var trimmed = query?.Trim() ?? "";
174 var showPlaceholder = trimmed.Length == 0 && !searchFocused;
175 var text = showPlaceholder ? "Поиск…" : trimmed;
176 using var paint = new SKPaint
177 {
178 IsAntialias = true,
179 Color = showPlaceholder ? theme.MutedContent : theme.Content,
180 };
181 var textLeft = bounds.Left + 8f;
182 canvas.DrawText(Truncate(text, 28), textLeft, bounds.MidY + 4f, SKTextAlign.Left, font, paint);
183
184 if (searchFocused && caretVisible)
185 {
186 var caret = Math.Clamp(caretIndex, 0, trimmed.Length);
187 var prefix = trimmed[..caret];
188 var caretX = textLeft + (prefix.Length > 0 ? font.MeasureText(prefix) : 0f);
189 using var caretPaint = new SKPaint { IsAntialias = true, Color = theme.Content, StrokeWidth = 1.5f };
190 var midY = bounds.MidY + 4f;
191 canvas.DrawLine(caretX, midY - pt * 0.45f, caretX, midY + pt * 0.45f, caretPaint);
192 }
193 }
194
195 private static string Truncate(string text, int maxChars) =>
196 text.Length <= maxChars ? text : text[..maxChars] + "…";
197}
198
View only · write via MCP/CIDE