Forge
csharpdeeb25a2
1#nullable enable
2using CascadeIDE.Features.Chat;
3using CascadeIDE.Models;
4using SkiaSharp;
5
6namespace CascadeIDE.Views.Chat.Skia;
7
8/// <summary>Spine row + topic tab bar (ADR 0127 фазы A–B).</summary>
9internal static class SkiaIntercomNavigationChrome
10{
11 public const float SpineRowHeight = 26f;
12 public const float ScopeStripRowHeight = 22f;
13 public const float TabBarHeight = 30f;
14 public const int DefaultMaxVisibleTabs = 7;
15 public const float HorizontalPad = 12f;
16 public const float TabGap = 6f;
17 public const float TabPadX = 10f;
18
19 public sealed record TabHit(Guid ThreadId, SKRect Bounds);
20
21 public sealed record LayoutResult(
22 IReadOnlyList<TabHit> TabHits,
23 SKRect CreateButtonBounds,
24 SKRect? OverflowBounds,
25 int OverflowHiddenCount);
26
27 public static float ResolveNavigationHeight(bool forwardHost, bool overviewMode, int topicCount, bool hasScopeStrip = false)
28 {
29 if (!forwardHost || topicCount <= 0)
30 return 0f;
31 var height = SpineRowHeight;
32 if (hasScopeStrip)
33 height += ScopeStripRowHeight;
34 if (!overviewMode)
35 height += TabBarHeight;
36 return height;
37 }
38
39 public static float ResolveTopChromeHeight(
40 bool forwardHost,
41 bool showOverviewCatalog,
42 bool showStatusSubtitle,
43 bool overviewMode,
44 int topicCount,
45 bool hasScopeStrip = false) =>
46 SkiaChatChromeRenderer.ResolveToolbarHeight(forwardHost, showStatusSubtitle)
47 + ResolveNavigationHeight(forwardHost, overviewMode, topicCount, hasScopeStrip)
48 + (showOverviewCatalog ? SkiaChatChromeRenderer.OverviewCatalogBandHeight : 0f);
49
50 public static float DrawSpineRow(
51 SKCanvas canvas,
52 float width,
53 float top,
54 SkiaChatTheme theme,
55 ChatProductSpine spine,
56 IntercomFontsSettings fonts)
57 {
58 var bottom = top + SpineRowHeight;
59 using (var fill = new SKPaint
60 {
61 Color = SkiaKit.SkiaKitColor.Blend(theme.Surface, theme.Border, 0.06f),
62 IsAntialias = true,
63 })
64 canvas.DrawRect(new SKRect(0, top, width, bottom), fill);
65
66 using (var line = new SKPaint { Color = theme.Border, StrokeWidth = 1, IsAntialias = true })
67 canvas.DrawLine(0, bottom - 0.5f, width, bottom - 0.5f, line);
68
69 var lineTitle = ChatProductSpinePresentation.ResolveLineTitle(spine);
70 var focus = string.IsNullOrWhiteSpace(spine.CurrentFocus)
71 ? null
72 : ChatProductSpinePresentation.FormatDetailStripFocus(spine.CurrentFocus);
73 var spineText = focus is null
74 ? $"Spine: {Truncate(lineTitle, 40)}"
75 : $"Spine: {Truncate(lineTitle, 28)} · {Truncate(focus, 36)}";
76
77 var pt = Math.Max(10f, fonts.ResolveChromeSubtitlePt());
78 using var font = new SKFont(SKTypeface.FromFamilyName(fonts.ResolveProseFamily()), pt);
79 using var paint = new SKPaint { IsAntialias = true, Color = theme.MutedContent };
80 canvas.DrawText(spineText, HorizontalPad, top + pt * 0.9f + 4f, SKTextAlign.Left, font, paint);
81 return bottom;
82 }
83
84 public static float DrawScopeStripRow(
85 SKCanvas canvas,
86 float width,
87 float top,
88 SkiaChatTheme theme,
89 ChatSedmScopeStrip scopeStrip,
90 IntercomFontsSettings fonts)
91 {
92 var bottom = top + ScopeStripRowHeight;
93 using (var fill = new SKPaint
94 {
95 Color = SkiaKit.SkiaKitColor.Blend(theme.Surface, theme.BubbleAssistant, 0.08f),
96 IsAntialias = true,
97 })
98 canvas.DrawRect(new SKRect(0, top, width, bottom), fill);
99
100 using (var line = new SKPaint { Color = theme.Border, StrokeWidth = 1, IsAntialias = true })
101 canvas.DrawLine(0, bottom - 0.5f, width, bottom - 0.5f, line);
102
103 var text = scopeStrip.FormatStripText();
104 if (string.IsNullOrWhiteSpace(text))
105 text = "—";
106
107 var pt = Math.Max(9f, fonts.ResolveChromeSubtitlePt() - 1f);
108 using var font = new SKFont(SKTypeface.FromFamilyName(fonts.ResolveProseFamily()), pt);
109 using var paint = new SKPaint { IsAntialias = true, Color = theme.Content };
110 canvas.DrawText("Scope: " + Truncate(text, 96), HorizontalPad, top + pt * 0.9f + 3f, SKTextAlign.Left, font, paint);
111 return bottom;
112 }
113
114 public static LayoutResult DrawTopicTabBar(
115 SKCanvas canvas,
116 float width,
117 float top,
118 SkiaChatTheme theme,
119 IReadOnlyList<ChatThreadOverviewItem> overview,
120 Guid selectedThreadId,
121 IntercomFontsSettings fonts,
122 int maxVisibleTabs = DefaultMaxVisibleTabs)
123 {
124 var bottom = top + TabBarHeight;
125 using (var fill = new SKPaint
126 {
127 Color = theme.Surface,
128 IsAntialias = true,
129 })
130 canvas.DrawRect(new SKRect(0, top, width, bottom), fill);
131
132 var tabPt = Math.Max(10f, fonts.ResolveChromeSubtitlePt() + 1f);
133 using var font = new SKFont(SKTypeface.FromFamilyName(fonts.ResolveProseFamily()), tabPt);
134 using var paint = new SKPaint { IsAntialias = true, Color = theme.Content };
135 using var muted = new SKPaint { IsAntialias = true, Color = theme.MutedContent };
136 using var selBg = new SKPaint
137 {
138 IsAntialias = true,
139 Color = SkiaKit.SkiaKitColor.Blend(theme.BubbleUser, theme.Border, 0.2f),
140 };
141
142 var hits = new List<TabHit>();
143 var x = HorizontalPad;
144 var maxX = width - HorizontalPad - 36f;
145 var overflowCount = 0;
146 SKRect? overflowBounds = null;
147
148 var visible = overview;
149 if (overview.Count > maxVisibleTabs)
150 {
151 overflowCount = overview.Count - (maxVisibleTabs - 1);
152 visible = overview.Take(maxVisibleTabs - 1).ToList();
153 }
154
155 foreach (var item in visible)
156 {
157 var label = item.IsMainThread ? "★ " + item.Title : item.Title;
158 label = Truncate(label, 22);
159 var textWidth = font.MeasureText(label);
160 var tabWidth = textWidth + TabPadX * 2;
161 if (x + tabWidth > maxX)
162 break;
163
164 var rect = new SKRect(x, top + 4f, x + tabWidth, bottom - 4f);
165 var selected = item.ThreadId == selectedThreadId;
166 if (selected)
167 canvas.DrawRoundRect(rect, 6, 6, selBg);
168 else
169 {
170 using var bg = new SKPaint
171 {
172 IsAntialias = true,
173 Color = SkiaKit.SkiaKitColor.Blend(theme.Surface, theme.Border, 0.25f),
174 };
175 canvas.DrawRoundRect(rect, 6, 6, bg);
176 }
177
178 canvas.DrawText(
179 label,
180 rect.MidX,
181 rect.MidY + tabPt * 0.35f,
182 SKTextAlign.Center,
183 font,
184 selected ? paint : muted);
185 hits.Add(new TabHit(item.ThreadId, rect));
186 x = rect.Right + TabGap;
187 }
188
189 if (overflowCount > 0)
190 {
191 var label = $"+{overflowCount}";
192 var textWidth = font.MeasureText(label);
193 var rect = new SKRect(x, top + 4f, x + textWidth + TabPadX * 2, bottom - 4f);
194 using var bg = new SKPaint
195 {
196 IsAntialias = true,
197 Color = SkiaKit.SkiaKitColor.Blend(theme.Surface, theme.Border, 0.35f),
198 };
199 canvas.DrawRoundRect(rect, 6, 6, bg);
200 canvas.DrawText(label, rect.MidX, rect.MidY + tabPt * 0.35f, SKTextAlign.Center, font, muted);
201 overflowBounds = rect;
202 x = rect.Right + TabGap;
203 }
204
205 var createLabel = "+";
206 var createWidth = font.MeasureText(createLabel) + 14f;
207 var createRect = new SKRect(Math.Min(x, width - HorizontalPad - createWidth), top + 4f, width - HorizontalPad, bottom - 4f);
208 using (var createBg = new SKPaint
209 {
210 IsAntialias = true,
211 Color = SkiaKit.SkiaKitColor.Blend(theme.BubbleAssistant, theme.Border, 0.3f),
212 })
213 canvas.DrawRoundRect(createRect, 6, 6, createBg);
214 canvas.DrawText(createLabel, createRect.MidX, createRect.MidY + tabPt * 0.35f, SKTextAlign.Center, font, paint);
215
216 using (var line = new SKPaint { Color = theme.Border, StrokeWidth = 1, IsAntialias = true })
217 canvas.DrawLine(0, bottom - 0.5f, width, bottom - 0.5f, line);
218
219 return new LayoutResult(hits, createRect, overflowBounds, overflowCount);
220 }
221
222 private static string Truncate(string text, int maxChars) =>
223 text.Length <= maxChars ? text : text[..maxChars] + "…";
224}
225
View only · write via MCP/CIDE