| 1 | #nullable enable |
| 2 | |
| 3 | using CascadeIDE.Models; |
| 4 | using CascadeIDE.Services; |
| 5 | using SkiaSharp; |
| 6 | |
| 7 | namespace CascadeIDE.Views.Chat.Skia; |
| 8 | |
| 9 | /// <summary> |
| 10 | /// Единые метрики flat feed (ADR 0123): role rail, колонка тела, prose/slash — без разрозненных inset/baseline в сущностях. |
| 11 | /// </summary> |
| 12 | internal readonly struct SkiaChatFeedLayout |
| 13 | { |
| 14 | public const float TextBaselineFactor = 0.85f; |
| 15 | public const float MinColumnWidth = 80f; |
| 16 | public const float CharsPerEm = 7.1f; |
| 17 | private const float ReferenceProsePt = 11f; |
| 18 | |
| 19 | /// <summary>Чат в лобовом Forward; плотнее метрики. Не ширина колонки (split — отдельная топология).</summary> |
| 20 | public bool ForwardHost { get; } |
| 21 | public IntercomFontsSettings Fonts { get; } |
| 22 | |
| 23 | public string ProseFamily { get; } |
| 24 | public string MonoFamily { get; } |
| 25 | public string ChipFamily { get; } |
| 26 | public string ChipIdFamily { get; } |
| 27 | public string SlashMetaFamily { get; } |
| 28 | public string SlashArgsFamily { get; } |
| 29 | public string RoleFamily { get; } |
| 30 | public string GutterFamily { get; } |
| 31 | |
| 32 | private float Scale { get; } |
| 33 | |
| 34 | public float RoleRailWidth => (ForwardHost ? 52f : 68f) * Scale; |
| 35 | public float RoleLabelInset => 6f; |
| 36 | public float TextInset => 8f; |
| 37 | public float BodyTopPad => ForwardHost ? 6f : 10f; |
| 38 | public float SegmentGap => ForwardHost ? 4f : 8f; |
| 39 | public float RowEdgePad => 3f; |
| 40 | |
| 41 | public float ProseFontSize { get; } |
| 42 | public float ProseLineHeight => (ForwardHost ? 15f : 18f) * Scale; |
| 43 | public float ProseMeasureWidthTrim => TextInset * 2f; |
| 44 | public float AttachChipFontSize => 11f * Scale; |
| 45 | |
| 46 | public float SlashAccentWidth => 3f; |
| 47 | public float SlashAccentGap => 4f; |
| 48 | public float SlashMetaBodyGap => SegmentGap; |
| 49 | public float SlashIconReserve => |
| 50 | SkiaSlashCommandStatusIconRenderer.IconSize |
| 51 | + SkiaSlashCommandStatusIconRenderer.IconMargin |
| 52 | + SlashAccentGap; |
| 53 | |
| 54 | public float SlashMetaFontSize => (ForwardHost ? 10.5f : 11.5f) * Scale; |
| 55 | public float SlashArgsFontSize => ProseFontSize; |
| 56 | public float SlashMetaLineHeight => (ForwardHost ? 15f : 17f) * Scale; |
| 57 | public float SlashArgsLineHeight => (ForwardHost ? 14f : 16f) * Scale; |
| 58 | public float SlashBadgeFontSize => 10.5f * Scale; |
| 59 | |
| 60 | public float RoleLabelFontSize => (ForwardHost ? 10f : 11f) * Scale; |
| 61 | public float GutterOrdinalFontSize => 10f * Scale; |
| 62 | |
| 63 | private SkiaChatFeedLayout(bool forwardHost, IntercomFontsSettings fonts) |
| 64 | { |
| 65 | ForwardHost = forwardHost; |
| 66 | Fonts = fonts; |
| 67 | ProseFontSize = fonts.ResolveProsePt(forwardHost); |
| 68 | Scale = ProseFontSize / ReferenceProsePt; |
| 69 | ProseFamily = fonts.ResolveProseFamily(); |
| 70 | MonoFamily = fonts.ResolveMonoFamily(); |
| 71 | ChipFamily = fonts.ResolveChipFamily(); |
| 72 | ChipIdFamily = fonts.ResolveChipIdFamily(); |
| 73 | SlashMetaFamily = fonts.ResolveSlashMetaFamily(); |
| 74 | SlashArgsFamily = fonts.ResolveSlashArgsFamily(); |
| 75 | RoleFamily = fonts.ResolveRoleFamily(); |
| 76 | GutterFamily = fonts.ResolveGutterFamily(); |
| 77 | } |
| 78 | |
| 79 | public static SkiaChatFeedLayout For(bool forwardHost, IntercomFontsSettings? fonts = null) => |
| 80 | new(forwardHost, fonts ?? IntercomFontDefaults.Intercom); |
| 81 | |
| 82 | public bool ShouldShowRoleRail(string? roleLabel, bool suppressTitle) => |
| 83 | !suppressTitle && !string.IsNullOrWhiteSpace(roleLabel); |
| 84 | |
| 85 | public FeedBodyColumn BodyColumn(float contentLeft, float contentWidth, bool includeRoleRail) |
| 86 | { |
| 87 | if (!includeRoleRail) |
| 88 | return new FeedBodyColumn(contentLeft, Math.Max(MinColumnWidth, contentWidth)); |
| 89 | |
| 90 | var left = contentLeft + RoleRailWidth; |
| 91 | var width = Math.Max(MinColumnWidth, contentWidth - RoleRailWidth); |
| 92 | return new FeedBodyColumn(left, width); |
| 93 | } |
| 94 | |
| 95 | public SkiaChatMeasureContext NarrowMeasureContext(SkiaChatMeasureContext context, bool includeRoleRail) |
| 96 | { |
| 97 | if (!includeRoleRail) |
| 98 | return context; |
| 99 | |
| 100 | var column = BodyColumn(0f, context.ContentWidth, includeRoleRail: true); |
| 101 | return context.WithContentWidth(column.Width); |
| 102 | } |
| 103 | |
| 104 | public int MaxCharsForWidth(float width) => |
| 105 | Math.Max(12, (int)(width / CharsPerEm)); |
| 106 | |
| 107 | public SKRect RowRect(float contentLeft, float contentWidth, float top, float height) => |
| 108 | new(contentLeft, top, contentLeft + contentWidth, top + height); |
| 109 | |
| 110 | public SKRect SegmentRect(in FeedBodyColumn column, float top, float height) => |
| 111 | new(column.Left, top, column.Right, top + height); |
| 112 | |
| 113 | public float FirstLineBaselineY(float rowTop, float titleHeight = 0f) => |
| 114 | rowTop + titleHeight + BodyTopPad + ProseFontSize * TextBaselineFactor; |
| 115 | |
| 116 | public float RoleLabelBaselineY(float rowTop) => |
| 117 | rowTop + BodyTopPad + RoleLabelFontSize * TextBaselineFactor; |
| 118 | |
| 119 | public float SlashMetaBaselineY(float rowTop) => |
| 120 | rowTop + BodyTopPad + SlashMetaFontSize * TextBaselineFactor; |
| 121 | |
| 122 | public SKPoint RichTextPaintOrigin(float textLeft, float firstLineBaselineY, float fontSize) => |
| 123 | new(textLeft, firstLineBaselineY - fontSize * TextBaselineFactor); |
| 124 | |
| 125 | public SlashTextColumn SlashTextColumn(float contentLeft, float contentWidth) |
| 126 | { |
| 127 | var body = BodyColumn(contentLeft, contentWidth, includeRoleRail: true); |
| 128 | var left = body.Left + SlashAccentWidth + SlashAccentGap; |
| 129 | var right = body.Right - SlashIconReserve; |
| 130 | var width = Math.Max(MinColumnWidth, right - left); |
| 131 | return new SlashTextColumn(left, right, width); |
| 132 | } |
| 133 | |
| 134 | public float SlashRowHeight(bool hasArgs, bool hasDetail, float detailHeight) |
| 135 | { |
| 136 | var height = SlashMetaLineHeight + SlashMetaBodyGap; |
| 137 | if (hasArgs) |
| 138 | height += SlashArgsLineHeight + SlashMetaBodyGap; |
| 139 | if (hasDetail) |
| 140 | height += SlashMetaBodyGap + detailHeight; |
| 141 | return height; |
| 142 | } |
| 143 | |
| 144 | public float SlashMetaBandTop(float rowTop) => rowTop; |
| 145 | |
| 146 | public float SlashArgsBandTop(float rowTop) => rowTop + SlashMetaLineHeight + SlashMetaBodyGap; |
| 147 | |
| 148 | public float SlashDetailBaselineY(float rowTop, bool hasArgs) |
| 149 | { |
| 150 | var bandTop = hasArgs |
| 151 | ? SlashArgsBandTop(rowTop) + SlashArgsLineHeight + SlashMetaBodyGap |
| 152 | : SlashMetaBandTop(rowTop) + SlashMetaLineHeight + SlashMetaBodyGap; |
| 153 | return bandTop + ProseFontSize * TextBaselineFactor; |
| 154 | } |
| 155 | |
| 156 | public float RoleLabelMaxWidth => RoleRailWidth - RoleLabelInset * 2f; |
| 157 | } |
| 158 | |
| 159 | internal readonly struct FeedBodyColumn(float left, float width) |
| 160 | { |
| 161 | public float Left { get; } = left; |
| 162 | public float Width { get; } = width; |
| 163 | public float Right => Left + Width; |
| 164 | |
| 165 | public float TextLeft(SkiaChatFeedLayout layout) => Left + layout.TextInset; |
| 166 | } |
| 167 | |
| 168 | internal readonly struct SlashTextColumn(float left, float right, float width) |
| 169 | { |
| 170 | public float Left { get; } = left; |
| 171 | public float Right { get; } = right; |
| 172 | public float Width { get; } = width; |
| 173 | } |
| 174 | |