From 94b89216f7f4f262f947e1761b4c525035b26483 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 22:08:01 +0000 Subject: [PATCH] style(onboarding): add speech bubble tails --- .../ClawdisChatUI/ChatMessageViews.swift | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift index 6f8e599d6..f4e33025a 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift @@ -6,6 +6,73 @@ private enum ChatUIConstants { static let bubbleCorner: CGFloat = 18 } +private enum ChatBubbleTailSide { + case left + case right + case none +} + +private struct ChatBubbleShape: Shape { + let cornerRadius: CGFloat + let tailSide: ChatBubbleTailSide + + private let tailWidth: CGFloat = 10 + private let tailHeight: CGFloat = 16 + + func path(in rect: CGRect) -> Path { + let hasTail = self.tailSide != .none + let bubbleRect: CGRect = { + guard hasTail else { return rect } + switch self.tailSide { + case .left: + return CGRect( + x: rect.minX + tailWidth, + y: rect.minY, + width: rect.width - tailWidth, + height: rect.height) + case .right: + return CGRect( + x: rect.minX, + y: rect.minY, + width: rect.width - tailWidth, + height: rect.height) + case .none: + return rect + } + }() + + var path = Path(roundedRect: bubbleRect, cornerRadius: cornerRadius) + + guard hasTail else { return path } + + let tailBaseY = bubbleRect.maxY - tailHeight - 4 + let tailTipY = bubbleRect.maxY - 6 + + switch self.tailSide { + case .left: + let start = CGPoint(x: bubbleRect.minX, y: tailBaseY) + let tip = CGPoint(x: rect.minX + 2, y: tailTipY) + let end = CGPoint(x: bubbleRect.minX + 2, y: tailTipY + 6) + path.move(to: start) + path.addQuadCurve(to: tip, control: CGPoint(x: bubbleRect.minX - 4, y: tailBaseY + 6)) + path.addQuadCurve(to: end, control: CGPoint(x: bubbleRect.minX - 2, y: tailTipY + 8)) + path.closeSubpath() + case .right: + let start = CGPoint(x: bubbleRect.maxX, y: tailBaseY) + let tip = CGPoint(x: rect.maxX - 2, y: tailTipY) + let end = CGPoint(x: bubbleRect.maxX - 2, y: tailTipY + 6) + path.move(to: start) + path.addQuadCurve(to: tip, control: CGPoint(x: bubbleRect.maxX + 4, y: tailBaseY + 6)) + path.addQuadCurve(to: end, control: CGPoint(x: bubbleRect.maxX + 2, y: tailTipY + 8)) + path.closeSubpath() + case .none: + break + } + + return path + } +} + @MainActor struct ChatMessageBubble: View { let message: ClawdisChatMessage @@ -76,7 +143,7 @@ private struct ChatMessageBody: View { .foregroundStyle(textColor) .background(self.bubbleBackground) .overlay(self.bubbleBorder) - .clipShape(RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous)) + .clipShape(self.bubbleShape) .shadow(color: self.bubbleShadowColor, radius: self.bubbleShadowRadius, y: self.bubbleShadowYOffset) } @@ -117,6 +184,17 @@ private struct ChatMessageBody: View { return RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous) .strokeBorder(borderColor, lineWidth: lineWidth) + .clipShape(self.bubbleShape) + } + + private var bubbleShape: ChatBubbleShape { + let tailSide: ChatBubbleTailSide + if self.style == .onboarding { + tailSide = self.isUser ? .right : .left + } else { + tailSide = .none + } + return ChatBubbleShape(cornerRadius: ChatUIConstants.bubbleCorner, tailSide: tailSide) } private var bubbleShadowColor: Color {