ClickableTextField.swift (10543B)
1 import AppKit 2 import SwiftUI 3 4 /// Custom NSTextField that notifies when clicked (becomes first responder) 5 class ClickableNSTextField: NSTextField { 6 var onBecomeFirstResponder: (() -> Void)? 7 8 /// The item ID this text field represents, used by the per-window 9 /// `WindowCoordinator.allowedFocusTarget` check. 10 var itemID: UUID? 11 12 override var acceptsFirstResponder: Bool { 13 // Always allow if this field is already editing. 14 if currentEditor() != nil { return super.acceptsFirstResponder } 15 16 // Always allow click-initiated focus. 17 if let event = NSApp.currentEvent, event.type == .leftMouseDown { 18 return super.acceptsFirstResponder 19 } 20 21 // Check the per-window coordinator for an allowed focus target. 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 // A specific target is set — only that field may accept. 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 /// NSTextField that's always present, manages its own editing state 63 struct 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 // Shows I-beam cursor on hover 85 textField.isEditable = true // Always editable, becomes first responder on click 86 87 // Notify when field is clicked (becomes first responder) 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 // Only update content when NOT editing to avoid interfering with field editor 99 if !hasEditor { 100 // Update text if different 101 if textField.stringValue != text { 102 textField.stringValue = text 103 } 104 105 // Apply styling (sets attributedStringValue) 106 context.coordinator.applyStyle(to: textField, text: text, isCompleted: isCompleted) 107 } else if text.isEmpty && !textField.stringValue.isEmpty { 108 // External reset (e.g. phantom row chaining) — clear the field 109 // even though the field editor is active. 110 textField.stringValue = "" 111 } 112 113 // Disable if completed 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 // Always calculate height based on maxWidth to preserve multiline wrapping 125 let height = calculateHeight( 126 for: text, width: maxWidth, 127 font: nsView.font ?? .systemFont(ofSize: NSFont.systemFontSize)) 128 129 if isEditing { 130 // When editing, take full width 131 return CGSize(width: maxWidth, height: max(height, 22)) 132 } else { 133 // When not editing, size width to content but maintain multiline height 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 // Calculate text width 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 // Calculate text height with wrapping 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 // Reset for next time 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 // Note: makeFirstResponder(nil) can trigger a Thread Performance 235 // Checker priority inversion warning. This is internal to AppKit's 236 // first responder machinery, not caused by our callback chain. 237 if commandSelector == #selector(NSResponder.insertNewline(_:)) { 238 // Return key pressed — set the per-window allowed focus 239 // target to .scrollView so no text field can steal focus 240 // during reconciliation. Cleared in ItemListView's outer 241 // onChange(of: focusedFieldBinding). 242 editEndReason = .returnKey 243 setAllowedFocusTarget(for: control.window, target: .scrollView) 244 control.window?.makeFirstResponder(nil) 245 return true // Prevent newline insertion 246 } 247 if commandSelector == #selector(NSResponder.cancelOperation(_:)) { 248 // Escape key pressed — same strategy as Return. 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 }