← All repositories

listless

A simple list app for Apple platforms

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


commit a2fa753e1ad15f9db59e4b410437ef229d2f5cfd
parent 1a979974bb59a7fa0d5dffd2c6040601b1684b04
Author: Michael Camilleri <[email protected]>
Date:   Fri,  6 Feb 2026 22:21:49 +0900

Add files I forgot

Co-Authored-By: Claude 4.5 Sonnet <[email protected]>

Diffstat

AListlessMac/Views/ClickableTextField.swift | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessiOS/Views/ClickableTextField.swift | 26++++++++++++++++++++++++++

2 files changed, 186 insertions(+), 0 deletions(-)

diff --git a/ListlessMac/Views/ClickableTextField.swift b/ListlessMac/Views/ClickableTextField.swift

@@ -0,0 +1,160 @@
+import SwiftUI
+import AppKit
+
+/// Custom NSTextField that notifies when clicked (becomes first responder)
+class ClickableNSTextField: NSTextField {
+    var onBecomeFirstResponder: (() -> Void)?
+
+    override func becomeFirstResponder() -> Bool {
+        let result = super.becomeFirstResponder()
+        if result {
+            onBecomeFirstResponder?()
+        }
+        return result
+    }
+}
+
+/// NSTextField that's always present, manages its own editing state
+struct ClickableTextField: NSViewRepresentable {
+    @Binding var text: String
+    let isCompleted: Bool
+    let onEditingChanged: (Bool) -> Void
+
+    func makeNSView(context: Context) -> ClickableNSTextField {
+        let textField = ClickableNSTextField()
+        textField.delegate = context.coordinator
+        textField.isBordered = false
+        textField.drawsBackground = false
+        textField.focusRingType = .none
+        textField.font = .systemFont(ofSize: NSFont.systemFontSize)
+        textField.lineBreakMode = .byWordWrapping
+        textField.maximumNumberOfLines = 5
+        textField.usesSingleLineMode = false
+        textField.cell?.wraps = true
+        textField.cell?.isScrollable = false
+        textField.isSelectable = true  // Shows I-beam cursor on hover
+        textField.isEditable = true    // Always editable, becomes first responder on click
+
+        // Notify when field is clicked (becomes first responder)
+        textField.onBecomeFirstResponder = {
+            context.coordinator.handleBecomeFirstResponder()
+        }
+
+        return textField
+    }
+
+    func updateNSView(_ textField: ClickableNSTextField, context: Context) {
+        let hasEditor = textField.currentEditor() != nil
+
+        // Only update content when NOT editing to avoid interfering with field editor
+        if !hasEditor {
+            // Update text if different
+            if textField.stringValue != text {
+                textField.stringValue = text
+            }
+
+            // Apply styling (sets attributedStringValue)
+            context.coordinator.applyStyle(to: textField, text: text, isCompleted: isCompleted)
+        }
+
+        // Disable if completed
+        textField.isEditable = !isCompleted
+        textField.isSelectable = !isCompleted
+    }
+
+    func sizeThatFits(_ proposal: ProposedViewSize, nsView: ClickableNSTextField, context: Context) -> CGSize? {
+        let maxWidth = proposal.width ?? 300
+        let isEditing = nsView.currentEditor() != nil
+
+        if isEditing {
+            // When editing, take full width
+            let height = calculateHeight(for: text, width: maxWidth, font: nsView.font ?? .systemFont(ofSize: NSFont.systemFontSize))
+            return CGSize(width: maxWidth, height: max(height, 22))
+        } else {
+            // When not editing, size to content
+            let width = calculateWidth(for: text, font: nsView.font ?? .systemFont(ofSize: NSFont.systemFontSize))
+            let height = calculateHeight(for: text, width: width, font: nsView.font ?? .systemFont(ofSize: NSFont.systemFontSize))
+            return CGSize(width: min(width, maxWidth), height: max(height, 22))
+        }
+    }
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator(text: $text, onEditingChanged: onEditingChanged)
+    }
+
+    // Calculate text width
+    private func calculateWidth(for text: String, font: NSFont) -> CGFloat {
+        let attributedString = NSAttributedString(
+            string: text.isEmpty ? "New task" : text,
+            attributes: [.font: font]
+        )
+        let size = attributedString.size()
+        return ceil(size.width) + 4
+    }
+
+    // Calculate text height with wrapping
+    private func calculateHeight(for text: String, width: CGFloat, font: NSFont) -> CGFloat {
+        let attributedString = NSAttributedString(
+            string: text.isEmpty ? "New task" : text,
+            attributes: [.font: font]
+        )
+        let textStorage = NSTextStorage(attributedString: attributedString)
+        let layoutManager = NSLayoutManager()
+        let textContainer = NSTextContainer(size: CGSize(width: width, height: .greatestFiniteMagnitude))
+        textContainer.lineFragmentPadding = 0
+
+        layoutManager.addTextContainer(textContainer)
+        textStorage.addLayoutManager(layoutManager)
+
+        layoutManager.ensureLayout(for: textContainer)
+        let rect = layoutManager.usedRect(for: textContainer)
+
+        return ceil(rect.height) + 4
+    }
+
+    final class Coordinator: NSObject, NSTextFieldDelegate {
+        @Binding var text: String
+        let onEditingChanged: (Bool) -> Void
+
+        init(text: Binding<String>, onEditingChanged: @escaping (Bool) -> Void) {
+            _text = text
+            self.onEditingChanged = onEditingChanged
+        }
+
+        @MainActor
+        func applyStyle(to textField: NSTextField, text: String, isCompleted: Bool) {
+            let displayText = text.isEmpty ? "New task" : text
+            let attributes: [NSAttributedString.Key: Any] = [
+                .font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
+                .foregroundColor: text.isEmpty ? NSColor.secondaryLabelColor : (isCompleted ? NSColor.secondaryLabelColor : NSColor.labelColor),
+                .strikethroughStyle: isCompleted ? NSUnderlineStyle.single.rawValue : 0,
+                .strikethroughColor: NSColor.secondaryLabelColor
+            ]
+            textField.attributedStringValue = NSAttributedString(string: displayText, attributes: attributes)
+        }
+
+        func handleBecomeFirstResponder() {
+            print("🟡 ClickableTextField.becomeFirstResponder - calling onEditingChanged(true)")
+            onEditingChanged(true)
+        }
+
+        func controlTextDidEndEditing(_ obj: Notification) {
+            print("🟡 ClickableTextField.controlTextDidEndEditing fired")
+            onEditingChanged(false)
+        }
+
+        func controlTextDidChange(_ obj: Notification) {
+            guard let textField = obj.object as? NSTextField else { return }
+            text = textField.stringValue
+        }
+
+        func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
+            if commandSelector == #selector(NSResponder.insertNewline(_:)) {
+                // Resign first responder, which triggers controlTextDidEndEditing → onEditingChanged(false)
+                control.window?.makeFirstResponder(nil)
+                return true  // Prevent newline insertion
+            }
+            return false
+        }
+    }
+}

diff --git a/ListlessiOS/Views/ClickableTextField.swift b/ListlessiOS/Views/ClickableTextField.swift

@@ -0,0 +1,26 @@
+import SwiftUI
+
+/// iOS version - simple TextField wrapper
+struct ClickableTextField: View {
+    @Binding var text: String
+    let isCompleted: Bool
+    let onEditingChanged: (Bool) -> Void
+
+    @FocusState private var isFocused: Bool
+
+    var body: some View {
+        TextField("New task", text: $text, axis: .vertical)
+            .textFieldStyle(.plain)
+            .font(.body)
+            .lineLimit(1...5)
+            .focused($isFocused)
+            .onSubmit {
+                // Resign focus when Return is pressed (same as focus loss)
+                isFocused = false
+            }
+            .disabled(isCompleted)
+            .onChange(of: isFocused) { _, newValue in
+                onEditingChanged(newValue)
+            }
+    }
+}