listless

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

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 {