← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


ListlessMac/Helpers/ClickableTextField.swift (10543B)

1import AppKit
2import SwiftUI
3
4/// Custom NSTextField that notifies when clicked (becomes first responder)
5class 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
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  // 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}