Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 34 additions & 15 deletions Wisp/Views/SpriteDetail/Chat/AssistantMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -119,6 +137,7 @@ private struct AssistantTextBubble: View {
}
}
.onTapGesture {
guard !isSelectMode else { return }
withAnimation(.easeInOut(duration: 0.2)) {
showTimestamp.toggle()
}
Expand Down
66 changes: 66 additions & 0 deletions Wisp/Views/SpriteDetail/Chat/SelectableTextView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
52 changes: 36 additions & 16 deletions Wisp/Views/SpriteDetail/Chat/UserBubbleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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")
}
}
}
}
Expand Down