commit 3593ebb10a03e6a706c7afc55ccb7c927bb8d02b
parent 246674fd26ccd4a99ab2dc4d946a226527cb02c0
Author: Michael Camilleri <[email protected]>
Date: Thu, 5 Feb 2026 01:25:52 +0900
Implement live reordering
Co-Authored-By: Claude 4.5 Sonnet <[email protected]>
Diffstat:
5 files changed, 292 insertions(+), 117 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 */; };
+ 15B71073767FB4766A6BA2BE /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */; };
3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; };
5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
@@ -29,6 +30,7 @@
DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */; };
DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */; };
+ FEC96DAA2BF8BB5D6D8504EB /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -46,6 +48,7 @@
74255E6B6C40899E9B17D927 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
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>"; };
9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
9FBEB58DD41817F09B0EB9F0 /* Listless.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Listless.xcdatamodel; sourceTree = "<group>"; };
@@ -54,6 +57,7 @@
C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
D10B5491A53E77C80F8F75CD /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
D123BB181208FC825777B0A7 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
+ D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; };
DC3DEE364304587D280C5672 /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
FBB8A3BEB346267B30B4675F /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
@@ -94,6 +98,7 @@
58F917D865E0BDF4EF282306 /* Views */ = {
isa = PBXGroup;
children = (
+ 82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */,
48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */,
81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */,
1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */,
@@ -134,6 +139,7 @@
D12ECC901ABED96B86CC85B5 /* Views */ = {
isa = PBXGroup;
children = (
+ D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */,
B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */,
EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */,
138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */,
@@ -252,6 +258,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 15B71073767FB4766A6BA2BE /* HoverCursorModifier.swift in Sources */,
D8EFF49E9156083D675D47F0 /* KeyboardNavigationModifier.swift in Sources */,
96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */,
614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */,
@@ -270,6 +277,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ FEC96DAA2BF8BB5D6D8504EB /* HoverCursorModifier.swift in Sources */,
6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */,
5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */,
F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */,
diff --git a/Listless/Views/TaskListView.swift b/Listless/Views/TaskListView.swift
@@ -18,7 +18,9 @@ struct TaskListView: View {
@FocusState private var focusedField: FocusField?
@State private var selectedTaskID: UUID?
@State private var refreshID = UUID()
- @State private var draggedTask: UUID?
+ @State private var draggedTaskID: UUID?
+ @State private var visualOrder: [UUID]?
+ @State private var editingTaskID: UUID?
init(store: TaskStore = TaskStore()) {
_store = State(wrappedValue: store)
@@ -26,78 +28,109 @@ struct TaskListView: View {
var body: some View {
ScrollView {
- ScrollViewReader { proxy in
- VStack(alignment: .leading, spacing: 0) {
- if !completedTasks.isEmpty {
- Text("Completed")
- .font(.caption)
- .foregroundStyle(.secondary)
- .padding(.leading, 16)
- .padding(.bottom, 6)
- }
+ VStack(alignment: .leading, spacing: 0) {
+ if !completedTasks.isEmpty {
+ Text("Completed")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.leading, 16)
+ .padding(.bottom, 6)
+ }
- ForEach(completedTasks) { task in
- TaskRowView(
- task: task,
- taskID: task.id,
- isSelected: selectedTaskID == task.id,
- focusedField: $focusedField,
- onToggle: toggleCompletion(_:),
- onSubmit: handleSubmit(_:),
- onTitleChange: updateTitle(_:_:),
- onDelete: deleteTask(_:),
- onSelect: { selectTask(task.id) }
- )
- }
+ ForEach(completedTasks) { task in
+ TaskRowView(
+ task: task,
+ taskID: task.id,
+ isSelected: selectedTaskID == task.id,
+ isEditing: editingTaskID == task.id,
+ focusedField: $focusedField,
+ onToggle: toggleCompletion(_:),
+ onSubmit: handleSubmit(_:),
+ onTitleChange: updateTitle(_:_:),
+ onDelete: deleteTask(_:),
+ onSelect: { selectTask(task.id) },
+ onStartEdit: { startEditing(task.id) },
+ onEndEdit: { endEditing() }
+ )
+ }
- if !activeTasks.isEmpty && !completedTasks.isEmpty {
- Divider()
- .padding(.vertical, 8)
- }
+ if !activeTasks.isEmpty && !completedTasks.isEmpty {
+ Divider()
+ .padding(.vertical, 8)
+ }
- ForEach(Array(activeTasks.enumerated()), id: \.element.id) { index, task in
- TaskRowView(
- task: task,
- taskID: task.id,
- isSelected: selectedTaskID == task.id,
- isDragging: draggedTask == task.id,
- focusedField: $focusedField,
- onToggle: toggleCompletion(_:),
- onSubmit: handleSubmit(_:),
- onTitleChange: updateTitle(_:_:),
- onDelete: deleteTask(_:),
- onSelect: { selectTask(task.id) },
- onDragStart: { handleDragStart(task.id) },
- onDragEnd: handleDragEnd,
- onDrop: { droppedTaskID in
- handleDrop(taskID: droppedTaskID, at: index)
- }
- )
+ ForEach(displayActiveTasks) { task in
+ TaskRowView(
+ task: task,
+ taskID: task.id,
+ isSelected: selectedTaskID == task.id,
+ isEditing: editingTaskID == task.id,
+ focusedField: $focusedField,
+ onToggle: toggleCompletion(_:),
+ onSubmit: handleSubmit(_:),
+ onTitleChange: updateTitle(_:_:),
+ onDelete: deleteTask(_:),
+ onSelect: { selectTask(task.id) },
+ onStartEdit: { startEditing(task.id) },
+ onEndEdit: { endEditing() }
+ )
+ .onDrag {
+ startDrag(taskID: task.id)
+ return NSItemProvider(object: task.id.uuidString as NSString)
+ } preview: {
+ Color.clear.frame(width: 1, height: 1)
}
-
- // Drop zone at the end to allow dropping below the last item
- if !activeTasks.isEmpty {
- Color.clear
- .frame(height: 44)
- .contentShape(Rectangle())
- .dropDestination(for: String.self) { items, location in
- guard let droppedUUIDString = items.first,
- let droppedUUID = UUID(uuidString: droppedUUIDString) else {
- return false
- }
- handleDrop(taskID: droppedUUID, at: activeTasks.count)
- return true
+ .overlay {
+ if draggedTaskID != nil && draggedTaskID != task.id {
+ VStack(spacing: 0) {
+ // Top 1/6 - insert BEFORE
+ Color.clear
+ .frame(maxHeight: .infinity)
+ .layoutPriority(1)
+ .dropDestination(for: String.self, action: { _, _ in false }, isTargeted: { isTargeted in
+ if isTargeted {
+ updateVisualOrder(insertBefore: task.id)
+ }
+ })
+
+ // Middle 2/3 - insert based on direction
+ Color.clear
+ .frame(maxHeight: .infinity)
+ .layoutPriority(4)
+ .dropDestination(for: String.self, action: { _, _ in false }, isTargeted: { isTargeted in
+ if isTargeted {
+ updateVisualOrderSmart(relativeTo: task.id)
+ }
+ })
+
+ // Bottom 1/6 - insert AFTER
+ Color.clear
+ .frame(maxHeight: .infinity)
+ .layoutPriority(1)
+ .dropDestination(for: String.self, action: { _, _ in false }, isTargeted: { isTargeted in
+ if isTargeted {
+ updateVisualOrder(insertAfter: task.id)
+ }
+ })
}
- }
- }
- .frame(maxWidth: .infinity, alignment: .topLeading)
- .onChange(of: selectedTaskID) { oldValue, newValue in
- if let newValue {
- withAnimation(.easeInOut(duration: 0.2)) {
- proxy.scrollTo(newValue, anchor: .center)
}
}
}
+
+ // Drop zone at the end
+ if !activeTasks.isEmpty && draggedTaskID != nil {
+ Color.clear
+ .frame(height: 44)
+ .dropDestination(for: String.self, action: { _, _ in false }, isTargeted: { isTargeted in
+ if isTargeted {
+ updateVisualOrder(insertAtEnd: true)
+ }
+ })
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .dropDestination(for: String.self) { items, location in
+ handleDrop(items: items)
}
}
.contentShape(Rectangle())
@@ -127,19 +160,29 @@ struct TaskListView: View {
.sorted { $0.sortOrder < $1.sortOrder }
}
+ private var displayActiveTasks: [TaskItem] {
+ guard let visualOrder = visualOrder else {
+ return activeTasks
+ }
+
+ return visualOrder.compactMap { id in
+ activeTasks.first(where: { $0.id == id })
+ }
+ }
+
private var completedTasks: [TaskItem] {
Array(tasks.filter { $0.isCompleted })
.sorted { $0.updatedAt > $1.updatedAt }
}
private var allTasksInDisplayOrder: [TaskItem] {
- completedTasks + activeTasks
+ completedTasks + displayActiveTasks
}
private func createTaskAndFocus() {
let task = store.createTask(title: "")
selectedTaskID = task.id
- focusTextField(task.id)
+ startEditing(task.id)
}
private func handleBackgroundTap() {
@@ -254,12 +297,13 @@ struct TaskListView: View {
guard let currentID = selectedTaskID else { return .handled }
guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else { return .handled }
guard !task.isCompleted else { return .handled }
- focusTextField(currentID)
+ startEditing(currentID)
return .handled
}
private func unfocusTextField() -> KeyPress.Result {
guard case .task = focusedField else { return .ignored }
+ endEditing()
focusScrollView()
return .handled
}
@@ -274,18 +318,105 @@ struct TaskListView: View {
focusedField = .task(taskID)
}
+ private func startEditing(_ taskID: UUID) {
+ editingTaskID = taskID
+ focusedField = .task(taskID)
+ }
+
+ private func endEditing() {
+ if let editingID = editingTaskID {
+ deleteIfEmpty(taskID: editingID)
+ }
+ editingTaskID = nil
+ }
+
// MARK: - Drag and Drop
- private func handleDragStart(_ taskID: UUID) {
- draggedTask = taskID
+ private func startDrag(taskID: UUID) {
+ draggedTaskID = taskID
+ visualOrder = activeTasks.map(\.id)
}
- private func handleDragEnd() {
- draggedTask = nil
+ private func updateVisualOrder(insertBefore targetID: UUID) {
+ guard let draggedID = draggedTaskID,
+ let order = visualOrder else { return }
+
+ var newOrder = order.filter { $0 != draggedID }
+ if let targetIndex = newOrder.firstIndex(of: targetID) {
+ newOrder.insert(draggedID, at: targetIndex)
+ }
+
+ if newOrder != visualOrder {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ visualOrder = newOrder
+ }
+ }
+ }
+
+ private func updateVisualOrder(insertAfter targetID: UUID) {
+ guard let draggedID = draggedTaskID,
+ let order = visualOrder else { return }
+
+ var newOrder = order.filter { $0 != draggedID }
+ if let targetIndex = newOrder.firstIndex(of: targetID) {
+ newOrder.insert(draggedID, at: targetIndex + 1)
+ }
+
+ if newOrder != visualOrder {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ visualOrder = newOrder
+ }
+ }
+ }
+
+ private func updateVisualOrderSmart(relativeTo targetID: UUID) {
+ guard let draggedID = draggedTaskID,
+ let order = visualOrder else { return }
+
+ // Determine if dragged item is currently above or below target
+ guard let draggedIndex = order.firstIndex(of: draggedID),
+ let targetIndex = order.firstIndex(of: targetID) else { return }
+
+ if draggedIndex < targetIndex {
+ // Dragging from above → insert after target
+ updateVisualOrder(insertAfter: targetID)
+ } else {
+ // Dragging from below → insert before target
+ updateVisualOrder(insertBefore: targetID)
+ }
}
- private func handleDrop(taskID: UUID, at index: Int) {
- store.moveTask(taskID: taskID, toIndex: index)
- draggedTask = nil
+ private func updateVisualOrder(insertAtEnd: Bool) {
+ guard let draggedID = draggedTaskID,
+ let order = visualOrder else { return }
+
+ var newOrder = order.filter { $0 != draggedID }
+ newOrder.append(draggedID)
+
+ if newOrder != visualOrder {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ visualOrder = newOrder
+ }
+ }
+ }
+
+ private func handleDrop(items: [String]) -> Bool {
+ guard let droppedUUIDString = items.first,
+ let droppedUUID = UUID(uuidString: droppedUUIDString),
+ let order = visualOrder,
+ let finalIndex = order.firstIndex(of: droppedUUID) else {
+ draggedTaskID = nil
+ visualOrder = nil
+ return false
+ }
+
+ // Commit the reorder
+ store.moveTask(taskID: droppedUUID, toIndex: finalIndex)
+
+ // Clear drag state
+ draggedTaskID = nil
+ visualOrder = nil
+
+ return true
}
}
diff --git a/Listless/Views/TaskRowView.swift b/Listless/Views/TaskRowView.swift
@@ -4,48 +4,44 @@ struct TaskRowView: View {
let task: TaskItem
let taskID: UUID
let isSelected: Bool
- let isDragging: Bool
+ let isEditing: Bool
let onToggle: (TaskItem) -> Void
let onSubmit: (TaskItem) -> Void
let onTitleChange: (TaskItem, String) -> Void
let onDelete: (TaskItem) -> Void
let onSelect: () -> Void
- let onDragStart: () -> Void
- let onDragEnd: () -> Void
- let onDrop: (UUID) -> Void
+ let onStartEdit: () -> Void
+ let onEndEdit: () -> Void
@FocusState.Binding var focusedField: TaskListView.FocusField?
- @State private var title: String
+ @State private var editingTitle: String = ""
init(
task: TaskItem,
taskID: UUID,
isSelected: Bool,
- isDragging: Bool = false,
+ 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,
- onDragStart: @escaping () -> Void = {},
- onDragEnd: @escaping () -> Void = {},
- onDrop: @escaping (UUID) -> Void = { _ in }
+ onStartEdit: @escaping () -> Void = {},
+ onEndEdit: @escaping () -> Void = {}
) {
self.task = task
self.taskID = taskID
self.isSelected = isSelected
- self.isDragging = isDragging
+ self.isEditing = isEditing
self.onToggle = onToggle
self.onSubmit = onSubmit
self.onTitleChange = onTitleChange
self.onDelete = onDelete
self.onSelect = onSelect
- self.onDragStart = onDragStart
- self.onDragEnd = onDragEnd
- self.onDrop = onDrop
+ self.onStartEdit = onStartEdit
+ self.onEndEdit = onEndEdit
_focusedField = focusedField
- _title = State(initialValue: task.title)
}
var body: some View {
@@ -59,17 +55,33 @@ struct TaskRowView: View {
}
.buttonStyle(.borderless)
- TextField("New task", text: $title)
- .textFieldStyle(.plain)
- .font(.body)
- .focused($focusedField, equals: .task(taskID))
- .onSubmit {
- onSubmit(task)
+ if isEditing {
+ TextField("New task", text: $editingTitle, axis: .vertical)
+ .textFieldStyle(.plain)
+ .font(.body)
+ .lineLimit(1...5)
+ .focused($focusedField, equals: .task(taskID))
+ .onSubmit {
+ onSubmit(task)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .disabled(task.isCompleted)
+ } 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()
+ }
+ }
+
+ Spacer(minLength: 0)
}
- .platformTextFieldWidth(text: title, placeholder: "New task")
- .disabled(task.isCompleted)
- .strikethrough(task.isCompleted, color: .secondary)
- .foregroundStyle(task.isCompleted ? .secondary : .primary)
+ }
}
.padding(.vertical, 8)
.padding(.horizontal, 16)
@@ -98,29 +110,22 @@ struct TaskRowView: View {
onDelete(task)
}
}
- .onChange(of: title) {
+ .onChange(of: editingTitle) {
guard !task.isCompleted else { return }
- onTitleChange(task, title)
+ onTitleChange(task, editingTitle)
}
- .onChange(of: task.title) {
- if task.title != title {
- title = task.title
+ .onChange(of: isEditing) { _, newValue in
+ if newValue {
+ editingTitle = task.title
}
}
.onChange(of: focusedField) { oldValue, newValue in
if newValue == .task(taskID) && oldValue != .task(taskID) {
onSelect()
+ } else if oldValue == .task(taskID) && newValue != .task(taskID) {
+ onEndEdit()
}
}
- .opacity(isDragging ? 0.5 : 1.0)
- .taskDragGesture(
- isActive: !task.isCompleted,
- taskID: task.id,
- taskTitle: task.title,
- onDragStart: onDragStart,
- onDragEnd: onDragEnd,
- onDrop: onDrop
- )
}
private var selectionBackground: some View {
@@ -144,13 +149,14 @@ struct TaskRowView: View {
}
private func copyToPasteboard() {
- guard !title.isEmpty else { return }
+ let text = isEditing ? editingTitle : task.title
+ guard !text.isEmpty else { return }
#if os(macOS)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
- pasteboard.setString(title, forType: .string)
+ pasteboard.setString(text, forType: .string)
#else
- UIPasteboard.general.string = title
+ UIPasteboard.general.string = text
#endif
}
@@ -160,7 +166,9 @@ struct TaskRowView: View {
#else
guard let string = UIPasteboard.general.string else { return }
#endif
- title = string
+ if isEditing {
+ editingTitle = string
+ }
onTitleChange(task, string)
}
}
diff --git a/ListlessMac/Views/HoverCursorModifier.swift b/ListlessMac/Views/HoverCursorModifier.swift
@@ -0,0 +1,18 @@
+import SwiftUI
+import AppKit
+
+struct TextHoverModifier: ViewModifier {
+ let isCompleted: Bool
+
+ func body(content: Content) -> some View {
+ content.onHover { isHovering in
+ if !isCompleted {
+ if isHovering {
+ NSCursor.iBeam.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+ }
+ }
+}
diff --git a/ListlessiOS/Views/HoverCursorModifier.swift b/ListlessiOS/Views/HoverCursorModifier.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+struct TextHoverModifier: ViewModifier {
+ let isCompleted: Bool
+
+ func body(content: Content) -> some View {
+ // No-op on iOS - cursors don't apply
+ content
+ }
+}