← All repositories

listless

A simple list app for Apple platforms

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


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

Get AppKit/SwiftUI integration working

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

Diffstat

MListless.xcodeproj/project.pbxproj | 23+++++++++++++++++++++++
MListless/Models/TaskStore.swift | 8+++++++-
MListless/Views/TaskListView.swift | 40++++++++++++++++++++++++++++++----------
MListless/Views/TaskRowView.swift | 60+++++++++++++++++++++---------------------------------------

4 files changed, 81 insertions(+), 50 deletions(-)

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

@@ -7,6 +7,7 @@
 	objects = {
 
 /* Begin PBXBuildFile section */
+		031B814FDF3F9B20E3602563 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 992AE558C5BBEE16D70B8FF4 /* README.md */; };
 		0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
 		15B71073767FB4766A6BA2BE /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */; };
 		3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
@@ -15,11 +16,13 @@
 		5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
 		614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; };
 		6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */; };
+		83378A0575640417ED41FC25 /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BB7ABCABA4EB67180A200 /* ClickableTextField.swift */; };
 		8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; };
 		8E680EC18B0339931E785F0C /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */; };
 		9041B7CED5298439BF7DC2C1 /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007D500D2EFF3E66B780ADE0 /* TaskRowView.swift */; };
 		91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
 		96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
+		978DABFA617B6EBD6FA179CD /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97A08C589AB65ED2CB26A092 /* ClickableTextField.swift */; };
 		A0AA8FD4C542E9AEB2437BC2 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
 		A8D13DD6196772ECA146CF2A /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */; };
 		C1BD61D6DFA687E9CB9ACA60 /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */; };
@@ -48,6 +51,7 @@
 		007D500D2EFF3E66B780ADE0 /* TaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowView.swift; sourceTree = "<group>"; };
 		01E141436176F83594E2F26B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
 		0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardNavigationModifier.swift; sourceTree = "<group>"; };
+		0F2BB7ABCABA4EB67180A200 /* ClickableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickableTextField.swift; sourceTree = "<group>"; };
 		126108860D7878DDC3BECC4B /* Listless iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "Listless iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
 		138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; };
 		1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; };
@@ -63,6 +67,8 @@
 		82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; };
 		9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
 		944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
+		97A08C589AB65ED2CB26A092 /* ClickableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickableTextField.swift; sourceTree = "<group>"; };
+		992AE558C5BBEE16D70B8FF4 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
 		9FBEB58DD41817F09B0EB9F0 /* Listless.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Listless.xcdatamodel; sourceTree = "<group>"; };
 		AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessiOSApp.swift; sourceTree = "<group>"; };
 		B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; };
@@ -112,6 +118,7 @@
 		58F917D865E0BDF4EF282306 /* Views */ = {
 			isa = PBXGroup;
 			children = (
+				97A08C589AB65ED2CB26A092 /* ClickableTextField.swift */,
 				82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */,
 				48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */,
 				81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */,
@@ -146,6 +153,7 @@
 			isa = PBXGroup;
 			children = (
 				F99C67C30185163A51DCD268 /* KeyboardNavigationTests.swift */,
+				992AE558C5BBEE16D70B8FF4 /* README.md */,
 			);
 			path = ListlessMacUITests;
 			sourceTree = "<group>";
@@ -161,6 +169,7 @@
 		D12ECC901ABED96B86CC85B5 /* Views */ = {
 			isa = PBXGroup;
 			children = (
+				0F2BB7ABCABA4EB67180A200 /* ClickableTextField.swift */,
 				D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */,
 				B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */,
 				EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */,
@@ -242,6 +251,7 @@
 			buildConfigurationList = 9FF482EB568895AF0B3F35CC /* Build configuration list for PBXNativeTarget "Listless macOS UITests" */;
 			buildPhases = (
 				34DA2E4A88D1AFCE78FCE279 /* Sources */,
+				ED7DBE75C614608E5D3B630F /* Resources */,
 			);
 			buildRules = (
 			);
@@ -300,6 +310,17 @@
 		};
 /* End PBXProject section */
 
+/* Begin PBXResourcesBuildPhase section */
+		ED7DBE75C614608E5D3B630F /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				031B814FDF3F9B20E3602563 /* README.md in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXResourcesBuildPhase section */
+
 /* Begin PBXSourcesBuildPhase section */
 		34DA2E4A88D1AFCE78FCE279 /* Sources */ = {
 			isa = PBXSourcesBuildPhase;
@@ -313,6 +334,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				83378A0575640417ED41FC25 /* ClickableTextField.swift in Sources */,
 				15B71073767FB4766A6BA2BE /* HoverCursorModifier.swift in Sources */,
 				D8EFF49E9156083D675D47F0 /* KeyboardNavigationModifier.swift in Sources */,
 				96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */,
@@ -332,6 +354,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				978DABFA617B6EBD6FA179CD /* ClickableTextField.swift in Sources */,
 				FEC96DAA2BF8BB5D6D8504EB /* HoverCursorModifier.swift in Sources */,
 				6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */,
 				5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */,

diff --git a/Listless/Models/TaskStore.swift b/Listless/Models/TaskStore.swift

@@ -60,6 +60,12 @@ final class TaskStore {
         save()
     }
 
+    func updateWithoutSaving(taskID: UUID, title: String) {
+        guard let task = findTask(id: taskID) else { return }
+        task.title = title
+        // Don't save - will be saved when editing ends
+    }
+
     func delete(taskID: UUID) {
         guard let task = findTask(id: taskID) else { return }
         context.delete(task)
@@ -101,7 +107,7 @@ final class TaskStore {
         }
     }
 
-    private func save() {
+    func save() {
         persistenceController.save()
     }
 }

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

@@ -47,7 +47,6 @@ struct TaskListView: View {
                         isEditing: editingTaskID == taskID,
                         focusedField: $focusedField,
                         onToggle: toggleCompletion(_:),
-                        onSubmit: handleSubmit(_:),
                         onTitleChange: updateTitle(_:_:),
                         onDelete: deleteTask(_:),
                         onSelect: { selectTask(taskID) },
@@ -70,7 +69,6 @@ struct TaskListView: View {
                         isEditing: editingTaskID == taskID,
                         focusedField: $focusedField,
                         onToggle: toggleCompletion(_:),
-                        onSubmit: handleSubmit(_:),
                         onTitleChange: updateTitle(_:_:),
                         onDelete: deleteTask(_:),
                         onSelect: { selectTask(taskID) },
@@ -182,6 +180,11 @@ struct TaskListView: View {
         completedTasks + displayActiveTasks
     }
 
+    private func isLastActiveTask(_ taskID: UUID) -> Bool {
+        guard let lastTask = activeTasks.last else { return false }
+        return lastTask.id == taskID
+    }
+
     private func createTaskAndFocus() {
         // Clear any lingering drag state
         draggedTaskID = nil
@@ -244,13 +247,10 @@ struct TaskListView: View {
         deleteTask(task)
     }
 
-    private func handleSubmit(_ task: TaskItem) {
-        createTaskAndFocus()
-    }
 
     private func updateTitle(_ task: TaskItem, _ title: String) {
         guard task.title != title else { return }
-        store.update(taskID: task.id, title: title)
+        store.updateWithoutSaving(taskID: task.id, title: title)
 
         // Clear the justCreated flag once user starts typing
         if task.id == justCreatedTaskID && !title.isEmpty {
@@ -344,6 +344,8 @@ struct TaskListView: View {
         }
         if let editingID = editingTaskID {
             endEditing(editingID)
+            // Keep the task selected
+            selectedTaskID = editingID
         }
         focusScrollView()
         return .handled
@@ -353,8 +355,10 @@ struct TaskListView: View {
 
     private func focusScrollView() {
         // Try clearing focus first, then setting to scrollView
+        print("🔵 focusScrollView called, setting focusedField = nil")
         focusedField = nil
         DispatchQueue.main.async {
+            print("🔵 focusScrollView async setting focusedField = .scrollView")
             self.focusedField = .scrollView
         }
     }
@@ -364,18 +368,34 @@ struct TaskListView: View {
     }
 
     private func startEditing(_ taskID: UUID) {
+        print("🟢 startEditing called for task \(taskID)")
+        selectedTaskID = taskID
         editingTaskID = taskID
         focusedField = .task(taskID)
+        print("🟢 startEditing set focusedField = .task(\(taskID))")
     }
 
     private func endEditing(_ taskID: UUID) {
+        // Save any pending changes
+        store.save()
+
+        // Delete if empty
+        deleteIfEmpty(taskID: taskID)
+
         // Only clear editingTaskID if it matches this task
-        guard editingTaskID == taskID else {
-            return
+        if editingTaskID == taskID {
+            editingTaskID = nil
         }
 
-        deleteIfEmpty(taskID: taskID)
-        editingTaskID = nil
+        // Focus management (same for Return key and clicking away)
+        if isLastActiveTask(taskID) {
+            // Last task: create new task below it
+            createTaskAndFocus()
+        } else {
+            // Not last task: keep task selected, return to navigation mode
+            selectedTaskID = taskID
+            focusScrollView()
+        }
     }
 
     // MARK: - Drag and Drop

diff --git a/Listless/Views/TaskRowView.swift b/Listless/Views/TaskRowView.swift

@@ -6,7 +6,6 @@ struct TaskRowView: View {
     let isSelected: Bool
     let isEditing: Bool
     let onToggle: (TaskItem) -> Void
-    let onSubmit: (TaskItem) -> Void
     let onTitleChange: (TaskItem, String) -> Void
     let onDelete: (TaskItem) -> Void
     let onSelect: () -> Void
@@ -15,6 +14,7 @@ struct TaskRowView: View {
     @FocusState.Binding var focusedField: TaskListView.FocusField?
 
     @State private var editingTitle: String = ""
+    @State private var isCurrentlyEditing: Bool = false
 
     init(
         task: TaskItem,
@@ -23,7 +23,6 @@ struct TaskRowView: View {
         isEditing: Bool = false,
         focusedField: FocusState<TaskListView.FocusField?>.Binding,
         onToggle: @escaping (TaskItem) -> Void,
-        onSubmit: @escaping (TaskItem) -> Void,
         onTitleChange: @escaping (TaskItem, String) -> Void,
         onDelete: @escaping (TaskItem) -> Void,
         onSelect: @escaping () -> Void,
@@ -35,7 +34,6 @@ struct TaskRowView: View {
         self.isSelected = isSelected
         self.isEditing = isEditing
         self.onToggle = onToggle
-        self.onSubmit = onSubmit
         self.onTitleChange = onTitleChange
         self.onDelete = onDelete
         self.onSelect = onSelect
@@ -57,35 +55,21 @@ struct TaskRowView: View {
             .accessibilityIdentifier("task-checkbox")
             .accessibilityValue(task.isCompleted ? "checkmark.circle.fill" : "circle")
 
-            if isEditing {
-                TextField("New task", text: $editingTitle, axis: .vertical)
-                    .textFieldStyle(.plain)
-                    .font(.body)
-                    .lineLimit(1...5)
-                    .focused($focusedField, equals: .task(taskID))
-                    .onSubmit {
-                        onSubmit(task)
+            ClickableTextField(
+                text: $editingTitle,
+                isCompleted: task.isCompleted,
+                onEditingChanged: { editing in
+                    isCurrentlyEditing = editing
+                    if editing {
+                        onStartEdit()
+                    } else {
+                        onEndEdit()
                     }
-                    .frame(maxWidth: .infinity, alignment: .leading)
-                    .disabled(task.isCompleted)
-                    .accessibilityIdentifier("task-textfield")
-            } else {
-                HStack(spacing: 0) {
-                    Text(task.title.isEmpty ? "New task" : task.title)
-                        .font(.body)
-                        .foregroundStyle(task.title.isEmpty ? .secondary : (task.isCompleted ? .secondary : .primary))
-                        .strikethrough(task.isCompleted, color: .secondary)
-                        .modifier(TextHoverModifier(isCompleted: task.isCompleted))
-                        .onTapGesture {
-                            if !task.isCompleted {
-                                onStartEdit()
-                            }
-                        }
-                        .accessibilityIdentifier("task-text-\(task.title)")
-
-                    Spacer(minLength: 0)
                 }
-            }
+            )
+            .focused($focusedField, equals: .task(taskID))
+            .frame(maxWidth: .infinity, alignment: .leading)
+            .accessibilityIdentifier(isCurrentlyEditing ? "task-textfield" : "task-text-\(task.title)")
         }
         .padding(.vertical, 8)
         .padding(.horizontal, 16)
@@ -118,17 +102,15 @@ struct TaskRowView: View {
             guard !task.isCompleted else { return }
             onTitleChange(task, editingTitle)
         }
-        .onChange(of: isEditing) { _, newValue in
-            if newValue {
-                editingTitle = task.title
+        .onChange(of: task.title) { _, newValue in
+            // Keep editingTitle in sync with task.title when not editing
+            if !isCurrentlyEditing {
+                editingTitle = newValue
             }
         }
-        .onChange(of: focusedField) { oldValue, newValue in
-            if newValue == .task(taskID) && oldValue != .task(taskID) {
-                onSelect()
-            } else if oldValue == .task(taskID) && newValue != .task(taskID) {
-                onEndEdit()
-            }
+        .onAppear {
+            // Initialize editingTitle
+            editingTitle = task.title
         }
     }