← All repositories

listless

A simple list app for Apple platforms

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


commit d9df2a5229c9dadd71816ac74c5f798bc06a8485
parent 01c629a9b1aa3ff44f7f77e263df79a492d03953
Author: Michael Camilleri <[email protected]>
Date:   Sun,  8 Feb 2026 01:01:24 +0900

Add colour strip

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

Diffstat

MListless.xcodeproj/project.pbxproj | 8++++++++
MListless/Views/KeyboardNavigationModifier.swift | 10++++------
MListless/Views/TaskListView.swift | 4+++-
MListless/Views/TaskRowView.swift | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessMac/ColorExtensions.swift | 21+++++++++++++++++++++
AListlessiOS/ColorExtensions.swift | 17+++++++++++++++++

6 files changed, 121 insertions(+), 7 deletions(-)

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

@@ -8,6 +8,7 @@
 
 /* Begin PBXBuildFile section */
 		0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
+		0BB5D2C4FADE3F1E22202814 /* ColorExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 917597025B3D5D18E33982D3 /* ColorExtensions.swift */; };
 		15B71073767FB4766A6BA2BE /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */; };
 		1AA328A921EF8A7FDD03119A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B048B19C5219862BBED2E7 /* TestHelpers.swift */; };
 		269B93D5543770B464DFB37A /* TaskStoreOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */; };
@@ -15,6 +16,7 @@
 		42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; };
 		5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
 		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 */; };
 		83378A0575640417ED41FC25 /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BB7ABCABA4EB67180A200 /* ClickableTextField.swift */; };
 		8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; };
@@ -71,6 +73,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>"; };
+		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>"; };
 		944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
 		967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreTests.swift; sourceTree = "<group>"; };
@@ -79,6 +82,7 @@
 		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>"; };
+		BBDF292392702661DBB94D06 /* ColorExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtensions.swift; sourceTree = "<group>"; };
 		C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
 		C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Listless iOS Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
 		C9B14DC786A336008AAB78EE /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
@@ -149,6 +153,7 @@
 		954AAB8DDFF6E6D6FD6A0A2C /* ListlessiOS */ = {
 			isa = PBXGroup;
 			children = (
+				BBDF292392702661DBB94D06 /* ColorExtensions.swift */,
 				3313FEDB101EECA4B344EEF4 /* Info.plist */,
 				9262207DAC21619BD9EDEE15 /* Listless.entitlements */,
 				AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */,
@@ -180,6 +185,7 @@
 		D5B197AFF26144948D032299 /* ListlessMac */ = {
 			isa = PBXGroup;
 			children = (
+				917597025B3D5D18E33982D3 /* ColorExtensions.swift */,
 				01E141436176F83594E2F26B /* Info.plist */,
 				7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */,
 				1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */,
@@ -337,6 +343,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				83378A0575640417ED41FC25 /* ClickableTextField.swift in Sources */,
+				0BB5D2C4FADE3F1E22202814 /* ColorExtensions.swift in Sources */,
 				15B71073767FB4766A6BA2BE /* HoverCursorModifier.swift in Sources */,
 				D8EFF49E9156083D675D47F0 /* KeyboardNavigationModifier.swift in Sources */,
 				96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */,
@@ -357,6 +364,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				978DABFA617B6EBD6FA179CD /* ClickableTextField.swift in Sources */,
+				69D1909CB6D2368CF90065C7 /* ColorExtensions.swift in Sources */,
 				FEC96DAA2BF8BB5D6D8504EB /* HoverCursorModifier.swift in Sources */,
 				6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */,
 				5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */,

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

@@ -42,11 +42,9 @@ extension View {
     }
 
     private func normalizeModifiers(_ modifiers: EventModifiers) -> EventModifiers {
-        // Filter out system modifiers that come automatically with certain keys
-        // (function keys, numericPad) - only keep user-intentional modifiers
-        var normalized = modifiers
-        normalized.remove(.function)
-        normalized.remove(.numericPad)
-        return normalized
+        // Mask to only meaningful shortcut modifiers, excluding system artifacts
+        // like .function (deprecated), .numericPad, .capsLock, etc.
+        let shortcutModifierMask: EventModifiers = [.command, .shift, .option, .control]
+        return EventModifiers(rawValue: modifiers.rawValue & shortcutModifierMask.rawValue)
     }
 }

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

@@ -32,11 +32,13 @@ struct TaskListView: View {
     var body: some View {
         ScrollView {
             VStack(alignment: .leading, spacing: 0) {
-                ForEach(displayActiveTasks) { task in
+                ForEach(Array(displayActiveTasks.enumerated()), id: \.element.id) { index, task in
                     let taskID = task.id
                     TaskRowView(
                         task: task,
                         taskID: taskID,
+                        index: index,
+                        totalTasks: displayActiveTasks.count,
                         isSelected: selectedTaskID == taskID,
                         isEditing: editingTaskID == taskID,
                         focusedField: $focusedField,

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

@@ -3,6 +3,8 @@ 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
@@ -16,9 +18,55 @@ struct TaskRowView: View {
     @State private var editingTitle: String = ""
     @State private var isCurrentlyEditing: Bool = false
 
+    private let horizontalPadding: CGFloat = 16
+    private let checkboxTextSpacing: CGFloat = 12
+    @ScaledMetric private var checkboxSize: CGFloat = 20
+
+    private var dividerInset: CGFloat {
+        horizontalPadding + checkboxSize + checkboxTextSpacing
+    }
+
+    private var accentColor: Color {
+        guard !task.isCompleted else { return .clear }
+        guard totalTasks > 1 else { return Color(hue: 0.98, saturation: 0.85, brightness: 1.0) }
+
+        // Gradient matches gradient.png: coral/red → pink/magenta → purple/blue
+        let progress = Double(index) / Double(totalTasks - 1)
+
+        // Define color stops based on the gradient image
+        let topColor = Color(hue: 0.98, saturation: 0.85, brightness: 1.0)      // Coral/red
+        let midColor = Color(hue: 0.88, saturation: 0.75, brightness: 0.95)    // Pink/magenta
+        let bottomColor = Color(hue: 0.72, saturation: 0.65, brightness: 0.85) // Purple/blue
+
+        // Interpolate between colors
+        if progress < 0.5 {
+            // Top half: coral → magenta
+            let localProgress = progress * 2.0
+            return interpolateColor(from: topColor, to: midColor, progress: localProgress)
+        } else {
+            // Bottom half: magenta → purple/blue
+            let localProgress = (progress - 0.5) * 2.0
+            return interpolateColor(from: midColor, to: bottomColor, progress: localProgress)
+        }
+    }
+
+    private func interpolateColor(from: Color, to: Color, progress: Double) -> Color {
+        // Extract HSB components and interpolate
+        let fromHSB = PlatformColor(from).hsba
+        let toHSB = PlatformColor(to).hsba
+
+        let hue = fromHSB.hue + (toHSB.hue - fromHSB.hue) * progress
+        let saturation = fromHSB.saturation + (toHSB.saturation - fromHSB.saturation) * progress
+        let brightness = fromHSB.brightness + (toHSB.brightness - fromHSB.brightness) * progress
+
+        return Color(hue: hue, saturation: saturation, brightness: brightness)
+    }
+
     init(
         task: TaskItem,
         taskID: UUID,
+        index: Int = 0,
+        totalTasks: Int = 1,
         isSelected: Bool,
         isEditing: Bool = false,
         focusedField: FocusState<TaskListView.FocusField?>.Binding,
@@ -31,6 +79,8 @@ struct TaskRowView: View {
     ) {
         self.task = task
         self.taskID = taskID
+        self.index = index
+        self.totalTasks = totalTasks
         self.isSelected = isSelected
         self.isEditing = isEditing
         self.onToggle = onToggle
@@ -50,6 +100,7 @@ struct TaskRowView: View {
                 Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                     .foregroundStyle(task.isCompleted ? .secondary : .primary)
                     .font(.system(size: 17))
+                    .fontWeight(.thin)
             }
             .buttonStyle(.borderless)
             .alignmentGuide(.firstTextBaseline) { d in
@@ -82,6 +133,23 @@ struct TaskRowView: View {
             onSelect(taskID)
         }
         .background(selectionBackground)
+        .overlay(alignment: .leading) {
+            // Colored accent bar on the left edge
+            Rectangle()
+                .fill(accentColor)
+                .frame(width: 4)
+                .padding(.vertical, 1)
+        }
+        .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)

diff --git a/ListlessMac/ColorExtensions.swift b/ListlessMac/ColorExtensions.swift

@@ -0,0 +1,21 @@
+import AppKit
+import SwiftUI
+
+typealias PlatformColor = NSColor
+
+extension NSColor {
+    var hsba: (hue: Double, saturation: Double, brightness: Double, alpha: Double) {
+        var hue: CGFloat = 0
+        var saturation: CGFloat = 0
+        var brightness: CGFloat = 0
+        var alpha: CGFloat = 0
+
+        // Convert to RGB color space first for consistency
+        guard let rgbColor = self.usingColorSpace(.deviceRGB) else {
+            return (0, 0, 0, 0)
+        }
+        rgbColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
+
+        return (Double(hue), Double(saturation), Double(brightness), Double(alpha))
+    }
+}

diff --git a/ListlessiOS/ColorExtensions.swift b/ListlessiOS/ColorExtensions.swift

@@ -0,0 +1,17 @@
+import UIKit
+import SwiftUI
+
+typealias PlatformColor = UIColor
+
+extension UIColor {
+    var hsba: (hue: Double, saturation: Double, brightness: Double, alpha: Double) {
+        var hue: CGFloat = 0
+        var saturation: CGFloat = 0
+        var brightness: CGFloat = 0
+        var alpha: CGFloat = 0
+
+        getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
+
+        return (Double(hue), Double(saturation), Double(brightness), Double(alpha))
+    }
+}