listless

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

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 } }