diff --git a/ReSwift-Todo.xcodeproj/project.pbxproj b/ReSwift-Todo.xcodeproj/project.pbxproj index b4fc293..3e68f9c 100644 --- a/ReSwift-Todo.xcodeproj/project.pbxproj +++ b/ReSwift-Todo.xcodeproj/project.pbxproj @@ -71,6 +71,9 @@ 507347901D7FFE8B00ACFD0D /* StreamReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5073478F1D7FFE8B00ACFD0D /* StreamReader.swift */; }; 507347921D7FFFBD00ACFD0D /* ErrorHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507347911D7FFFBD00ACFD0D /* ErrorHelpers.swift */; }; 507347941D8001A200ACFD0D /* String+ReSwiftTodo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507347931D8001A200ACFD0D /* String+ReSwiftTodo.swift */; }; + 6972332A240708A500E91FBA /* ToDoPasteboardWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 697233282407084100E91FBA /* ToDoPasteboardWriter.swift */; }; + 6972332C24070EDE00E91FBA /* ToDoSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6972332B24070EDE00E91FBA /* ToDoSerializer.swift */; }; + 6972332E2407679800E91FBA /* Array+ReSwiftTodo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6972332D2407679800E91FBA /* Array+ReSwiftTodo.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -164,6 +167,9 @@ 5073478F1D7FFE8B00ACFD0D /* StreamReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StreamReader.swift; sourceTree = ""; }; 507347911D7FFFBD00ACFD0D /* ErrorHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorHelpers.swift; sourceTree = ""; }; 507347931D8001A200ACFD0D /* String+ReSwiftTodo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = "String+ReSwiftTodo.swift"; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + 697233282407084100E91FBA /* ToDoPasteboardWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ToDoPasteboardWriter.swift; path = "ReSwift-Todo/ToDoPasteboardWriter.swift"; sourceTree = SOURCE_ROOT; }; + 6972332B24070EDE00E91FBA /* ToDoSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToDoSerializer.swift; sourceTree = ""; }; + 6972332D2407679800E91FBA /* Array+ReSwiftTodo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+ReSwiftTodo.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -208,6 +214,7 @@ 504CDFDD1D8A6DA800D16314 /* Util */ = { isa = PBXGroup; children = ( + 6972332D2407679800E91FBA /* Array+ReSwiftTodo.swift */, 50703B461D7EF83D001DAF45 /* CollectionType+ReSwiftTodo.swift */, 507347931D8001A200ACFD0D /* String+ReSwiftTodo.swift */, ); @@ -350,6 +357,7 @@ 50703B311D7EB736001DAF45 /* ToDoListWindowController.swift */, 50703B441D7EF590001DAF45 /* ToDoTableDataSource.swift */, 50703B3A1D7EE569001DAF45 /* ToDoListViewModel.swift */, + 697233282407084100E91FBA /* ToDoPasteboardWriter.swift */, 504CDFFD1D8ADF3400D16314 /* KeyboardEventHandler.swift */, 50703B3C1D7EE581001DAF45 /* To-Do Item */, 504CDFFC1D8ADF2400D16314 /* Components */, @@ -408,6 +416,7 @@ 502C04841D8850D00032B7F3 /* ToDoLineTokenizer.swift */, 5073478F1D7FFE8B00ACFD0D /* StreamReader.swift */, 502C046E1D87D9580032B7F3 /* ToDoListSerializer.swift */, + 6972332B24070EDE00E91FBA /* ToDoSerializer.swift */, ); name = Persistence; sourceTree = ""; @@ -555,12 +564,14 @@ 50703AE91D7EACB8001DAF45 /* ToDoDocument.swift in Sources */, 502C04801D880C2A0032B7F3 /* DateConverter.swift in Sources */, 50703B0A1D7EAD09001DAF45 /* ToDoList.swift in Sources */, + 6972332E2407679800E91FBA /* Array+ReSwiftTodo.swift in Sources */, 507347901D7FFE8B00ACFD0D /* StreamReader.swift in Sources */, 50703B371D7EBC76001DAF45 /* ToDoCellView.swift in Sources */, 504CDFE71D8A721D00D16314 /* UndoActionContext.swift in Sources */, 502C04851D8850D00032B7F3 /* ToDoLineTokenizer.swift in Sources */, 50703B0D1D7EAD09001DAF45 /* ToDoID.swift in Sources */, 504CDFE31D8A6F2D00D16314 /* UndoCommand.swift in Sources */, + 6972332C24070EDE00E91FBA /* ToDoSerializer.swift in Sources */, 50703B4E1D7F1407001DAF45 /* ToDoListImporter.swift in Sources */, 504CDFE11D8A6E7300D16314 /* NotUndoable.swift in Sources */, 504CDFF71D8ADB3D00D16314 /* ToDoTableView.swift in Sources */, @@ -585,6 +596,7 @@ 504CDFEE1D8A8E1300D16314 /* SelectionState.swift in Sources */, 502C046F1D87D9580032B7F3 /* ToDoListSerializer.swift in Sources */, 50703B2C1D7EB2E7001DAF45 /* LoggingMiddleware.swift in Sources */, + 6972332A240708A500E91FBA /* ToDoPasteboardWriter.swift in Sources */, 50703B341D7EB838001DAF45 /* ToDoListPresenter.swift in Sources */, 50703B301D7EB60F001DAF45 /* ToDoListState.swift in Sources */, 502C04731D87DF040032B7F3 /* ErrorHandling.swift in Sources */, diff --git a/ReSwift-Todo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ReSwift-Todo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ReSwift-Todo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ReSwift-Todo/Array+ReSwiftTodo.swift b/ReSwift-Todo/Array+ReSwiftTodo.swift new file mode 100644 index 0000000..f54f5df --- /dev/null +++ b/ReSwift-Todo/Array+ReSwiftTodo.swift @@ -0,0 +1,29 @@ +// +// Array+ReSwiftTodo.swift +// ReSwift-Todo +// +// Created by Jay Koutavas on 2/26/20. +// Copyright © 2020 ReSwift. All rights reserved. +// + +import Foundation + +extension Array { + // These functions from sooop on GitHub + // https://gist.github.com/sooop/3c964900d429516ba48bd75050d0de0a + mutating func move(from start: Index, to end: Index) { + guard (0.. Int? { return items.firstIndex(where: { $0.toDoID == toDoID }) diff --git a/ReSwift-Todo/ToDoListActions.swift b/ReSwift-Todo/ToDoListActions.swift index 7e916c9..11d4047 100644 --- a/ReSwift-Todo/ToDoListActions.swift +++ b/ReSwift-Todo/ToDoListActions.swift @@ -104,3 +104,32 @@ struct RemoveTaskAction: UndoableAction, ToDoListAction { return InsertTaskAction(toDo: removingToDo.toDo, index: removingToDo.index) } } + +struct MoveTaskAction: UndoableAction, ToDoListAction { + let from: Int + let to: Int + + init(from: Int, to: Int) { + self.from = from + self.to = to + } + + func apply(oldToDoList: ToDoList) -> ToDoList { + + var result = oldToDoList + result.moveItems(from: from, to: to) + return result + } + + var name: String { return "Move Task" } + var isUndoable: Bool { return true } + + func inverse(context: UndoActionContext) -> UndoableAction? { + + let movedDown = self.to > self.from + let inversedFrom = movedDown ? self.to-1 : self.to + let inversedTo = movedDown ? self.from : self.from+1 + + return MoveTaskAction(from: inversedFrom, to: inversedTo) + } +} diff --git a/ReSwift-Todo/ToDoListSerializer.swift b/ReSwift-Todo/ToDoListSerializer.swift index 4145252..02b85ac 100644 --- a/ReSwift-Todo/ToDoListSerializer.swift +++ b/ReSwift-Todo/ToDoListSerializer.swift @@ -34,8 +34,8 @@ enum SerializationError: Error { class ToDoListSerializer { init() { } - - lazy var dateConverter: DateConverter = DateConverter() + + lazy var toDoSerializer = ToDoSerializer() func data(toDoList: ToDoList, encoding: String.Encoding = String.Encoding.utf8) -> Data? { @@ -47,7 +47,7 @@ class ToDoListSerializer { guard !toDoList.isEmpty else { return "" } let title = toDoList.title.map { $0.appended(":") } ?? "" - let items = toDoList.items.map(itemRepresentation) + let items = toDoList.items.map(toDoSerializer.itemRepresentation) let lines = [title] .appendedContentsOf(items) @@ -55,27 +55,6 @@ class ToDoListSerializer { return lines.joined(separator: "\n").appended("\n") } - - fileprivate func itemRepresentation(_ item: ToDo) -> String { - - let body = "- \(item.title)" - let tags = item.tags.sorted().map { "@\($0)" } - let done: String? = { - switch item.completion { - case .unfinished: return nil - case .finished(when: let date): - guard let date = date else { return "@done" } - - let dateString = dateConverter.string(date: date) - return "@done(\(dateString))" - } - }() - - return [body] - .appendedContentsOf(tags) - .appendedContentsOf([done].compactMap(identity)) // remove nil - .joined(separator: " ") - } } func identity(_ value: T?) -> T? { diff --git a/ReSwift-Todo/ToDoListWindowController.swift b/ReSwift-Todo/ToDoListWindowController.swift index cbff497..e9de371 100644 --- a/ReSwift-Todo/ToDoListWindowController.swift +++ b/ReSwift-Todo/ToDoListWindowController.swift @@ -23,6 +23,7 @@ protocol ToDoTableDataSourceType { var toDoCount: Int { get } func updateContents(toDoListViewModel viewModel: ToDoListViewModel) + func setStore(toDoListStore: ToDoListStore?) func toDoCellView(tableView: NSTableView, row: Int, owner: AnyObject) -> ToDoCellView? } @@ -64,6 +65,7 @@ class ToDoListWindowController: NSWindowController { didSet { keyboardEventHandler?.store = store + dataSource.setStore(toDoListStore: store) } } @@ -86,6 +88,7 @@ class ToDoListWindowController: NSWindowController { tableView.dataSource = self.dataSource.tableDataSource tableView.delegate = self + tableView.registerForDraggedTypes([.todo, .tableViewIndex]) keyboardEventHandler?.dataSource = self.dataSource keyboardEventHandler?.store = self.store @@ -193,6 +196,12 @@ extension ToDoListWindowController: NSTableViewDelegate { dispatchAction(action) } + + // Due to a bug with NSTableView, this method has to be implemented to get + // the draggingDestinationFeedbackStyle.gap animation to look right. + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + return 30 + } } extension ToDoListWindowController: ToDoItemChangeDelegate { diff --git a/ReSwift-Todo/ToDoPasteboardWriter.swift b/ReSwift-Todo/ToDoPasteboardWriter.swift new file mode 100644 index 0000000..ae1abf7 --- /dev/null +++ b/ReSwift-Todo/ToDoPasteboardWriter.swift @@ -0,0 +1,50 @@ +// +// ToDoPasteboardWriter.swift +// ReSwift-TodoTests +// +// Created by Jay Koutavas on 2/26/20. +// Copyright © 2020 ReSwift. All rights reserved. +// + +import Cocoa + +class ToDoPasteboardWriter: NSObject, NSPasteboardWriting { + var todoViewModel: ToDoViewModel + var index: Int + + init(todoViewModel: ToDoViewModel, at index: Int) { + self.todoViewModel = todoViewModel + self.index = index + } + + func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] { + return [.todo, .tableViewIndex] + } + + func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? { + switch type { + case .todo: + return todoViewModel.title + case .tableViewIndex: + return index + default: + return nil + } + } +} + +extension NSPasteboard.PasteboardType { + static let todo = NSPasteboard.PasteboardType("com.heynow.todo") + static let tableViewIndex = NSPasteboard.PasteboardType("com.heynow.tableViewIndex") +} + +extension NSPasteboardItem { + open func integer(forType type: NSPasteboard.PasteboardType) -> Int? { + guard let data = data(forType: type) else { return nil } + let plist = try? PropertyListSerialization.propertyList( + from: data, + options: .mutableContainers, + format: nil) + return plist as? Int + } +} diff --git a/ReSwift-Todo/ToDoSerializer.swift b/ReSwift-Todo/ToDoSerializer.swift new file mode 100644 index 0000000..d65b2e8 --- /dev/null +++ b/ReSwift-Todo/ToDoSerializer.swift @@ -0,0 +1,37 @@ +// +// ToDoSerializer.swift +// ReSwift-Todo +// +// Created by Jay Koutavas on 2/26/20. +// Copyright © 2020 ReSwift. All rights reserved. +// + +import Foundation + +class ToDoSerializer { + +init() { } + + lazy var dateConverter: DateConverter = DateConverter() + + func itemRepresentation(_ item: ToDo) -> String { + + let body = "- \(item.title)" + let tags = item.tags.sorted().map { "@\($0)" } + let done: String? = { + switch item.completion { + case .unfinished: return nil + case .finished(when: let date): + guard let date = date else { return "@done" } + + let dateString = dateConverter.string(date: date) + return "@done(\(dateString))" + } + }() + + return [body] + .appendedContentsOf(tags) + .appendedContentsOf([done].compactMap(identity)) // remove nil + .joined(separator: " ") + } +} diff --git a/ReSwift-Todo/ToDoTableDataSource.swift b/ReSwift-Todo/ToDoTableDataSource.swift index 93eb006..d05c470 100644 --- a/ReSwift-Todo/ToDoTableDataSource.swift +++ b/ReSwift-Todo/ToDoTableDataSource.swift @@ -11,6 +11,12 @@ import Cocoa class ToDoTableDataSource: NSObject { var viewModel: ToDoListViewModel? + var store: ToDoListStore? + + fileprivate func dispatchAction(_ action: Action) { + + store?.dispatch(action) + } } extension ToDoTableDataSource: NSTableViewDataSource { @@ -19,6 +25,44 @@ extension ToDoTableDataSource: NSTableViewDataSource { return viewModel?.itemCount ?? 0 } + + func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? + { + guard let viewModel = viewModel?.items[safe: row] + else { return nil } + + return ToDoPasteboardWriter(todoViewModel: viewModel, at: row) + } + + func tableView( _ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, + proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation + { + guard dropOperation == .above else { return [] } + + if let source = info.draggingSource as? NSTableView, source === tableView + { + tableView.draggingDestinationFeedbackStyle = .gap + } else { + tableView.draggingDestinationFeedbackStyle = .regular + } + return .move + } + + func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, + dropOperation: NSTableView.DropOperation) -> Bool + { + guard let items = info.draggingPasteboard.pasteboardItems + else { return false } + + let indexes = items.compactMap{ $0.integer(forType: .tableViewIndex) } + if !indexes.isEmpty { + dispatchAction( + MoveTaskAction(from: indexes[0], to: row)) + return true + } + + return true + } } extension ToDoTableDataSource: ToDoTableDataSourceType { @@ -27,6 +71,10 @@ extension ToDoTableDataSource: ToDoTableDataSourceType { var selectedToDo: ToDoViewModel? { return viewModel?.selectedToDo } var toDoCount: Int { return viewModel?.itemCount ?? 0 } + func setStore(toDoListStore: ToDoListStore?) { + store = toDoListStore + } + func updateContents(toDoListViewModel viewModel: ToDoListViewModel) { self.viewModel = viewModel diff --git a/ReSwift-TodoTests/NullToDoTableDataSource.swift b/ReSwift-TodoTests/NullToDoTableDataSource.swift index 4cf8a89..c1588da 100644 --- a/ReSwift-TodoTests/NullToDoTableDataSource.swift +++ b/ReSwift-TodoTests/NullToDoTableDataSource.swift @@ -10,7 +10,9 @@ import Cocoa @testable import ReSwiftTodo class NullToDoTableDataSource: ToDoTableDataSourceType { - + func setStore(toDoListStore: ToDoListStore?) { + } + var tableDataSource: NSTableViewDataSource { return NullTableViewDataSource() } var selectedRow: Int? { return nil }