← All repositories

listless

A simple list app for Apple platforms

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


commit 5440590cea2a0f4a76416299f58be67d92b4a362
parent 554b423b2765660f82247aa6d88f7ec9fecb9b81
Author: Michael Camilleri <[email protected]>
Date:   Thu, 12 Feb 2026 09:58:18 +0900

Improving simultaneous support for gestures and tap to focus

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

Diffstat

MListless.xcodeproj/project.pbxproj | 8++++++++
MListless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme | 12+++---------
MListless/Views/TaskListView.swift | 55+++++++++++++++++++++++++++++++++++++------------------
AListlessiOS/Views/TaskRowSwipeGesture.swift | 331+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessiOS/Views/TaskRowTapGesture.swift | 39+++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/TaskRowView.swift | 131++++++++++++++++++++++++-------------------------------------------------------

6 files changed, 457 insertions(+), 119 deletions(-)

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

@@ -20,6 +20,7 @@
 		614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; };
 		69D1909CB6D2368CF90065C7 /* ColorExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBDF292392702661DBB94D06 /* ColorExtensions.swift */; };
 		6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */; };
+		7B2CD636BA3B63A586F93E31 /* TaskRowSwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44C16D36971A140364159FB9 /* TaskRowSwipeGesture.swift */; };
 		7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */; };
 		83378A0575640417ED41FC25 /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BB7ABCABA4EB67180A200 /* ClickableTextField.swift */; };
 		8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; };
@@ -35,6 +36,7 @@
 		D878CD3A552C6A9685A30AA8 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */; };
 		D8EFF49E9156083D675D47F0 /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */; };
 		D9DA553485AD0CA28D5BE0C8 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */; };
+		DAB0D2F8881B234DDE2B0BF6 /* TaskRowTapGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8829E89B1F50224FCAE10F84 /* TaskRowTapGesture.swift */; };
 		DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */; };
 		DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
 		ECD5E7EA05AE1C00B38C939E /* TaskStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */; };
@@ -65,6 +67,7 @@
 		2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreOrderingTests.swift; sourceTree = "<group>"; };
 		3313FEDB101EECA4B344EEF4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
 		431B0EA72D590AF0CC868515 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; };
+		44C16D36971A140364159FB9 /* TaskRowSwipeGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowSwipeGesture.swift; sourceTree = "<group>"; };
 		48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; };
 		4FC64B9F9370041BEDBD1E14 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
 		537A913AC421BAEF60D26D9C /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; };
@@ -75,6 +78,7 @@
 		7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
 		81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
 		82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; };
+		8829E89B1F50224FCAE10F84 /* TaskRowTapGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowTapGesture.swift; sourceTree = "<group>"; };
 		917597025B3D5D18E33982D3 /* ColorExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtensions.swift; sourceTree = "<group>"; };
 		9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
 		93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; };
@@ -137,6 +141,8 @@
 				81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */,
 				93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */,
 				1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */,
+				44C16D36971A140364159FB9 /* TaskRowSwipeGesture.swift */,
+				8829E89B1F50224FCAE10F84 /* TaskRowTapGesture.swift */,
 				199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */,
 			);
 			path = Views;
@@ -381,6 +387,8 @@
 				D9DA553485AD0CA28D5BE0C8 /* TaskListView+Toolbar.swift in Sources */,
 				42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */,
 				D6B3DBDD6A3F0A6E166CFFD5 /* TaskRowDragGesture.swift in Sources */,
+				7B2CD636BA3B63A586F93E31 /* TaskRowSwipeGesture.swift in Sources */,
+				DAB0D2F8881B234DDE2B0BF6 /* TaskRowTapGesture.swift in Sources */,
 				7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */,
 				91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */,
 			);

diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme

@@ -1,11 +1,10 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
    LastUpgradeVersion = "1430"
-   version = "1.7">
+   version = "1.3">
    <BuildAction
       parallelizeBuildables = "YES"
-      buildImplicitDependencies = "YES"
-      runPostActionsOnFailure = "NO">
+      buildImplicitDependencies = "YES">
       <BuildActionEntries>
          <BuildActionEntry
             buildForTesting = "YES"
@@ -27,8 +26,7 @@
       buildConfiguration = "Debug"
       selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
-      shouldUseLaunchSchemeArgsEnv = "YES"
-      onlyGenerateCoverageForSpecifiedTargets = "NO">
+      shouldUseLaunchSchemeArgsEnv = "YES">
       <MacroExpansion>
          <BuildableReference
             BuildableIdentifier = "primary"
@@ -61,8 +59,6 @@
             ReferencedContainer = "container:Listless.xcodeproj">
          </BuildableReference>
       </BuildableProductRunnable>
-      <CommandLineArguments>
-      </CommandLineArguments>
    </LaunchAction>
    <ProfileAction
       buildConfiguration = "Release"
@@ -80,8 +76,6 @@
             ReferencedContainer = "container:Listless.xcodeproj">
          </BuildableReference>
       </BuildableProductRunnable>
-      <CommandLineArguments>
-      </CommandLineArguments>
    </ProfileAction>
    <AnalyzeAction
       buildConfiguration = "Debug">

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

@@ -22,6 +22,7 @@ struct TaskListView: View {
     @State var selectedTaskID: UUID?
     @State private var refreshID = UUID()
     @State private var draggedTaskID: UUID?
+    @State private var swipingTaskID: UUID?
     @State private var visualOrder: [UUID]?
     @State private var pendingFocus: FocusField?
 
@@ -40,17 +41,16 @@ struct TaskListView: View {
                         index: index,
                         totalTasks: displayActiveTasks.count,
                         isSelected: selectedTaskID == taskID,
-                        isEditing: editingTaskID == taskID,
                         focusedField: $focusedField,
-                        onToggle: toggleCompletion(_:),
-                        onTitleChange: updateTitle(_:_:),
-                        onDelete: deleteTask(_:),
-                        onSelect: selectTask(_:),
-                        onStartEdit: startEditing(_:),
-                        onEndEdit: endEditing(_:shouldCreateNewTask:)
+                        onToggle: { toggleCompletion($0) },
+                        onTitleChange: { updateTitle($0, $1) },
+                        onDelete: { deleteTask($0) },
+                        onSelect: { selectTask($0) },
+                        onStartEdit: { startEditing($0) },
+                        onEndEdit: { endEditing($0, shouldCreateNewTask: $1) }
                     )
                     .taskDragGesture(
-                        isActive: !task.isCompleted,
+                        isActive: !task.isCompleted && swipingTaskID == nil,
                         taskID: task.id,
                         onDragStart: { startDrag(taskID: task.id) }
                     )
@@ -116,14 +116,11 @@ struct TaskListView: View {
                         task: task,
                         taskID: taskID,
                         isSelected: selectedTaskID == taskID,
-                        isEditing: editingTaskID == taskID,
                         focusedField: $focusedField,
-                        onToggle: toggleCompletion(_:),
-                        onTitleChange: updateTitle(_:_:),
-                        onDelete: deleteTask(_:),
-                        onSelect: selectTask(_:),
-                        onStartEdit: startEditing(_:),
-                        onEndEdit: endEditing(_:shouldCreateNewTask:)
+                        onToggle: { toggleCompletion($0) },
+                        onTitleChange: { updateTitle($0, $1) },
+                        onDelete: { deleteTask($0) },
+                        onSelect: { selectTask($0) }
                     )
                 }
             }
@@ -297,6 +294,20 @@ struct TaskListView: View {
         }
     }
 
+    private func handleSwipeComplete(_ taskID: UUID) {
+        guard let task = tasks.first(where: { $0.id == taskID }) else { return }
+        toggleCompletion(task)
+    }
+
+    private func handleSwipeDelete(_ taskID: UUID) {
+        guard let task = tasks.first(where: { $0.id == taskID }) else { return }
+        deleteTask(task)
+    }
+
+    private func handleSwipeActiveChanged(_ taskID: UUID, _ isActive: Bool) {
+        swipingTaskID = isActive ? taskID : nil
+    }
+
     private func selectTask(_ taskID: UUID) {
         selectedTaskID = taskID
     }
@@ -443,9 +454,17 @@ struct TaskListView: View {
             print("🟢 endEditing() creating new task")
             createNewTask()
         } else {
-            print("🟢 endEditing() keeping task selected, returning to navigation")
-            selectedTaskID = taskID
-            // Focus repair will set to .scrollView if needed
+            print("🟢 endEditing() done, selection unchanged")
+            // Do NOT restore selectedTaskID = taskID here.
+            //
+            // On macOS: selectedTaskID is already taskID (set by startEditing when editing
+            // began), and AppKit fires controlTextDidEndEditing synchronously before any
+            // SwiftUI tap gesture handler runs, so nothing has changed it yet. The line
+            // would be a no-op.
+            //
+            // On iOS: everything flows through onChange(of: focusedField), so onStartEdit
+            // on the new row may have already updated selectedTaskID before this fires.
+            // Restoring it here would overwrite the new selection.
         }
 
         print("🟢 endEditing() completed, final focus: \(String(describing: focusedField))")

diff --git a/ListlessiOS/Views/TaskRowSwipeGesture.swift b/ListlessiOS/Views/TaskRowSwipeGesture.swift

@@ -0,0 +1,331 @@
+import SwiftUI
+import UIKit
+
+extension View {
+    func taskSwipeGesture(
+        isActive: Bool,
+        isEditing: Bool,
+        isDragging: Bool,
+        swipeOffset: Binding<CGFloat>,
+        swipeDirection: Binding<TaskRowSwipeGesture.SwipeDirection>,
+        isTriggered: Binding<Bool>,
+        onComplete: @escaping () -> Void,
+        onDelete: @escaping () -> Void,
+        onSwipeActiveChanged: @escaping (Bool) -> Void = { _ in }
+    ) -> some View {
+        self.modifier(
+            TaskRowSwipeGesture(
+                isActive: isActive,
+                isEditing: isEditing,
+                isDragging: isDragging,
+                swipeOffset: swipeOffset,
+                swipeDirection: swipeDirection,
+                isTriggered: isTriggered,
+                onComplete: onComplete,
+                onDelete: onDelete,
+                onSwipeActiveChanged: onSwipeActiveChanged
+            ))
+    }
+}
+
+struct TaskRowSwipeGesture: ViewModifier {
+    let isActive: Bool
+    let isEditing: Bool
+    let isDragging: Bool
+    @Binding var swipeOffset: CGFloat
+    @Binding var swipeDirection: SwipeDirection
+    @Binding var isTriggered: Bool
+    let onComplete: () -> Void
+    let onDelete: () -> Void
+    let onSwipeActiveChanged: (Bool) -> Void
+
+    enum SwipeDirection: Equatable {
+        case left
+        case right
+        case none
+    }
+
+    private let completeThreshold: CGFloat = 40  // Pixels to swipe right before triggering complete
+    private let deleteThreshold: CGFloat = 80  // Pixels to swipe left before triggering delete
+    private let horizontalBufferPt: CGFloat = 10  // Horizontal movement must exceed vertical by this amount
+    private let offsetDamping: CGFloat = 0.9  // Damping factor for responsive feel
+
+    func body(content: Content) -> some View {
+        return ZStack(alignment: .leading) {
+            // Background stays in place
+            swipeBackground
+                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
+                .allowsHitTesting(false)
+
+            // Only the content moves
+            content
+                .offset(x: swipeOffset)
+                .animation(.spring(response: 0.3, dampingFraction: 0.8), value: swipeOffset)
+                .contentShape(Rectangle())
+                .background {
+                    #if canImport(UIKit)
+                        SwipePanGestureInstaller(
+                            isEnabled: isActive && !isEditing && !isDragging,
+                            onChanged: { translation in
+                                handleDragChanged(
+                                    horizontalTranslation: translation.x,
+                                    verticalTranslation: abs(translation.y)
+                                )
+                            },
+                            onEnded: {
+                                handleDragEnded()
+                            }
+                        )
+                    #endif
+                }
+        }
+        .onDisappear {
+            resetSwipeState()
+        }
+    }
+
+    @ViewBuilder
+    private var swipeBackground: some View {
+        if swipeDirection == .right {
+            // Complete action — plain green background
+            Color.green.opacity(backgroundOpacity(offset: swipeOffset))
+        } else if swipeDirection == .left {
+            // Delete action (red background, trash icon)
+            HStack {
+                Spacer()
+                Image(systemName: "trash.fill")
+                    .font(.system(size: 24))
+                    .foregroundStyle(.white)
+                    .padding(.trailing, 20)
+            }
+            .background(Color.red.opacity(backgroundOpacity(offset: swipeOffset)))
+        }
+    }
+
+    private func handleDragChanged(horizontalTranslation: CGFloat, verticalTranslation: CGFloat) {
+        // Require horizontal > vertical + buffer to activate swipe
+        guard abs(horizontalTranslation) > verticalTranslation + horizontalBufferPt else {
+            return
+        }
+
+        // Determine direction and notify that swipe is active
+        if swipeDirection == .none {
+            onSwipeActiveChanged(true)
+        }
+
+        if horizontalTranslation > 0 {
+            swipeDirection = .right
+        } else if horizontalTranslation < 0 {
+            swipeDirection = .left
+        }
+
+        // Update offset with damping
+        swipeOffset = horizontalTranslation * offsetDamping
+
+        // Track whether threshold is currently crossed — reversible until release
+        if swipeDirection == .right {
+            isTriggered = swipeOffset >= completeThreshold
+        } else if swipeDirection == .left {
+            isTriggered = abs(swipeOffset) >= deleteThreshold
+        }
+    }
+
+    private func handleDragEnded() {
+        if isTriggered {
+            if swipeDirection == .right {
+                // Complete: spring back and let SwiftUI animate the row to the completed section
+                triggerAction(action: onComplete)
+                withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+                    resetSwipeState()
+                }
+            } else {
+                // Delete: slide off screen
+                triggerAction(action: onDelete)
+                withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
+                    swipeOffset = -400
+                }
+            }
+        } else {
+            // Released below threshold — spring back with no action
+            withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
+                resetSwipeState()
+            }
+        }
+    }
+
+    private func triggerAction(action: @escaping () -> Void) {
+        isTriggered = true
+
+        // Trigger haptic feedback
+        let generator = UIImpactFeedbackGenerator(style: .medium)
+        generator.impactOccurred()
+
+        // Execute action after a brief delay to show visual feedback
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+            action()
+        }
+    }
+
+    private func resetSwipeState() {
+        if swipeDirection != .none {
+            onSwipeActiveChanged(false)
+        }
+        swipeOffset = 0
+        swipeDirection = .none
+        isTriggered = false
+    }
+
+    private func backgroundOpacity(offset: CGFloat) -> CGFloat {
+        let threshold = offset >= 0 ? completeThreshold : deleteThreshold
+        return min(abs(offset) / threshold, 1.0)
+    }
+}
+
+private struct SwipePanGestureInstaller: UIViewRepresentable {
+    let isEnabled: Bool
+    let onChanged: (CGPoint) -> Void
+    let onEnded: () -> Void
+
+    func makeUIView(context: Context) -> InstallerView {
+        let view = InstallerView()
+        view.onMoveToSuperview = { installerView in
+            context.coordinator.attach(to: hostView(for: installerView), marker: installerView)
+        }
+        return view
+    }
+
+    func updateUIView(_ uiView: InstallerView, context: Context) {
+        context.coordinator.isEnabled = isEnabled
+        context.coordinator.onChanged = onChanged
+        context.coordinator.onEnded = onEnded
+        context.coordinator.attach(to: hostView(for: uiView), marker: uiView)
+    }
+
+    static func dismantleUIView(_ uiView: InstallerView, coordinator: Coordinator) {
+        coordinator.detach()
+    }
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator(isEnabled: isEnabled, onChanged: onChanged, onEnded: onEnded)
+    }
+
+    private func hostView(for installerView: UIView) -> UIView? {
+        // Climb toward the row container (above the background host, below the ScrollView).
+        var current = installerView.superview
+        var candidate: UIView?
+
+        while let view = current {
+            if view is UIScrollView {
+                break
+            }
+            candidate = view
+            current = view.superview
+        }
+
+        return candidate ?? installerView.superview
+    }
+
+    final class InstallerView: UIView {
+        var onMoveToSuperview: ((UIView) -> Void)?
+
+        override func didMoveToSuperview() {
+            super.didMoveToSuperview()
+            onMoveToSuperview?(self)
+        }
+
+        override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
+            false
+        }
+    }
+
+        final class Coordinator: NSObject, UIGestureRecognizerDelegate {
+        var isEnabled: Bool
+        var onChanged: (CGPoint) -> Void
+        var onEnded: () -> Void
+
+        private weak var attachedView: UIView?
+        private weak var markerView: UIView?
+        private var panRecognizer: UIPanGestureRecognizer?
+
+        init(isEnabled: Bool, onChanged: @escaping (CGPoint) -> Void, onEnded: @escaping () -> Void) {
+            self.isEnabled = isEnabled
+            self.onChanged = onChanged
+            self.onEnded = onEnded
+        }
+
+        func attach(to view: UIView?, marker: UIView) {
+            markerView = marker
+
+            guard let view else {
+                detach()
+                return
+            }
+
+            if attachedView === view {
+                panRecognizer?.isEnabled = isEnabled
+                return
+            }
+
+            detach()
+
+            let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
+            pan.delegate = self
+            pan.cancelsTouchesInView = false
+            pan.delaysTouchesBegan = false
+            pan.maximumNumberOfTouches = 1
+            pan.isEnabled = isEnabled
+            view.addGestureRecognizer(pan)
+
+            attachedView = view
+            panRecognizer = pan
+        }
+
+        func detach() {
+            if let panRecognizer, let attachedView {
+                attachedView.removeGestureRecognizer(panRecognizer)
+            }
+            panRecognizer = nil
+            attachedView = nil
+            markerView = nil
+        }
+
+        @objc
+        private func handlePan(_ recognizer: UIPanGestureRecognizer) {
+            guard isEnabled else { return }
+            let translation = recognizer.translation(in: recognizer.view)
+
+            switch recognizer.state {
+            case .began, .changed:
+                onChanged(translation)
+            case .ended, .cancelled, .failed:
+                onEnded()
+            default:
+                break
+            }
+        }
+
+        func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
+            guard
+                isEnabled,
+                let pan = gestureRecognizer as? UIPanGestureRecognizer,
+                let attachedView,
+                let markerView
+            else {
+                return false
+            }
+
+            // Multiple row recognizers may be attached to a shared host view.
+            // Only allow the recognizer whose row contains the touch to begin.
+            let markerFrame = markerView.convert(markerView.bounds, to: attachedView)
+            let touchPoint = pan.location(in: attachedView)
+            return markerFrame.contains(touchPoint)
+        }
+
+        func gestureRecognizer(
+            _ gestureRecognizer: UIGestureRecognizer,
+            shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
+        ) -> Bool {
+            true
+        }
+    }
+}

diff --git a/ListlessiOS/Views/TaskRowTapGesture.swift b/ListlessiOS/Views/TaskRowTapGesture.swift

@@ -0,0 +1,39 @@
+import SwiftUI
+
+extension View {
+    func taskTapGesture(
+        onCheckboxTap: @escaping () -> Void,
+        onTextTap: @escaping () -> Void
+    ) -> some View {
+        self.modifier(
+            TaskRowTapGesture(
+                onCheckboxTap: onCheckboxTap,
+                onTextTap: onTextTap
+            ))
+    }
+}
+
+struct TaskRowTapGesture: ViewModifier {
+    let onCheckboxTap: () -> Void
+    let onTextTap: () -> Void
+
+    private let checkboxZoneWidth: CGFloat = 48  // Checkbox + padding area
+
+    func body(content: Content) -> some View {
+        content
+            .contentShape(Rectangle())
+            .onTapGesture(coordinateSpace: .local) { location in
+                handleTap(at: location)
+            }
+    }
+
+    private func handleTap(at location: CGPoint) {
+        if location.x <= checkboxZoneWidth {
+            // Tap in checkbox zone
+            onCheckboxTap()
+        } else {
+            // Tap in text zone - TextField will handle focus natively
+            onTextTap()
+        }
+    }
+}

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

@@ -3,10 +3,7 @@ import SwiftUI
 struct TaskRowView: View {
     let task: TaskItem
     let taskID: UUID
-    let index: Int
-    let totalTasks: Int
     let isSelected: Bool
-    let isEditing: Bool
     let onToggle: (TaskItem) -> Void
     let onTitleChange: (TaskItem, String) -> Void
     let onDelete: (TaskItem) -> Void
@@ -17,15 +14,9 @@ struct TaskRowView: View {
 
     @State private var editingTitle: String = ""
     @State private var isCurrentlyEditing: Bool = false
-    @State private var submittedViaReturn = false
-
-    private let horizontalPadding: CGFloat = 16
-    private let checkboxTextSpacing: CGFloat = 12
-    @ScaledMetric private var checkboxSize: CGFloat = 20
-
-    private var dividerInset: CGFloat {
-        horizontalPadding + checkboxSize + checkboxTextSpacing
-    }
+    @State private var swipeOffset: CGFloat = 0
+    @State private var swipeDirection: TaskRowSwipeGesture.SwipeDirection = .none
+    @State private var isSwipeTriggered: Bool = false
 
     init(
         task: TaskItem,
@@ -44,10 +35,7 @@ struct TaskRowView: View {
     ) {
         self.task = task
         self.taskID = taskID
-        self.index = index
-        self.totalTasks = totalTasks
         self.isSelected = isSelected
-        self.isEditing = isEditing
         self.onToggle = onToggle
         self.onTitleChange = onTitleChange
         self.onDelete = onDelete
@@ -62,94 +50,70 @@ struct TaskRowView: View {
             Button {
                 onToggle(task)
             } label: {
-                Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
-                    .foregroundStyle(task.isCompleted ? .secondary : .primary)
+                // When a right-swipe is past the threshold, preview the toggled state
+                let previewCompleted = isSwipeTriggered && swipeDirection == .right
+                    ? !task.isCompleted
+                    : task.isCompleted
+                Image(systemName: previewCompleted ? "checkmark.circle.fill" : "circle")
+                    .foregroundStyle(previewCompleted ? Color.secondary : Color.primary)
                     .font(.system(size: 17))
                     .fontWeight(.thin)
             }
             .buttonStyle(.borderless)
-            .accessibilityIdentifier("task-checkbox")
-            .accessibilityValue(task.isCompleted ? "checkmark.circle.fill" : "circle")
 
-            TextField("New task", text: $editingTitle, axis: .vertical)
-                .textFieldStyle(.plain)
-                .font(.body)
-                .lineLimit(1...5)
+            TextField("Task", text: $editingTitle)
                 .focused($focusedField, equals: .task(taskID))
-                .onSubmit {
-                    // Return key pressed - mark for new task creation
-                    submittedViaReturn = true
-                    focusedField = nil
-                }
+                .foregroundStyle(task.isCompleted ? Color.secondary : Color.primary)
+                .strikethrough(task.isCompleted, color: .secondary)
                 .disabled(task.isCompleted)
                 .frame(maxWidth: .infinity, alignment: .leading)
-                .accessibilityIdentifier(
-                    isCurrentlyEditing ? "task-textfield" : "task-text-\(task.title)")
         }
         .padding(.vertical, 8)
         .padding(.horizontal, 16)
         .frame(maxWidth: .infinity, alignment: .leading)
         .contentShape(Rectangle())
-        .onTapGesture {
-            onSelect(taskID)
-        }
+        .simultaneousGesture(
+            TapGesture()
+                .onEnded {
+                    onSelect(taskID)
+                    if !task.isCompleted {
+                        focusedField = .task(taskID)
+                    }
+                }
+        )
         .background(selectionBackground)
-        .overlay(alignment: .bottom) {
-            // Hairline border between rows, inset to align with text
-            // Only show for active (non-completed) tasks
-            if !task.isCompleted {
-                Rectangle()
-                    .fill(.separator)
-                    .frame(height: 0.5)
-                    .padding(.leading, dividerInset)
-            }
-        }
-        .contextMenu {
-            Button(task.isCompleted ? "Mark as Incomplete" : "Mark as Complete") {
-                onToggle(task)
-            }
-            Divider()
-            Button("Cut") {
-                cutToPasteboard()
-            }
-            Button("Copy") {
-                copyToPasteboard()
-            }
-            Button("Paste") {
-                pasteFromPasteboard()
-            }
-            Divider()
-            Button("Delete", role: .destructive) {
-                onDelete(task)
-            }
+        .onAppear {
+            editingTitle = task.title
         }
         .onChange(of: editingTitle) {
             guard !task.isCompleted else { return }
             onTitleChange(task, editingTitle)
         }
         .onChange(of: task.title) { _, newValue in
-            // Keep editingTitle in sync with task.title when not editing
             if !isCurrentlyEditing {
                 editingTitle = newValue
             }
         }
         .onChange(of: focusedField) { _, newValue in
-            let isFocused = newValue == .task(taskID)
-            if isFocused {
-                // Focus gained
+            let isNowEditing = newValue == .task(taskID)
+            if isNowEditing && !isCurrentlyEditing {
                 isCurrentlyEditing = true
                 onStartEdit(taskID)
-            } else if isCurrentlyEditing {
-                // Focus lost - check if it was via Return key
+            } else if !isNowEditing && isCurrentlyEditing {
                 isCurrentlyEditing = false
-                onEndEdit(taskID, submittedViaReturn)
-                submittedViaReturn = false
+                onEndEdit(taskID, false)
             }
         }
-        .onAppear {
-            // Initialize editingTitle
-            editingTitle = task.title
-        }
+        .taskSwipeGesture(
+            isActive: true,
+            isEditing: isCurrentlyEditing,
+            isDragging: false,
+            swipeOffset: $swipeOffset,
+            swipeDirection: $swipeDirection,
+            isTriggered: $isSwipeTriggered,
+            onComplete: { onToggle(task) },
+            onDelete: { onDelete(task) }
+        )
     }
 
     @ViewBuilder
@@ -157,25 +121,8 @@ struct TaskRowView: View {
         if isSelected {
             RoundedRectangle(cornerRadius: 6, style: .continuous)
                 .fill(Color.accentColor.opacity(0.2))
+        } else {
+            Color(uiColor: .systemBackground)
         }
     }
-
-    private func cutToPasteboard() {
-        copyToPasteboard()
-        onDelete(task)
-    }
-
-    private func copyToPasteboard() {
-        let text = isEditing ? editingTitle : task.title
-        guard !text.isEmpty else { return }
-        UIPasteboard.general.string = text
-    }
-
-    private func pasteFromPasteboard() {
-        guard let string = UIPasteboard.general.string else { return }
-        if isEditing {
-            editingTitle = string
-        }
-        onTitleChange(task, string)
-    }
 }