diff --git a/Sources/UI/CompactComposerOverlayRows.swift b/Sources/UI/CompactComposerOverlayRows.swift index 2a7bb70a..39dc1eba 100644 --- a/Sources/UI/CompactComposerOverlayRows.swift +++ b/Sources/UI/CompactComposerOverlayRows.swift @@ -52,14 +52,18 @@ extension CompactComposerOverlayView { @ViewBuilder var quoteCardsRow: some View { if !draftQuotes.isEmpty { - VStack(alignment: .leading, spacing: JinSpacing.xSmall + 2) { - ForEach(draftQuotes) { quote in - ComposerQuoteCardView(quote: quote) { - onRemoveQuote(quote) + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: JinSpacing.small) { + ForEach(draftQuotes) { quote in + ComposerQuoteCardView(quote: quote) { + onRemoveQuote(quote) + } + .equatable() + .transition(ComposerQuoteCardView.transition(reduceMotion: reduceMotion)) } - .equatable() - .transition(ComposerQuoteCardView.transition(reduceMotion: reduceMotion)) } + .padding(.horizontal, JinSpacing.xSmall) + .padding(.vertical, 2) } } } diff --git a/Sources/UI/ExpandedComposerOverlay.swift b/Sources/UI/ExpandedComposerOverlay.swift index 0f567e2f..4a572efe 100644 --- a/Sources/UI/ExpandedComposerOverlay.swift +++ b/Sources/UI/ExpandedComposerOverlay.swift @@ -85,8 +85,8 @@ struct ExpandedComposerOverlay: View { if !draftQuotes.isEmpty { ExpandedComposerAccessorySection(title: "Quotes", systemName: "quote.opening") { - ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: JinSpacing.xSmall + 2) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: JinSpacing.small) { ForEach(draftQuotes) { quote in ComposerQuoteCardView(quote: quote) { onRemoveQuote(quote) @@ -95,8 +95,9 @@ struct ExpandedComposerOverlay: View { .transition(ComposerQuoteCardView.transition(reduceMotion: reduceMotion)) } } + .padding(.horizontal, JinSpacing.xSmall) + .padding(.vertical, 2) } - .frame(maxHeight: 168) } } diff --git a/Sources/UI/QuoteCardViews.swift b/Sources/UI/QuoteCardViews.swift index ca1f861a..4debc752 100644 --- a/Sources/UI/QuoteCardViews.swift +++ b/Sources/UI/QuoteCardViews.swift @@ -77,15 +77,20 @@ private enum QuoteCardDensity { private struct QuoteCardContainer: View { let quote: QuoteContent let density: QuoteCardDensity + let accessoryFocused: Bool let accessory: Accessory + @State private var isHovering = false + init( quote: QuoteContent, density: QuoteCardDensity = .message, + accessoryFocused: Bool = false, @ViewBuilder accessory: () -> Accessory ) { self.quote = quote self.density = density + self.accessoryFocused = accessoryFocused self.accessory = accessory() } @@ -149,78 +154,69 @@ private struct QuoteCardContainer: View { ) } - private var composerQuoteBody: some View { - VStack(alignment: .leading, spacing: 3) { - HStack(alignment: .center, spacing: JinSpacing.xSmall + 2) { - Image(systemName: "arrowshape.turn.up.left.fill") - .font(.system(size: 8.5, weight: .semibold)) - .foregroundStyle(QuoteCardPalette.accent) - - if let iconID = quote.sourceProviderIconID { - ProviderIconView(iconID: iconID, fallbackSystemName: "sparkles", size: 11) - .frame(width: 11, height: 11) - } - - headerLabel - - Spacer(minLength: JinSpacing.xSmall) + private var composerTooltip: String { + let header = composerHeader + let prefix: String + if let modelName = header.modelName { + prefix = "\(header.prefix) \(modelName)" + } else { + prefix = header.prefix + } + return "\(prefix)\n\n\(quote.quotedText)" + } - accessory - .layoutPriority(1) - } + private var composerQuoteBody: some View { + HStack(alignment: .top, spacing: 0) { + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(QuoteCardPalette.accent) + .frame(width: 2) + .padding(.vertical, JinSpacing.small - 2) + .padding(.leading, JinSpacing.small - 2) Text(quote.quotedText) - .font(.callout) - .foregroundStyle(.primary.opacity(0.86)) - .lineLimit(2) + .font(.caption) + .foregroundStyle(.primary.opacity(0.85)) + .lineLimit(QuoteCardLayout.composerLineLimit) .truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.leading, JinSpacing.small) + .padding(.trailing, JinSpacing.small) + .padding(.vertical, JinSpacing.small) } - .padding(.leading, JinSpacing.medium + 4) - .padding(.trailing, JinSpacing.small + 2) - .padding(.vertical, JinSpacing.small) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) + .frame( + width: QuoteCardLayout.composerWidth, + height: QuoteCardLayout.composerHeight, + alignment: .topLeading + ) .background( - RoundedRectangle(cornerRadius: JinRadius.medium, style: .continuous) - .fill(JinSemanticColor.subtleSurface) + RoundedRectangle(cornerRadius: JinRadius.small, style: .continuous) + .fill(JinSemanticColor.subtleSurface.opacity(0.65)) ) - .overlay(alignment: .leading) { - RoundedRectangle(cornerRadius: 1.25, style: .continuous) - .fill(QuoteCardPalette.accent) - .frame(width: 2.5) - .padding(.vertical, JinSpacing.small) - .padding(.leading, JinSpacing.small) - } .overlay( - RoundedRectangle(cornerRadius: JinRadius.medium, style: .continuous) - .stroke(JinSemanticColor.separator.opacity(0.5), lineWidth: JinStrokeWidth.hairline) + RoundedRectangle(cornerRadius: JinRadius.small, style: .continuous) + .stroke(JinSemanticColor.separator.opacity(0.4), lineWidth: JinStrokeWidth.hairline) ) - } - - @ViewBuilder - private var headerLabel: some View { - let header = composerHeader - if let modelName = header.modelName { - HStack(spacing: 4) { - Text(header.prefix) - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) - Text(modelName) - .font(.caption.weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) - } - } else { - Text(header.prefix) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .lineLimit(1) + .overlay(alignment: .topTrailing) { + accessory + .opacity(isHovering || accessoryFocused ? 1 : 0) + .animation(.easeInOut(duration: 0.12), value: isHovering) + .animation(.easeInOut(duration: 0.12), value: accessoryFocused) + .padding(.top, 2) + .padding(.trailing, 2) } + .contentShape(RoundedRectangle(cornerRadius: JinRadius.small, style: .continuous)) + .onHover { isHovering = $0 } + .help(composerTooltip) } } +enum QuoteCardLayout { + static let composerWidth: CGFloat = 160 + static let composerHeight: CGFloat = 76 + static let composerLineLimit: Int = 4 +} + struct MessageQuoteCardView: View { let quote: QuoteContent @@ -235,9 +231,15 @@ struct ComposerQuoteCardView: View, Equatable { let quote: DraftQuote let onRemove: () -> Void + @State private var isDismissFocused = false + var body: some View { - QuoteCardContainer(quote: quote.content, density: .composer) { - QuoteDismissButton(action: onRemove) + QuoteCardContainer( + quote: quote.content, + density: .composer, + accessoryFocused: isDismissFocused + ) { + QuoteDismissButton(action: onRemove, isFocused: $isDismissFocused) } } @@ -258,26 +260,42 @@ struct ComposerQuoteCardView: View, Equatable { private struct QuoteDismissButton: View { let action: () -> Void + @Binding var isFocused: Bool @State private var isHovering = false + @FocusState private var isButtonFocused: Bool var body: some View { Button(action: action) { Image(systemName: "xmark") - .font(.system(size: 9, weight: .bold)) + .font(.system(size: 8, weight: .bold)) .foregroundStyle( - isHovering - ? AnyShapeStyle(Color.primary.opacity(0.78)) - : AnyShapeStyle(HierarchicalShapeStyle.tertiary) + isHovering || isButtonFocused + ? AnyShapeStyle(Color.primary.opacity(0.85)) + : AnyShapeStyle(Color.primary.opacity(0.55)) ) - .frame(width: 18, height: 18) + .frame(width: 16, height: 16) .background( Circle() - .fill(Color.primary.opacity(isHovering ? 0.08 : 0)) + .fill(.regularMaterial) + .opacity(isHovering || isButtonFocused ? 1 : 0.85) + ) + .overlay( + Circle() + .stroke( + isButtonFocused + ? Color.accentColor.opacity(0.7) + : JinSemanticColor.separator.opacity(0.4), + lineWidth: isButtonFocused ? JinStrokeWidth.regular : JinStrokeWidth.hairline + ) ) .contentShape(Rectangle()) } .buttonStyle(.plain) + .focused($isButtonFocused) + .onChange(of: isButtonFocused) { _, newValue in + isFocused = newValue + } .onHover { isHovering = $0 } .accessibilityLabel("Remove quote") .help("Remove quote")