diff --git a/Wisp/Views/SpriteDetail/Chat/AssistantMessageView.swift b/Wisp/Views/SpriteDetail/Chat/AssistantMessageView.swift index 2d50360e..991ee4a0 100644 --- a/Wisp/Views/SpriteDetail/Chat/AssistantMessageView.swift +++ b/Wisp/Views/SpriteDetail/Chat/AssistantMessageView.swift @@ -85,31 +85,49 @@ private struct AssistantTextBubble: View { var onCreateCheckpoint: (() -> Void)? = nil @State private var showTimestamp = false + @State private var isSelectMode = false var body: some View { VStack(alignment: .leading, spacing: 4) { - Markdown(text) - .markdownTheme(.wisp) - .markdownCodeSyntaxHighlighter(WispCodeHighlighter()) - .textSelection(.enabled) + if isSelectMode { + SelectableTextView( + text: text, + textColor: .label, + font: .preferredFont(forTextStyle: .body), + onDeselect: { isSelectMode = false } + ) .padding(.horizontal, 14) .padding(.vertical, 10) .background(Color(.systemGray5), in: RoundedRectangle(cornerRadius: 16)) - .contextMenu { - Button { - UIPasteboard.general.string = text - } label: { - Label("Copy", systemImage: "doc.on.doc") - } - if canCheckpoint { + } else { + Markdown(text) + .markdownTheme(.wisp) + .markdownCodeSyntaxHighlighter(WispCodeHighlighter()) + .textSelection(.enabled) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color(.systemGray5), in: RoundedRectangle(cornerRadius: 16)) + .contextMenu { + Button { + UIPasteboard.general.string = text + } label: { + Label("Copy", systemImage: "doc.on.doc") + } Button { - onCreateCheckpoint?() + isSelectMode = true } label: { - Label("Create Checkpoint", systemImage: "diamond") + Label("Select", systemImage: "selection.pin.in.out") + } + if canCheckpoint { + Button { + onCreateCheckpoint?() + } label: { + Label("Create Checkpoint", systemImage: "diamond") + } + .disabled(isCheckpointDisabled) } - .disabled(isCheckpointDisabled) } - } + } if showTimestamp { Text(timestamp.chatTimestamp) .font(.caption2) @@ -119,6 +137,7 @@ private struct AssistantTextBubble: View { } } .onTapGesture { + guard !isSelectMode else { return } withAnimation(.easeInOut(duration: 0.2)) { showTimestamp.toggle() } diff --git a/Wisp/Views/SpriteDetail/Chat/SelectableTextView.swift b/Wisp/Views/SpriteDetail/Chat/SelectableTextView.swift new file mode 100644 index 00000000..622d5490 --- /dev/null +++ b/Wisp/Views/SpriteDetail/Chat/SelectableTextView.swift @@ -0,0 +1,66 @@ +import SwiftUI +import UIKit + +/// A non-editable UITextView that immediately selects all its text on appearance. +/// Used for the "Select" context menu action on chat message bubbles. +struct SelectableTextView: UIViewRepresentable { + let text: String + let textColor: UIColor + let font: UIFont + var onDeselect: (() -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator(onDeselect: onDeselect) + } + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = true + textView.backgroundColor = .clear + textView.font = font + textView.textColor = textColor + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.isScrollEnabled = false + textView.delegate = context.coordinator + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + if textView.text != text { + textView.text = text + } + guard !context.coordinator.hasInitiallySelected else { return } + DispatchQueue.main.async { + textView.selectAll(nil) + context.coordinator.hasInitiallySelected = true + } + } + + class Coordinator: NSObject, UITextViewDelegate { + var onDeselect: (() -> Void)? + var hasInitiallySelected = false + + init(onDeselect: (() -> Void)?) { + self.onDeselect = onDeselect + } + + func textViewDidChangeSelection(_ textView: UITextView) { + guard hasInitiallySelected, textView.selectedRange.length == 0 else { return } + onDeselect?() + } + } +} + +#Preview { + SelectableTextView( + text: "Can you add a README to this project?", + textColor: .white, + font: .preferredFont(forTextStyle: .body) + ) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.blue, in: RoundedRectangle(cornerRadius: 16)) + .padding() +} diff --git a/Wisp/Views/SpriteDetail/Chat/UserBubbleView.swift b/Wisp/Views/SpriteDetail/Chat/UserBubbleView.swift index 7e8e5572..e422f01e 100644 --- a/Wisp/Views/SpriteDetail/Chat/UserBubbleView.swift +++ b/Wisp/Views/SpriteDetail/Chat/UserBubbleView.swift @@ -3,6 +3,7 @@ import SwiftUI struct UserBubbleView: View { let message: ChatMessage @State private var showTimestamp = false + @State private var isSelectMode = false private func linkedText(_ text: String) -> AttributedString { var attributed = AttributedString(text) @@ -24,19 +25,31 @@ struct UserBubbleView: View { HStack { Spacer(minLength: 60) VStack(alignment: .trailing, spacing: 4) { - ForEach(message.content) { content in - if case .text(let text) = content { - Text(linkedText(text)) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(.blue, in: RoundedRectangle(cornerRadius: 16)) - .foregroundStyle(.white) - .tint(.white) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - showTimestamp.toggle() + if isSelectMode { + SelectableTextView( + text: message.textContent, + textColor: .white, + font: .preferredFont(forTextStyle: .body), + onDeselect: { isSelectMode = false } + ) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.blue, in: RoundedRectangle(cornerRadius: 16)) + } else { + ForEach(message.content) { content in + if case .text(let text) = content { + Text(linkedText(text)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.blue, in: RoundedRectangle(cornerRadius: 16)) + .foregroundStyle(.white) + .tint(.white) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + showTimestamp.toggle() + } } - } + } } } if showTimestamp { @@ -47,10 +60,17 @@ struct UserBubbleView: View { } } .contextMenu { - Button { - UIPasteboard.general.string = message.textContent - } label: { - Label("Copy", systemImage: "doc.on.doc") + if !isSelectMode { + Button { + UIPasteboard.general.string = message.textContent + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + Button { + isSelectMode = true + } label: { + Label("Select", systemImage: "selection.pin.in.out") + } } } }