| 1 | #nullable enable |
| 2 | using CascadeIDE.Features.Chat; |
| 3 | using CascadeIDE.Models; |
| 4 | using SkiaSharp; |
| 5 | |
| 6 | namespace CascadeIDE.Views.Chat.Skia; |
| 7 | |
| 8 | /// <summary>Боковая панель Topic Navigator: поиск + дерево тем (ADR 0127-E).</summary> |
| 9 | internal 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 | |