listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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 }