← All repositories

listless

A simple list app for Apple platforms

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


commit 45d6a4431551ee55ae3a2ad2a1342e0aeb51426e
parent 7cba48e134ed7925eff330ca039434dc89e73740
Author: Michael Camilleri <[email protected]>
Date:   Fri, 13 Mar 2026 10:41:55 +0900

Fix clicking issues in empty space

In the macOS version, clicking in empty space in a window should cause
a currently focused text field to lose focus and selection. This was not
occurring but this commit fixes that.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat

MListless.xcodeproj/project.pbxproj | 4++++
AListlessMac/Helpers/BackgroundClickMonitor.swift | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessMac/Views/TaskListView.swift | 32+++++++++++++++++++++++++++++---

3 files changed, 101 insertions(+), 3 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj

@@ -28,6 +28,7 @@
 		2CCB5FE0084742D018E52A3D /* KeyValueSyncBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */; };
 		322EE74CBED492693B92AD59 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A440253D045A0896D94ADD0 /* TaskListView+Toolbar.swift */; };
 		365FDEE6823D7A114F3FB12A /* TaskListViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C018476BD91B73870244B9 /* TaskListViewProtocol.swift */; };
+		37AEF10712B3325BF9BC72E4 /* BackgroundClickMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */; };
 		3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
 		3D1F551A03B97ECF4E3DC8B0 /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06485DBE35B60868E14202A /* TaskRowView.swift */; };
 		3EDB6A9A30B4226C15E7F44D /* AppCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C46FC97E6DB6FF81AC5C22 /* AppCommands.swift */; };
@@ -198,6 +199,7 @@
 		E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
 		E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCommandBridge.swift; sourceTree = "<group>"; };
 		E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
+		F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundClickMonitor.swift; sourceTree = "<group>"; };
 		F1E998119283F784B9ADEE28 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
 		F416DD868A4C044F0D64F8D0 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; };
 		FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; };
@@ -248,6 +250,7 @@
 			isa = PBXGroup;
 			children = (
 				4C79ABB39A40D3E1828716C7 /* AppCommands.swift */,
+				F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */,
 				D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */,
 				9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */,
 				466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */,
@@ -602,6 +605,7 @@
 			files = (
 				99D17075DA3F00F52A18BB4D /* AccentColor.swift in Sources */,
 				B7CFDCA5EA48EDE1C768FA21 /* AppCommands.swift in Sources */,
+				37AEF10712B3325BF9BC72E4 /* BackgroundClickMonitor.swift in Sources */,
 				DB5FF6C1AA57D4C9BDDD50FD /* ClickableTextField.swift in Sources */,
 				E4BD761E34CBB84CE80F7F49 /* CloudKitErrorClassifier.swift in Sources */,
 				E429067963379F99DD184FED /* CloudKitSyncMonitor.swift in Sources */,

diff --git a/ListlessMac/Helpers/BackgroundClickMonitor.swift b/ListlessMac/Helpers/BackgroundClickMonitor.swift

@@ -0,0 +1,68 @@
+import AppKit
+import SwiftUI
+
+/// Monitors for mouse clicks in empty scroll view space (below content).
+///
+/// SwiftUI's `.contentShape(Rectangle()).onTapGesture` on a `ScrollView`
+/// does not reliably fire for clicks in the area below the document view
+/// on macOS, because `NSScrollView`'s clip view handles the event before
+/// SwiftUI's gesture system. This representable installs a local event
+/// monitor that detects those clicks and forwards them to a handler.
+struct BackgroundClickMonitor: NSViewRepresentable {
+    let onClick: () -> Void
+
+    func makeNSView(context: Context) -> ClickMonitorNSView {
+        let view = ClickMonitorNSView()
+        view.onClick = onClick
+        return view
+    }
+
+    func updateNSView(_ nsView: ClickMonitorNSView, context: Context) {
+        nsView.onClick = onClick
+    }
+}
+
+final class ClickMonitorNSView: NSView {
+    var onClick: (() -> Void)?
+    private var monitor: Any?
+
+    override func viewDidMoveToWindow() {
+        super.viewDidMoveToWindow()
+        if window != nil {
+            installMonitor()
+        } else {
+            removeMonitor()
+        }
+    }
+
+    private func installMonitor() {
+        guard monitor == nil else { return }
+        monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) {
+            [weak self] event in
+            self?.handleClick(event)
+            return event
+        }
+    }
+
+    private func removeMonitor() {
+        if let monitor {
+            NSEvent.removeMonitor(monitor)
+            self.monitor = nil
+        }
+    }
+
+    private func handleClick(_ event: NSEvent) {
+        guard let window, event.window == window else { return }
+
+        let pointInSelf = convert(event.locationInWindow, from: nil)
+        guard bounds.contains(pointInSelf) else { return }
+
+        guard let hitView = window.contentView?.hitTest(event.locationInWindow)
+        else { return }
+
+        if hitView is NSClipView {
+            onClick?()
+        }
+    }
+
+}

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

@@ -157,6 +157,10 @@ struct TaskListView: View, TaskListViewProtocol {
         if fState.selectedTaskID == draftID(for: placement) {
             fState.selectedTaskID = nil
         }
+        // Resign AppKit first responder explicitly — SwiftUI's @FocusState
+        // and AppKit's responder chain are parallel systems, so setting
+        // focusedField alone may not dismiss the NSTextField.
+        NSApp.keyWindow?.makeFirstResponder(nil)
         focusedField = nil
     }
 
@@ -164,6 +168,7 @@ struct TaskListView: View, TaskListViewProtocol {
 
     var body: some View {
         ScrollView {
+          ScrollViewReader { scrollProxy in
             VStack(alignment: .leading, spacing: vStackSpacing) {
                 ForEach(Array(displayActiveTasks.enumerated()), id: \.element.id) { index, task in
                     let taskID = task.id
@@ -303,6 +308,7 @@ struct TaskListView: View, TaskListViewProtocol {
                                 .stroke(accentColor.opacity(0.40), lineWidth: 2)
                         }
                     }
+                    .id(draftAppendRowID)
                 }
 
                 ForEach(completedTasks) { task in
@@ -327,6 +333,25 @@ struct TaskListView: View, TaskListViewProtocol {
                     onPerform: { commitCurrentDrag() }
                 )
             )
+            .onChange(of: focusedFieldBinding) { _, newValue in
+                if case .task(let id) = (newValue ?? fState.focusedField),
+                    draggedTaskID == nil,
+                    id != draftPrependRowID
+                {
+                    withAnimation {
+                        scrollProxy.scrollTo(id)
+                    }
+                }
+            }
+            .onChange(of: fState.selectedTaskID) { _, newID in
+                if let newID, draggedTaskID == nil {
+                    guard newID != draftPrependRowID else { return }
+                    withAnimation {
+                        scrollProxy.scrollTo(newID)
+                    }
+                }
+            }
+          }
         }
         .onDrop(
             of: [UTType.text],
@@ -335,9 +360,10 @@ struct TaskListView: View, TaskListViewProtocol {
                 onPerform: { commitCurrentDrag() }
             )
         )
-        .contentShape(Rectangle())
-        .onTapGesture {
-            handleBackgroundTap()
+        .background {
+            BackgroundClickMonitor {
+                handleBackgroundTap()
+            }
         }
         .overlay {
             if isCompletelyEmpty {