ListlessMac/Helpers/ClickableTextField.swift (10543B)
1import AppKit
2import SwiftUI
3
4
5class ClickableNSTextField: NSTextField {
6 var onBecomeFirstResponder: (() -> Void)?
7
8
9
10 var itemID: UUID?
11
12 override var acceptsFirstResponder: Bool {
13
14 if currentEditor() != nil { return super.acceptsFirstResponder }
15
16
17 if let event = NSApp.currentEvent, event.type == .leftMouseDown {
18 return super.acceptsFirstResponder
19 }
20
21
22 if let window,
23 let delegate = NSApp.delegate as? AppDelegate,
24 let coordinator = delegate.coordinator(for: window)
25 {
26 if let allowed = coordinator.allowedFocusTarget {
27
28 guard let itemID, case .item(let allowedID) = allowed, allowedID == itemID else {
29 return false
30 }
31 }
32 }
33
34 return super.acceptsFirstResponder
35 }
36
37 override func viewDidMoveToWindow() {
38 super.viewDidMoveToWindow()
39 guard let window,
40 let itemID,
41 let delegate = NSApp.delegate as? AppDelegate,
42 let coordinator = delegate.coordinator(for: window)
43 else { return }
44 if case .item(let allowedID) = coordinator.allowedFocusTarget, allowedID == itemID {
45 coordinator.allowedFocusTarget = nil
46 window.makeFirstResponder(self)
47 }
48 }
49
50 override func becomeFirstResponder() -> Bool {
51 let result = super.becomeFirstResponder()
52 if result, let event = NSApp.currentEvent, event.type == .leftMouseDown {
53 let locationInView = convert(event.locationInWindow, from: nil)
54 if bounds.contains(locationInView) {
55 onBecomeFirstResponder?()
56 }
57 }
58 return result
59 }
60}
61
62
63struct ClickableTextField: NSViewRepresentable {
64 @Binding var text: String
65 let isCompleted: Bool
66 let onEditingChanged: (Bool, _ shouldCreateNewItem: Bool) -> Void
67 var itemID: UUID? = nil
68 var onContentChange: ((String) -> Void)? = nil
69
70 func makeNSView(context: Context) -> ClickableNSTextField {
71 let textField = ClickableNSTextField()
72 textField.itemID = itemID
73 textField.delegate = context.coordinator
74 textField.isBordered = false
75 textField.drawsBackground = false
76 textField.focusRingType = .none
77 textField.font = .systemFont(ofSize: NSFont.systemFontSize)
78 textField.placeholderString = "Enter text"
79 textField.lineBreakMode = .byWordWrapping
80 textField.maximumNumberOfLines = 5
81 textField.usesSingleLineMode = false
82 textField.cell?.wraps = true
83 textField.cell?.isScrollable = false
84 textField.isSelectable = true
85 textField.isEditable = true
86
87
88 textField.onBecomeFirstResponder = {
89 context.coordinator.handleBecomeFirstResponder()
90 }
91
92 return textField
93 }
94
95 func updateNSView(_ textField: ClickableNSTextField, context: Context) {
96 let hasEditor = textField.currentEditor() != nil
97
98
99 if !hasEditor {
100
101 if textField.stringValue != text {
102 textField.stringValue = text
103 }
104
105
106 context.coordinator.applyStyle(to: textField, text: text, isCompleted: isCompleted)
107 } else if text.isEmpty && !textField.stringValue.isEmpty {
108
109
110 textField.stringValue = ""
111 }
112
113
114 textField.isEditable = !isCompleted
115 textField.isSelectable = !isCompleted
116 }
117
118 func sizeThatFits(_ proposal: ProposedViewSize, nsView: ClickableNSTextField, context: Context)
119 -> CGSize?
120 {
121 let maxWidth = proposal.width ?? 300
122 let isEditing = nsView.currentEditor() != nil
123
124
125 let height = calculateHeight(
126 for: text, width: maxWidth,
127 font: nsView.font ?? .systemFont(ofSize: NSFont.systemFontSize))
128
129 if isEditing {
130
131 return CGSize(width: maxWidth, height: max(height, 22))
132 } else {
133
134 let width = calculateWidth(
135 for: text, font: nsView.font ?? .systemFont(ofSize: NSFont.systemFontSize))
136 return CGSize(width: min(width, maxWidth), height: max(height, 22))
137 }
138 }
139
140 func makeCoordinator() -> Coordinator {
141 Coordinator(text: $text, onEditingChanged: onEditingChanged, onContentChange: onContentChange)
142 }
143
144 enum EditEndReason {
145 case returnKey
146 case escape
147 case focusLost
148 }
149
150
151 private func calculateWidth(for text: String, font: NSFont) -> CGFloat {
152 let attributedString = NSAttributedString(
153 string: text.isEmpty ? "Enter text" : text,
154 attributes: [.font: font]
155 )
156 let size = attributedString.size()
157 return ceil(size.width) + 4
158 }
159
160
161 private func calculateHeight(for text: String, width: CGFloat, font: NSFont) -> CGFloat {
162 let displayText = text.isEmpty ? "Enter text" : text
163 let rect = (displayText as NSString).boundingRect(
164 with: CGSize(width: width, height: .greatestFiniteMagnitude),
165 options: [.usesLineFragmentOrigin, .usesFontLeading],
166 attributes: [.font: font]
167 )
168 return ceil(rect.height) + 4
169 }
170
171 @MainActor
172 final class Coordinator: NSObject, NSTextFieldDelegate {
173 @Binding var text: String
174 let onEditingChanged: (Bool, _ shouldCreateNewItem: Bool) -> Void
175 let onContentChange: ((String) -> Void)?
176 var editEndReason: EditEndReason = .focusLost
177
178 init(
179 text: Binding<String>,
180 onEditingChanged: @escaping (Bool, _ shouldCreateNewItem: Bool) -> Void,
181 onContentChange: ((String) -> Void)? = nil
182 ) {
183 _text = text
184 self.onEditingChanged = onEditingChanged
185 self.onContentChange = onContentChange
186 }
187
188 func applyStyle(to textField: NSTextField, text: String, isCompleted: Bool) {
189 guard !text.isEmpty else {
190 textField.stringValue = ""
191 return
192 }
193 let attributes: [NSAttributedString.Key: Any] = [
194 .font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
195 .foregroundColor: isCompleted ? NSColor.secondaryLabelColor : NSColor.labelColor,
196 .strikethroughStyle: isCompleted ? NSUnderlineStyle.single.rawValue : 0,
197 .strikethroughColor: NSColor.secondaryLabelColor,
198 ]
199 textField.attributedStringValue = NSAttributedString(
200 string: text, attributes: attributes)
201 }
202
203 private var hasNotifiedEditingStarted = false
204
205 func handleBecomeFirstResponder() {
206 hasNotifiedEditingStarted = true
207 onEditingChanged(true, false)
208 }
209
210 func controlTextDidBeginEditing(_ obj: Notification) {
211 guard !hasNotifiedEditingStarted else { return }
212 hasNotifiedEditingStarted = true
213 onEditingChanged(true, false)
214 }
215
216 func controlTextDidEndEditing(_ obj: Notification) {
217 hasNotifiedEditingStarted = false
218 let shouldCreateNewItem = editEndReason == .returnKey
219 editEndReason = .focusLost
220 onEditingChanged(false, shouldCreateNewItem)
221 }
222
223 func controlTextDidChange(_ obj: Notification) {
224 guard let textField = obj.object as? NSTextField else { return }
225 text = textField.stringValue
226 onContentChange?(textField.stringValue)
227 }
228
229 func control(
230 _ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector
231 )
232 -> Bool
233 {
234
235
236
237 if commandSelector == #selector(NSResponder.insertNewline(_:)) {
238
239
240
241
242 editEndReason = .returnKey
243 setAllowedFocusTarget(for: control.window, target: .scrollView)
244 control.window?.makeFirstResponder(nil)
245 return true
246 }
247 if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
248
249 editEndReason = .escape
250 setAllowedFocusTarget(for: control.window, target: .scrollView)
251 control.window?.makeFirstResponder(nil)
252 return true
253 }
254 return false
255 }
256
257 private func setAllowedFocusTarget(for window: NSWindow?, target: FocusField) {
258 guard let window,
259 let delegate = NSApp.delegate as? AppDelegate,
260 let coordinator = delegate.coordinator(for: window)
261 else { return }
262 coordinator.allowedFocusTarget = target
263 }
264 }
265}