Skip to content

Commit dfc85ba

Browse files
committed
✨ New chat message box
This is now an auto-expanding TextEditor with the sidebars hidden until absolutely needed
1 parent 38e9c7a commit dfc85ba

File tree

10 files changed

+160
-35
lines changed

10 files changed

+160
-35
lines changed

DraftPatch/Components/ChatBox.swift

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ struct ChatBoxView: View {
2121
@State private var selectedText: String?
2222
@State private var lineNumbers: (start: Int, end: Int)?
2323
@State private var fileName: String?
24+
@State private var textEditorHeight: CGFloat = 15
2425

2526
var draftingText: String {
2627
guard let app = selectedDraftApp else { return "" }
@@ -66,34 +67,13 @@ struct ChatBoxView: View {
6667
}
6768
}
6869

69-
TextField(thinking ? "Sending..." : "Draft a message", text: $userMessage, axis: .vertical)
70-
.accessibilityIdentifier("Chatbox")
71-
.multilineTextAlignment(.leading)
72-
.font(.system(size: 14, weight: .regular, design: .rounded))
73-
.textFieldStyle(PlainTextFieldStyle())
74-
.focused($isTextFieldFocused)
75-
.disabled(thinking)
76-
.onAppear {
77-
updateSelectedTextDetails()
78-
DispatchQueue.main.async {
79-
isTextFieldFocused = true
80-
}
81-
}
82-
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) {
83-
_ in
84-
updateSelectedTextDetails()
85-
}
86-
.onKeyPress { keyPress in
87-
if keyPress.modifiers == .shift && keyPress.key == .return {
88-
userMessage += "\n"
89-
return .handled
90-
} else if keyPress.modifiers.isEmpty && keyPress.key == .return {
91-
onSubmit()
92-
return .handled
93-
} else {
94-
return .ignored
95-
}
96-
}
70+
ChatBoxEditor(
71+
userMessage: $userMessage,
72+
isTextFieldFocused: $isTextFieldFocused,
73+
thinking: thinking,
74+
onSubmit: onSubmit,
75+
updateSelectedTextDetails: updateSelectedTextDetails
76+
)
9777

9878
Divider()
9979

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//
2+
// ChatBoxEditor.swift
3+
// DraftPatch
4+
//
5+
// Created by Robert DeLuca on 3/13/25.
6+
//
7+
8+
import Combine
9+
import SwiftUI
10+
11+
struct ChatBoxEditor: View {
12+
@Binding var userMessage: String
13+
@FocusState.Binding var isTextFieldFocused: Bool
14+
@State private var textEditorHeight: CGFloat = 20
15+
16+
let thinking: Bool
17+
let onSubmit: () -> Void
18+
let updateSelectedTextDetails: () -> Void
19+
20+
var body: some View {
21+
ZStack(alignment: .topLeading) {
22+
if userMessage.isEmpty {
23+
Text(thinking ? "Sending..." : "Draft a message")
24+
.font(.system(size: 14, weight: .regular, design: .rounded))
25+
.foregroundColor(.gray)
26+
.padding(0)
27+
.padding(.leading, 5)
28+
.frame(maxWidth: .infinity, alignment: .leading)
29+
}
30+
31+
Text(userMessage.isEmpty ? " " : userMessage)
32+
.font(.system(size: 14, weight: .regular, design: .rounded))
33+
.padding(8)
34+
.frame(maxWidth: .infinity)
35+
.background(
36+
GeometryReader { geometry in
37+
Color.clear
38+
.onAppear { textEditorHeight = max(15, geometry.size.height + 16) }
39+
.onChange(of: userMessage) {
40+
textEditorHeight = max(15, geometry.size.height + 16)
41+
}
42+
}
43+
)
44+
.opacity(0)
45+
46+
CustomTextEditor(text: $userMessage, isFocused: $isTextFieldFocused, thinking: thinking)
47+
.frame(minHeight: 15, maxHeight: textEditorHeight)
48+
.font(.system(size: 14, weight: .regular, design: .rounded))
49+
.disabled(thinking)
50+
.accessibilityLabel(thinking ? "Sending..." : "Draft a message")
51+
.onAppear {
52+
updateSelectedTextDetails()
53+
DispatchQueue.main.async {
54+
isTextFieldFocused = true
55+
}
56+
}
57+
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) {
58+
_ in
59+
updateSelectedTextDetails()
60+
}
61+
.onKeyPress { keyPress in
62+
if keyPress.modifiers.isEmpty && keyPress.key == .return {
63+
onSubmit()
64+
return .handled
65+
} else {
66+
return .ignored
67+
}
68+
}
69+
}
70+
}
71+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//
2+
// CustomTextEditor.swift
3+
// DraftPatch
4+
//
5+
// Created by Robert DeLuca on 3/13/25.
6+
//
7+
8+
import AppKit
9+
import SwiftUI
10+
11+
struct CustomTextEditor: NSViewRepresentable {
12+
@Binding var text: String
13+
@FocusState.Binding var isFocused: Bool
14+
15+
var isEditable: Bool = true
16+
var thinking: Bool = false
17+
18+
class Coordinator: NSObject, NSTextViewDelegate {
19+
var parent: CustomTextEditor
20+
21+
init(_ parent: CustomTextEditor) {
22+
self.parent = parent
23+
}
24+
25+
func textDidChange(_ notification: Notification) {
26+
if let textView = notification.object as? NSTextView {
27+
DispatchQueue.main.async {
28+
self.parent.text = textView.string
29+
}
30+
}
31+
}
32+
}
33+
34+
func makeCoordinator() -> Coordinator {
35+
Coordinator(self)
36+
}
37+
38+
func makeNSView(context: Context) -> NSScrollView {
39+
let scrollView = NSScrollView()
40+
scrollView.hasVerticalScroller = true
41+
scrollView.autohidesScrollers = true
42+
scrollView.hasHorizontalScroller = false
43+
scrollView.drawsBackground = false
44+
scrollView.scrollerStyle = .overlay
45+
46+
let textView = NSTextView()
47+
textView.delegate = context.coordinator
48+
textView.isEditable = isEditable
49+
textView.font = NSFont.systemFont(ofSize: 14)
50+
textView.backgroundColor = .clear
51+
textView.drawsBackground = false
52+
textView.isRichText = false
53+
textView.allowsUndo = true
54+
textView.isHorizontallyResizable = false
55+
textView.autoresizingMask = [.width, .height]
56+
textView.setAccessibilityIdentifier("Chatbox")
57+
textView.setAccessibilityLabel(thinking ? "Sending..." : "Draft a message")
58+
59+
scrollView.documentView = textView
60+
return scrollView
61+
}
62+
63+
func updateNSView(_ nsView: NSScrollView, context: Context) {
64+
if let textView = nsView.documentView as? NSTextView {
65+
if textView.string != text {
66+
textView.string = text
67+
}
68+
69+
if isFocused {
70+
nsView.window?.makeFirstResponder(textView)
71+
}
72+
}
73+
}
74+
}

DraftPatch/Utils/DraftPatchCommands.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,22 @@ struct DraftPatchCommands: Commands {
1919
}
2020

2121
CommandGroup(before: .textEditing) {
22-
Button("Toggle drafting") {
22+
Button("Toggle Drafting") {
2323
viewModel.toggleDrafting()
2424
}
2525
.keyboardShortcut("d", modifiers: .command)
2626
}
2727

2828
CommandGroup(before: .textEditing) {
29-
Button("Select model") {
29+
Button("Select Model") {
3030
// Hacky, letting it fall through to the pickers shortcut modifier
3131
// TODO: Move popover state to view model?
3232
}
3333
.keyboardShortcut("e", modifiers: .command)
3434
}
3535

3636
CommandGroup(replacing: .appSettings) {
37-
Button("Preferences...") {
37+
Button("Settings...") {
3838
viewModel.showSettings.toggle()
3939
}
4040
.keyboardShortcut(",", modifiers: .command)

DraftPatchUITests/DraftPatchUITests.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ final class DraftPatchUITests: XCTestCase {
8787
let title = app.staticTexts["New Conversation"]
8888
XCTAssertTrue(title.waitForExistence(timeout: 2), "The chat title is not 'New Conversation'")
8989

90-
let messageField = app.textFields["Chatbox"]
90+
let messageField = app.textViews["Chatbox"]
9191
XCTAssertTrue(messageField.waitForExistence(timeout: 2), "Chat input field is not visible")
9292

9393
let messageText = "Hello, how are you?"
@@ -119,7 +119,7 @@ final class DraftPatchUITests: XCTestCase {
119119
let title = app.staticTexts["New Conversation"]
120120
XCTAssertTrue(title.waitForExistence(timeout: 2), "The chat title is not 'New Conversation'")
121121

122-
let messageField = app.textFields["Chatbox"]
122+
let messageField = app.textViews["Chatbox"]
123123
XCTAssertTrue(messageField.waitForExistence(timeout: 2), "Chat input field is not visible")
124124

125125
let messageText = "Hello, how are you?"
@@ -192,7 +192,7 @@ final class DraftPatchUITests: XCTestCase {
192192
XCTAssertTrue(title.waitForExistence(timeout: 2), "The chat title is not 'New Conversation'")
193193

194194
// Send a new chat message
195-
let messageField = app.textFields["Chatbox"]
195+
let messageField = app.textViews["Chatbox"]
196196
XCTAssertTrue(messageField.waitForExistence(timeout: 2), "Chat input field is not visible")
197197

198198
let messageText = "Test message"
@@ -269,7 +269,7 @@ final class DraftPatchUITests: XCTestCase {
269269
let title = app.staticTexts["New Conversation"]
270270
XCTAssertTrue(title.waitForExistence(timeout: 2), "The chat title is not 'New Conversation'")
271271

272-
let messageField = app.textFields["Chatbox"]
272+
let messageField = app.textViews["Chatbox"]
273273
XCTAssertTrue(messageField.waitForExistence(timeout: 2), "Chat input field is not visible")
274274

275275
let messageText = "Hello!"

0 commit comments

Comments
 (0)