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:
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 {