← All repositories

listless

A simple list app for Apple platforms

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


commit c5e368a69436a651a3b06fb5768b59daafeed3b5
parent 9a50bb1fd589366b02341e52bef7c1b29ff46c31
Author: Michael Camilleri <[email protected]>
Date:   Wed, 18 Feb 2026 11:10:31 +0900

Add initial implementation of pull-to-clear

Co-Authored-By: Codex GPT 5.3 <[email protected]>

Diffstat

MListless.xcodeproj/project.pbxproj | 8++++++++
AListlessiOS/Extensions/TaskListView+PullToClear.swift | 9+++++++++
AListlessiOS/Views/PullToClear.swift | 49+++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/TaskListView.swift | 101++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------

4 files changed, 128 insertions(+), 39 deletions(-)

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

@@ -26,10 +26,12 @@
 		763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */; };
 		77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */; };
 		77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */; };
+		785721EB774EAC6BBA26C038 /* PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567DBAC2A39FA2760D006AAB /* PullToClear.swift */; };
 		7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */; };
 		80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */; };
 		82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */; };
 		882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */; };
+		8A8D164CAA7B95B4C7435A7C /* TaskListView+PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */; };
 		91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
 		96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
 		99977BFA37FBAAA49AF6B71E /* TaskStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */; };
@@ -76,6 +78,8 @@
 		4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardNavigationModifier.swift; sourceTree = "<group>"; };
 		466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; };
 		4FC64B9F9370041BEDBD1E14 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
+		567DBAC2A39FA2760D006AAB /* PullToClear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullToClear.swift; sourceTree = "<group>"; };
+		5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullToClear.swift"; sourceTree = "<group>"; };
 		5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
 		5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreCompletionTests.swift; sourceTree = "<group>"; };
 		632DA39B24C4CF1528A1A24D /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; };
@@ -165,6 +169,7 @@
 		58F917D865E0BDF4EF282306 /* Views */ = {
 			isa = PBXGroup;
 			children = (
+				567DBAC2A39FA2760D006AAB /* PullToClear.swift */,
 				BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */,
 				E51B8129962E5CC78ECDDC2B /* TaskListView.swift */,
 				199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */,
@@ -186,6 +191,7 @@
 			children = (
 				3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */,
 				712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */,
+				5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */,
 				9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */,
 				CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */,
 			);
@@ -442,12 +448,14 @@
 				DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */,
 				763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */,
 				77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */,
+				785721EB774EAC6BBA26C038 /* PullToClear.swift in Sources */,
 				E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */,
 				0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */,
 				0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */,
 				82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */,
 				CAD142ED738A83371DFF8F5B /* TaskListView+Logic.swift in Sources */,
 				26F609518DE1055DF09B0159 /* TaskListView+NavigationHeader.swift in Sources */,
+				8A8D164CAA7B95B4C7435A7C /* TaskListView+PullToClear.swift in Sources */,
 				80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */,
 				77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */,
 				C169823665158AA347A63990 /* TaskListView.swift in Sources */,

diff --git a/ListlessiOS/Extensions/TaskListView+PullToClear.swift b/ListlessiOS/Extensions/TaskListView+PullToClear.swift

@@ -0,0 +1,9 @@
+import SwiftUI
+
+extension TaskListView {
+    @ViewBuilder var pullToClearIndicatorRow: some View {
+        if pullUpOffset > 0 && !completedTasks.isEmpty {
+            PullToClearIndicator(pullOffset: pullUpOffset)
+        }
+    }
+}

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

@@ -0,0 +1,49 @@
+import SwiftUI
+
+/// Pull distance at which the indicator signals readiness and completed task clearing triggers.
+let pullClearThreshold: CGFloat = 70
+
+struct PullToClearIndicator: View {
+    let pullOffset: CGFloat
+
+    private var progress: CGFloat { min(1, pullOffset / pullClearThreshold) }
+    private var isReady: Bool { pullOffset >= pullClearThreshold }
+
+    // Matches the "bottom" gradient stop used for the last active task row
+    private let accentColor = Color(hue: 0.72, saturation: 0.65, brightness: 0.85)
+
+    var body: some View {
+        HStack(spacing: 0) {
+            Rectangle()
+                .fill(accentColor)
+                .frame(width: 8)
+            HStack(spacing: 6) {
+                Image(systemName: isReady ? "checkmark" : "trash")
+                    .foregroundStyle(.secondary)
+                    .fontWeight(.semibold)
+                    .animation(.easeInOut(duration: 0.15), value: isReady)
+                Text(isReady ? "Release to clear" : "Clear completed")
+                    .foregroundStyle(.secondary)
+                    .font(.body)
+                    .animation(.easeInOut(duration: 0.15), value: isReady)
+                Spacer()
+            }
+            .padding(.horizontal, 16)
+            .frame(maxHeight: .infinity)
+            .background(Color.taskCard)
+        }
+        .frame(height: 56)
+        .clipShape(
+            UnevenRoundedRectangle(
+                topLeadingRadius: 0, bottomLeadingRadius: 0,
+                bottomTrailingRadius: 14, topTrailingRadius: 14
+            )
+        )
+        .padding(.trailing, 16)
+        // Reveal from the bottom upward as the user pulls
+        .frame(height: min(pullOffset, 56), alignment: .bottom)
+        .clipped()
+        .opacity(Double(progress))
+        .allowsHitTesting(false)
+    }
+}

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

@@ -25,6 +25,7 @@ struct TaskListView: View {
     @State var visualOrder: [UUID]?
     @State var pendingFocus: FocusField?
     @State var pullOffset: CGFloat = 0
+    @State var pullUpOffset: CGFloat = 0
     @State var isDragging: Bool = false
     @State var rowFrames: [UUID: CGRect] = [:]
 
@@ -41,6 +42,50 @@ struct TaskListView: View {
     }
 
     var body: some View {
+        taskScrollView
+            .contentShape(Rectangle())
+            .onTapGesture {
+                handleBackgroundTap()
+            }
+            .focusable()
+            .focused($focusedField, equals: .scrollView)
+            .focusEffectDisabled()
+            .accessibilityIdentifier("task-list-scrollview")
+            .keyboardNavigation([
+                ShortcutKey(key: .upArrow): navigateUp,
+                ShortcutKey(key: .downArrow): navigateDown,
+                ShortcutKey(key: .space): toggleSelectedTask,
+                ShortcutKey(key: .return): focusSelectedTask,
+                ShortcutKey(key: .delete): deleteSelectedTask,
+            ])
+            .onAppear {
+                if focusedField == nil {
+                    focusedField = .scrollView
+                }
+            }
+            .onChange(of: focusedField) { oldValue, newValue in
+                handleFocusChange(from: oldValue, to: newValue)
+
+                if newValue == nil {
+                    if let pending = pendingFocus {
+                        print("🟣 onChange resolving pendingFocus: \(pending)")
+                        focusedField = pending
+                        pendingFocus = nil
+                    } else {
+                        print("🟣 onChange repairing nil focus to .scrollView")
+                        focusedField = .scrollView
+                    }
+                }
+            }
+            .onChange(of: undoManager, initial: true) { _, newValue in
+                managedObjectContext.undoManager = newValue
+            }
+            .toolbar {
+                platformToolbar
+            }
+    }
+
+    private var taskScrollView: some View {
         ScrollView {
             VStack(alignment: .leading, spacing: vStackSpacing) {
                 navigationHeader
@@ -102,62 +147,40 @@ struct TaskListView: View {
             .offset(y: -pullOffset)
         }
         .scrollDisabled(draggedTaskID != nil)
+        .scrollBounceBehavior(.always)
         .background {
             Color.outerBackground.ignoresSafeArea()
         }
+        .overlay(alignment: .bottom) {
+            pullToClearIndicatorRow
+        }
         .onScrollGeometryChange(for: CGFloat.self) { geo in
             max(0, -(geo.contentOffset.y + geo.contentInsets.top))
         } action: { _, pullDistance in
             pullOffset = pullDistance
         }
+        .onScrollGeometryChange(for: CGFloat.self) { geo in
+            let maxOffset = max(
+                -geo.contentInsets.top,
+                geo.contentSize.height - geo.bounds.size.height + geo.contentInsets.bottom
+            )
+            return max(0, geo.contentOffset.y - maxOffset)
+        } action: { _, pullDistance in
+            pullUpOffset = pullDistance
+        }
         .onScrollPhaseChange { oldPhase, newPhase in
             if oldPhase == .interacting && newPhase != .interacting {
                 if pullOffset >= pullCreateThreshold { createNewTaskAtTop() }
+                if pullUpOffset >= pullClearThreshold && !completedTasks.isEmpty { clearCompletedTasks() }
                 pullOffset = 0
+                pullUpOffset = 0
             }
         }
         .sensoryFeedback(.impact(weight: .medium), trigger: pullOffset >= pullCreateThreshold) { old, new in
             !old && new
         }
-        .contentShape(Rectangle())
-        .onTapGesture {
-            handleBackgroundTap()
-        }
-        .focusable()
-        .focused($focusedField, equals: .scrollView)
-        .focusEffectDisabled()
-        .accessibilityIdentifier("task-list-scrollview")
-        .keyboardNavigation([
-            ShortcutKey(key: .upArrow): navigateUp,
-            ShortcutKey(key: .downArrow): navigateDown,
-            ShortcutKey(key: .space): toggleSelectedTask,
-            ShortcutKey(key: .return): focusSelectedTask,
-            ShortcutKey(key: .delete): deleteSelectedTask,
-        ])
-        .onAppear {
-            if focusedField == nil {
-                focusedField = .scrollView
-            }
-        }
-        .onChange(of: focusedField) { oldValue, newValue in
-            handleFocusChange(from: oldValue, to: newValue)
-
-            if newValue == nil {
-                if let pending = pendingFocus {
-                    print("🟣 onChange resolving pendingFocus: \(pending)")
-                    focusedField = pending
-                    pendingFocus = nil
-                } else {
-                    print("🟣 onChange repairing nil focus to .scrollView")
-                    focusedField = .scrollView
-                }
-            }
-        }
-        .onChange(of: undoManager, initial: true) { _, newValue in
-            managedObjectContext.undoManager = newValue
-        }
-        .toolbar {
-            platformToolbar
+        .sensoryFeedback(.impact(weight: .medium), trigger: pullUpOffset >= pullClearThreshold) { old, new in
+            !old && new
         }
     }
 }