Forge
csharpdeeb25a2
1#nullable enable
2using Avalonia;
3using CascadeIDE.Models.Intercom;
4using CascadeIDE.Views.SkiaKit;
5using SkiaSharp;
6
7namespace CascadeIDE.Views.Chat.Skia;
8
9internal readonly record struct SkiaChatMeasureContext(int MaxChars, float ContentWidth)
10{
11 public SkiaChatMeasureContext WithContentWidth(float width) =>
12 new(Math.Max(12, (int)(width / 7.1f)), Math.Max(120f, width));
13}
14
15internal readonly record struct SkiaChatHit(
16 int? MessageIndex,
17 Guid? SelectThreadId,
18 bool ResetDetailMode,
19 bool ToggleThinking = false,
20 AttachmentAnchor? RevealAttachment = null,
21 bool RevealAttachmentSelect = false,
22 SkiaChatPointerAction PointerAction = SkiaChatPointerAction.None);
23
24internal readonly record struct SkiaChatMeasuredLayout(
25 float Height,
26 float GapAfter,
27 SkiaChatBubbleMetrics? Bubble = null,
28 SkiaTopicCardModel? TopicCard = null,
29 SkiaTopicCardLayout? TopicCardLayout = null,
30 SkiaRichTextKitBodyLayout? RichTextDetail = null);
31
32internal readonly record struct SkiaChatBubbleMetrics(
33 IReadOnlyList<SkiaMarkdownLine> ContentLines,
34 string? Footer,
35 float TitleHeight,
36 float FooterHeight,
37 float LineHeight,
38 SkiaRichTextKitBodyLayout? RichTextBody = null);
39
40internal readonly record struct SkiaChatPlacedEntity(
41 ISkiaChatEntity Entity,
42 float Top,
43 SkiaChatMeasuredLayout Layout,
44 float Left = float.NaN,
45 float Width = float.NaN);
46
47internal sealed class SkiaChatDrawContext
48{
49 public const float FeedGutterWidth = 36f;
50
51 public required SKCanvas Canvas { get; init; }
52 public required SkiaChatTheme Theme { get; init; }
53 public required float ContentLeft { get; init; }
54 public required float ContentWidth { get; init; }
55 public float FeedGutterLeft => ContentLeft - FeedGutterWidth;
56 public required float ScrollOffset { get; init; }
57 public required int ItemIndex { get; init; }
58 public required int HoveredItemIndex { get; init; }
59 public required int SelectedMessageIndex { get; init; }
60 public IReadOnlySet<int>? HighlightedMessageIndices { get; init; }
61 public required SkiaChatHitRegistry HitRegistry { get; init; }
62
63 public bool IsHovered => ItemIndex == HoveredItemIndex;
64
65 public bool IsMessageHighlighted(int? messageIndex) =>
66 messageIndex is { } idx
67 && (HighlightedMessageIndices?.Contains(idx) == true || idx == SelectedMessageIndex);
68
69 public void RegisterHit(SKRect contentRect, SkiaChatHit hit) =>
70 HitRegistry.RegisterContentRect(contentRect, ScrollOffset, hit);
71}
72
73internal interface ISkiaChatEntity
74{
75 SkiaChatMeasuredLayout Measure(SkiaChatMeasureContext context);
76 void Draw(SkiaChatDrawContext context, float top, in SkiaChatMeasuredLayout layout);
77 SkiaChatHit? CreateHit(in SkiaChatMeasuredLayout layout);
78}
79
80internal interface IGridTileSkiaChatEntity
81{
82 bool IsGridTile { get; }
83}
84
85internal static class SkiaChatLayoutEngine
86{
87 private static readonly SkiaTileGridOptions GridOptions = new(OriginX: 12f);
88
89 public static IReadOnlyList<SkiaChatPlacedEntity> Layout(
90 IReadOnlyList<ISkiaChatEntity> entities,
91 SkiaChatMeasureContext context)
92 {
93 var placed = new List<SkiaChatPlacedEntity>(entities.Count);
94 var y = 8f;
95 for (var i = 0; i < entities.Count;)
96 {
97 if (entities[i] is IGridTileSkiaChatEntity { IsGridTile: true } && TryCollectGridRun(entities, i, out var run))
98 {
99 PlaceGridTiles(placed, run, context, ref y);
100 i += run.Count;
101 continue;
102 }
103
104 var entity = entities[i];
105 var layout = entity.Measure(context);
106 placed.Add(new SkiaChatPlacedEntity(entity, y, layout));
107 y += layout.Height + layout.GapAfter;
108 i++;
109 }
110
111 return placed;
112 }
113
114 private static bool TryCollectGridRun(
115 IReadOnlyList<ISkiaChatEntity> entities,
116 int start,
117 out List<ISkiaChatEntity> run)
118 {
119 run = [];
120 for (var i = start; i < entities.Count; i++)
121 {
122 if (entities[i] is not IGridTileSkiaChatEntity { IsGridTile: true })
123 break;
124 run.Add(entities[i]);
125 }
126
127 return run.Count > 0;
128 }
129
130 private static void PlaceGridTiles(
131 List<SkiaChatPlacedEntity> placed,
132 IReadOnlyList<ISkiaChatEntity> tiles,
133 SkiaChatMeasureContext context,
134 ref float y)
135 {
136 var columns = SkiaTileGridLayout.ComputeColumnCount(context.ContentWidth, in GridOptions);
137 var tileWidth = SkiaTileGridLayout.TileWidth(context.ContentWidth, columns, in GridOptions);
138 var tileContext = context.WithContentWidth(tileWidth);
139 var col = 0;
140 var rowStartY = y;
141 var rowMaxHeight = 0f;
142
143 for (var i = 0; i < tiles.Count; i++)
144 {
145 var entity = tiles[i];
146 var layout = entity.Measure(tileContext);
147 var left = GridOptions.OriginX + col * (tileWidth + GridOptions.GapX);
148 placed.Add(new SkiaChatPlacedEntity(entity, rowStartY, layout, left, tileWidth));
149 rowMaxHeight = Math.Max(rowMaxHeight, layout.Height);
150 col++;
151 if (col < columns)
152 continue;
153
154 col = 0;
155 rowStartY += rowMaxHeight + GridOptions.GapY;
156 rowMaxHeight = 0f;
157 }
158
159 if (col > 0)
160 rowStartY += rowMaxHeight;
161
162 y = rowStartY + (tiles.Count > 0 ? GridOptions.GapY : 0f);
163 }
164
165 public static double TotalHeight(IReadOnlyList<SkiaChatPlacedEntity> placed)
166 {
167 if (placed.Count == 0)
168 return 56;
169 var last = placed[^1];
170 return last.Top + last.Layout.Height + 8;
171 }
172}
173
View only · write via MCP/CIDE