diff --git a/CHANGELOG.md b/CHANGELOG.md index e6524d9..dcf38c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Restored the task search/filter bar in the popover, lost in the AppKit rewrite: real-time title/notes filtering, result count, matching subtasks shown with their parents, and an auto-expanded Completed section during search. - Added Homebrew tap installation instructions and release maintenance notes. - Added an opt-in testing window UI mode for local AppKit interaction outside the menu bar popover. +- The mouse cursor now turns into a pointing hand over the task completion circle, in both the task list and the task edit screen. - Added a right-click Delete action for task rows in the popover task list. - Added seeded task data for testing-window verification. - Added a Long Subtasks testing-window list with a parent task containing 12 subtasks, including several completed subtasks. diff --git a/TaskMenu.xcodeproj/project.pbxproj b/TaskMenu.xcodeproj/project.pbxproj index d88919a..3f08a49 100644 --- a/TaskMenu.xcodeproj/project.pbxproj +++ b/TaskMenu.xcodeproj/project.pbxproj @@ -43,6 +43,7 @@ C1EA9BDD5C59238997F833F1 /* TestingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9141316E1D908A6C099CF691 /* TestingWindowController.swift */; }; C3C41DC18427B14CEA020FD6 /* SettingsLaunchAtLoginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF81FBE51E0B1CC80B1A5FD /* SettingsLaunchAtLoginTests.swift */; }; C4D74AEE4D4E0555EA0336A2 /* GitHubUpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC8DA7EF5FEF032B81469A3 /* GitHubUpdateChecker.swift */; }; + CE62B34C489FAB410B59EF62 /* TaskMenuActionButtonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33B8E5D270E820D7C6070D4D /* TaskMenuActionButtonTests.swift */; }; D321CE95DA3DC0D6090CD619 /* MockURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB598B8D34C0A014D7826A63 /* MockURLProtocol.swift */; }; D75722B06B77536D032C2FCD /* DueDateNotificationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE4010667E3A28546894589 /* DueDateNotificationServiceTests.swift */; }; DE51E84794E46D07C1401DE6 /* TaskList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 633899C51990EF71D24F5C89 /* TaskList.swift */; }; @@ -78,6 +79,7 @@ 246C75CF9B4CE92C49A9A967 /* TaskListContentAppKitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListContentAppKitView.swift; sourceTree = ""; }; 2667803C667BBD21DCCCD029 /* TaskListAppKitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListAppKitViewController.swift; sourceTree = ""; }; 2C84C4C64E2BF982920144CA /* TaskListControlsAppKitViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListControlsAppKitViews.swift; sourceTree = ""; }; + 33B8E5D270E820D7C6070D4D /* TaskMenuActionButtonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskMenuActionButtonTests.swift; sourceTree = ""; }; 3F2D65032E1F2A015F67B6D0 /* TaskPopoverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPopoverViewController.swift; sourceTree = ""; }; 3FC8DA7EF5FEF032B81469A3 /* GitHubUpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubUpdateChecker.swift; sourceTree = ""; }; 4041A68FD32907E3B89B8F8B /* MenuBarWindowGlassSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarWindowGlassSupport.swift; sourceTree = ""; }; @@ -90,11 +92,11 @@ 62C9B2D2EE1B7668B9245DBE /* TaskDetailViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskDetailViewTests.swift; sourceTree = ""; }; 633899C51990EF71D24F5C89 /* TaskList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskList.swift; sourceTree = ""; }; 63E868E0B0520F6ABD40D350 /* GoogleAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleAuthService.swift; sourceTree = ""; }; + 66948CA865C6CDB97011D782 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; 68060F2FA94B23E37273F003 /* MenuBarWindowChromeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarWindowChromeTests.swift; sourceTree = ""; }; 68CD93FC69779BFB08340F08 /* TaskMenu.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TaskMenu.entitlements; sourceTree = ""; }; 6E4CD6808B2587AD2F8F9563 /* TaskItemModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItemModelTests.swift; sourceTree = ""; }; 77DBAAB7CC102C6006686981 /* MetricKitPayloadStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricKitPayloadStoreTests.swift; sourceTree = ""; }; - 7B9B96C944BC0E6F55E3D791 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; 7DF81FBE51E0B1CC80B1A5FD /* SettingsLaunchAtLoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLaunchAtLoginTests.swift; sourceTree = ""; }; 85062E7D51749F3ED4FD688D /* AppKitTaskUIHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKitTaskUIHelpers.swift; sourceTree = ""; }; 8C8C52C19BDA6F8F2D3C638F /* AppIcon.svg */ = {isa = PBXFileReference; path = AppIcon.svg; sourceTree = ""; }; @@ -122,15 +124,6 @@ /* End PBXFileReference section */ /* Begin PBXGroup section */ - 02C7954B6F0AE7D4B33301DC /* TaskMenu */ = { - isa = PBXGroup; - children = ( - 7B9B96C944BC0E6F55E3D791 /* Config.xcconfig */, - ); - name = TaskMenu; - path = .; - sourceTree = ""; - }; 141A484087CC4923B5F2371C /* Resources */ = { isa = PBXGroup; children = ( @@ -176,6 +169,15 @@ path = Models; sourceTree = ""; }; + 82BE47CB8A813E4BEDC55BD9 /* pointing-hand-cursor */ = { + isa = PBXGroup; + children = ( + 66948CA865C6CDB97011D782 /* Config.xcconfig */, + ); + name = "pointing-hand-cursor"; + path = .; + sourceTree = ""; + }; A34BA795C75683448AFABACA /* TaskMenu */ = { isa = PBXGroup; children = ( @@ -237,6 +239,7 @@ 62C9B2D2EE1B7668B9245DBE /* TaskDetailViewTests.swift */, 6E4CD6808B2587AD2F8F9563 /* TaskItemModelTests.swift */, A2191A69341B04C760925B55 /* TaskListViewTests.swift */, + 33B8E5D270E820D7C6070D4D /* TaskMenuActionButtonTests.swift */, 9C0DB3189CC6045A5C0A230F /* TaskMenuAppTests.swift */, ADED99C71B9C470D7D37974C /* TestDueDateNotificationService.swift */, ); @@ -246,8 +249,8 @@ E7776CF5C61A2557700CB1FE = { isa = PBXGroup; children = ( + 82BE47CB8A813E4BEDC55BD9 /* pointing-hand-cursor */, A34BA795C75683448AFABACA /* TaskMenu */, - 02C7954B6F0AE7D4B33301DC /* TaskMenu */, C53DB1319E1111ADB350C841 /* TaskMenuTests */, 1E011DB940E268BD3223C95C /* Products */, ); @@ -397,6 +400,7 @@ F583510B7875405F768BADD3 /* TaskDetailViewTests.swift in Sources */, 0FD718B29CF8183561A6F550 /* TaskItemModelTests.swift in Sources */, B1E9E90C4184BDD3DED97A2B /* TaskListViewTests.swift in Sources */, + CE62B34C489FAB410B59EF62 /* TaskMenuActionButtonTests.swift in Sources */, 44D8CA5D6D124CF97A57EF1D /* TaskMenuAppTests.swift in Sources */, 44A6F4A2E806FF1D44F0E19B /* TestDueDateNotificationService.swift in Sources */, ); @@ -436,7 +440,7 @@ }; 6EF47391B5EF770AC931DC3F /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7B9B96C944BC0E6F55E3D791 /* Config.xcconfig */; + baseConfigurationReference = 66948CA865C6CDB97011D782 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -524,7 +528,7 @@ }; A734F39AA9B691088E02AEFF /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7B9B96C944BC0E6F55E3D791 /* Config.xcconfig */; + baseConfigurationReference = 66948CA865C6CDB97011D782 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; diff --git a/TaskMenu/Views/AppKitTaskUIHelpers.swift b/TaskMenu/Views/AppKitTaskUIHelpers.swift index 0b62f09..8d29a69 100644 --- a/TaskMenu/Views/AppKitTaskUIHelpers.swift +++ b/TaskMenu/Views/AppKitTaskUIHelpers.swift @@ -115,6 +115,13 @@ final class TaskMenuAppStateObserver { final class TaskMenuActionButton: NSButton { var onPress: (() -> Void)? var onHoverChanged: ((Bool) -> Void)? + /// Shows the pointing-hand cursor while the mouse is over the button. + var usesPointingHandCursor = false { + didSet { + guard usesPointingHandCursor != oldValue else { return } + updateTrackingAreas() + } + } private var hoverTrackingArea: NSTrackingArea? init( @@ -160,10 +167,14 @@ final class TaskMenuActionButton: NSButton { removeTrackingArea(hoverTrackingArea) self.hoverTrackingArea = nil } - guard onHoverChanged != nil else { return } + guard onHoverChanged != nil || usesPointingHandCursor else { return } + var options: NSTrackingArea.Options = [.activeAlways, .mouseEnteredAndExited, .inVisibleRect] + if usesPointingHandCursor { + options.insert(.cursorUpdate) + } let area = NSTrackingArea( rect: bounds, - options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect], + options: options, owner: self, userInfo: nil ) @@ -171,6 +182,14 @@ final class TaskMenuActionButton: NSButton { addTrackingArea(area) } + override func cursorUpdate(with event: NSEvent) { + guard usesPointingHandCursor, isEnabled else { + super.cursorUpdate(with: event) + return + } + NSCursor.pointingHand.set() + } + override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) onHoverChanged?(true) @@ -178,6 +197,9 @@ final class TaskMenuActionButton: NSButton { override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) + if usesPointingHandCursor { + NSCursor.arrow.set() + } onHoverChanged?(false) } } diff --git a/TaskMenu/Views/README.md b/TaskMenu/Views/README.md index c7e7b64..5977f2c 100644 --- a/TaskMenu/Views/README.md +++ b/TaskMenu/Views/README.md @@ -4,7 +4,7 @@ Views render the AppKit menu-bar popover and settings UI. Keep business behavior ## Files -- `AppKitTaskUIHelpers.swift` - shared AppKit controls, SF Symbol helpers, layout pinning, hover handling, menu actions, and Observation glue. +- `AppKitTaskUIHelpers.swift` - shared AppKit controls, SF Symbol helpers, layout pinning, hover handling, opt-in pointing-hand cursor, menu actions, and Observation glue. - `TaskPopoverViewController.swift` - signed-out, initial-loading, signed-in task list, bottom error strip, popover sizing, settings handoff, and popover surface styling. - `TaskListAppKitViewController.swift` - task-list/detail coordination with an animated push/pop slide between the list page and the edit screen, list picker routing, search bar wiring, local list disclosure state, and `AppState` mutation wiring. - `TaskListControlsAppKitViews.swift` - list picker, refresh spinner, overflow menu, search bar, and quick-add field AppKit views. diff --git a/TaskMenu/Views/TaskDetailAppKitViewController.swift b/TaskMenu/Views/TaskDetailAppKitViewController.swift index babc520..2a2fd7a 100644 --- a/TaskMenu/Views/TaskDetailAppKitViewController.swift +++ b/TaskMenu/Views/TaskDetailAppKitViewController.swift @@ -606,6 +606,7 @@ private final class TaskDetailSubtaskRow: NSView { onPress: onToggle ) toggle.contentTintColor = task.isCompleted ? .controlAccentColor : .secondaryLabelColor + toggle.usesPointingHandCursor = true NSLayoutConstraint.activate([ toggle.widthAnchor.constraint(equalToConstant: 22), toggle.heightAnchor.constraint(equalToConstant: 22) diff --git a/TaskMenu/Views/TaskListContentAppKitView.swift b/TaskMenu/Views/TaskListContentAppKitView.swift index c678b35..bcd7b0e 100644 --- a/TaskMenu/Views/TaskListContentAppKitView.swift +++ b/TaskMenu/Views/TaskListContentAppKitView.swift @@ -1085,6 +1085,7 @@ private final class TaskOutlineTaskCellView: NSTableCellView { } checkButton.contentTintColor = entry.task.isCompleted ? .controlAccentColor : .secondaryLabelColor checkButton.refusesFirstResponder = true + checkButton.usesPointingHandCursor = true NSLayoutConstraint.activate([ checkButton.widthAnchor.constraint(equalToConstant: 26), checkButton.heightAnchor.constraint(lessThanOrEqualToConstant: 24) diff --git a/TaskMenuTests/README.md b/TaskMenuTests/README.md index 3453b6d..42f1c73 100644 --- a/TaskMenuTests/README.md +++ b/TaskMenuTests/README.md @@ -15,6 +15,7 @@ Unit tests cover model behavior, app state, services, pure view helpers, and app - `GitHubUpdateCheckerTests.swift` - semantic-version parsing, GitHub release decoding, update-check persistence, throttling, launch-alert suppression, and AppState update outcomes. - `DateFormattingTests.swift` - RFC 3339, Google due-date, display, and relative-date behavior. - `TaskListViewTests.swift`, `TaskDetailViewTests.swift`, and `MenuBarWindowChromeTests.swift` - shared task presentation helpers and AppKit glass/window behavior. +- `TaskMenuActionButtonTests.swift` - pointing-hand cursor opt-in on `TaskMenuActionButton` (cursor tracking areas and `cursorUpdate` behavior). - `MetricKitPayloadStoreTests.swift` - local payload persistence. - `TaskMenuAppTests.swift` - app/app-delegate construction seams, launch UI mode parsing, the automatic update-check loop, and update-alert choice mapping. - `SettingsLaunchAtLoginTests.swift` - the pure launch-at-login status decision (`requiresApproval` notice) behind the Settings toggle. diff --git a/TaskMenuTests/TaskMenuActionButtonTests.swift b/TaskMenuTests/TaskMenuActionButtonTests.swift new file mode 100644 index 0000000..a869ff9 --- /dev/null +++ b/TaskMenuTests/TaskMenuActionButtonTests.swift @@ -0,0 +1,60 @@ +import AppKit +import XCTest +@testable import TaskMenu + +@MainActor +final class TaskMenuActionButtonTests: XCTestCase { + private func makeMouseMovedEvent() throws -> NSEvent { + let event = NSEvent.mouseEvent( + with: .mouseMoved, + location: NSPoint(x: 5, y: 5), + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: 0, + pressure: 0 + ) + return try XCTUnwrap(event) + } + + func testPointingHandCursorIsOptOutByDefault() { + let button = TaskMenuActionButton(symbolName: "circle") + button.frame = NSRect(x: 0, y: 0, width: 26, height: 24) + button.updateTrackingAreas() + + XCTAssertFalse(button.usesPointingHandCursor) + XCTAssertTrue(button.trackingAreas.allSatisfy { !$0.options.contains(.cursorUpdate) }) + } + + func testEnablingPointingHandCursorAddsCursorUpdateTracking() { + let button = TaskMenuActionButton(symbolName: "circle") + button.frame = NSRect(x: 0, y: 0, width: 26, height: 24) + + button.usesPointingHandCursor = true + + XCTAssertTrue(button.trackingAreas.contains { $0.options.contains(.cursorUpdate) }) + } + + func testCursorUpdateSetsPointingHandWhenEnabled() throws { + let button = TaskMenuActionButton(symbolName: "circle") + button.usesPointingHandCursor = true + defer { NSCursor.arrow.set() } + + NSCursor.arrow.set() + button.cursorUpdate(with: try makeMouseMovedEvent()) + + XCTAssertEqual(NSCursor.current, NSCursor.pointingHand) + } + + func testCursorUpdateLeavesDefaultCursorWhenDisabled() throws { + let button = TaskMenuActionButton(symbolName: "circle") + defer { NSCursor.arrow.set() } + + NSCursor.arrow.set() + button.cursorUpdate(with: try makeMouseMovedEvent()) + + XCTAssertNotEqual(NSCursor.current, NSCursor.pointingHand) + } +}