diff options
| author | Ruben Beltran del Rio <git@r.bdr.sh> | 2024-09-16 11:10:32 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <git@r.bdr.sh> | 2024-09-16 11:10:32 +0200 |
| commit | e2c37ac1dd2ad562e3d619d39b72a174a2212b67 (patch) | |
| tree | d4da2c0a12db0473eb4030014a92045eebe38834 /Map | |
| parent | fdb4633d3e9158e457d57e820df6e1efb4df39c2 (diff) | |
Map 3 first commit: files, groups and layout
Diffstat (limited to 'Map')
43 files changed, 833 insertions, 682 deletions
diff --git a/Map/Assets.xcassets/Colors/Theme/Darker Neutral Gray.colorset/Contents.json b/Map/Assets.xcassets/Colors/Theme/Darker Neutral Gray.colorset/Contents.json new file mode 100644 index 0000000..e890acd --- /dev/null +++ b/Map/Assets.xcassets/Colors/Theme/Darker Neutral Gray.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.488", + "green" : "0.500", + "red" : "0.434" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Map/Assets.xcassets/Colors/Theme/Hermosa Pink.colorset/Contents.json b/Map/Assets.xcassets/Colors/Theme/Hermosa Pink.colorset/Contents.json new file mode 100644 index 0000000..b90aa6c --- /dev/null +++ b/Map/Assets.xcassets/Colors/Theme/Hermosa Pink.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF0", + "green" : "0xB3", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Map/Assets.xcassets/Colors/Theme/Light Porcelain Green.colorset/Contents.json b/Map/Assets.xcassets/Colors/Theme/Light Porcelain Green.colorset/Contents.json new file mode 100644 index 0000000..13a53a1 --- /dev/null +++ b/Map/Assets.xcassets/Colors/Theme/Light Porcelain Green.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x7C", + "green" : "0xC1", + "red" : "0x23" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Map/Assets.xcassets/Colors/Theme/Naples Yellow.colorset/Contents.json b/Map/Assets.xcassets/Colors/Theme/Naples Yellow.colorset/Contents.json new file mode 100644 index 0000000..1c11b82 --- /dev/null +++ b/Map/Assets.xcassets/Colors/Theme/Naples Yellow.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x8F", + "green" : "0xED", + "red" : "0xFA" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Map/Core Extensions/Date+format.swift b/Map/Core Extensions/Date+format.swift deleted file mode 100644 index d29ed0f..0000000 --- a/Map/Core Extensions/Date+format.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// Date+format.swift -// Map -// -// Created by Ruben Beltran del Rio on 2/1/21. -// - -import Foundation - -extension Date { - func format() -> String { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .medium - return formatter.string(from: self) - } -} diff --git a/Map/Core Extensions/Font+Theme.swift b/Map/Core Extensions/Font+Theme.swift deleted file mode 100644 index 30bc6f9..0000000 --- a/Map/Core Extensions/Font+Theme.swift +++ /dev/null @@ -1,8 +0,0 @@ -import SwiftUI - -public extension Font { - struct theme { - static let axisLabel = Font.custom("Hiragino Mincho ProN", size: 14) - static let vertexLabel = Font.custom("Hiragino Mincho ProN", size: 12) - } -} diff --git a/Map/Core Extensions/NSImage+writePNG.swift b/Map/Core Extensions/NSImage+writePNG.swift deleted file mode 100644 index c24c0cf..0000000 --- a/Map/Core Extensions/NSImage+writePNG.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Cocoa - -extension NSImage { - public func writePNG(toURL url: URL) { - - guard let data = tiffRepresentation, - let rep = NSBitmapImageRep(data: data), - let imgData = rep.representation( - using: .png, properties: [.compressionFactor: NSNumber(floatLiteral: 1.0)]) - else { - - print( - "\(self.self) Error Function '\(#function)' Line: \(#line) No tiff rep found for image writing to \(url)" - ) - return - } - - do { - try imgData.write(to: url) - } catch let error { - print( - "\(self.self) Error Function '\(#function)' Line: \(#line) \(error.localizedDescription)") - } - } -} diff --git a/Map/Data/AppState.swift b/Map/Data/AppState.swift deleted file mode 100644 index d0d5670..0000000 --- a/Map/Data/AppState.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Cocoa -import Foundation -import SwiftUI - -struct AppState { - var selectedEvolution: StageType = .general -} - -enum AppAction { - case selectEvolution(evolution: StageType) - case exportMapAsImage(map: Map) - case exportMapAsText(map: Map) - case deleteMap(map: Map) -} - -func appStateReducer(state: inout AppState, action: AppAction) { - - switch action { - - case .selectEvolution(let evolution): - state.selectedEvolution = evolution - - case .exportMapAsImage(let map): - let window = NSWindow( - contentRect: .init( - origin: .zero, - size: .init( - width: NSScreen.main!.frame.width, - height: NSScreen.main!.frame.height)), - styleMask: [.closable], - backing: .buffered, - defer: false) - - window.title = map.title ?? "Untitled Map" - window.isOpaque = true - window.center() - window.isMovableByWindowBackground = true - window.makeKeyAndOrderFront(nil) - - let renderView = MapRenderView( - content: Binding.constant(map.content ?? ""), - evolution: Binding.constant(state.selectedEvolution)) - - let view = NSHostingView(rootView: renderView) - window.contentView = view - - let imageRepresentation = view.bitmapImageRepForCachingDisplay(in: view.bounds)! - view.cacheDisplay(in: view.bounds, to: imageRepresentation) - let image = NSImage(cgImage: imageRepresentation.cgImage!, size: view.bounds.size) - - let dialog = NSSavePanel() - - dialog.title = "Save Map" - dialog.showsResizeIndicator = false - dialog.canCreateDirectories = true - dialog.showsHiddenFiles = false - dialog.allowedContentTypes = [.png] - dialog.nameFieldStringValue = map.title ?? "Untitled Map" - - if dialog.runModal() == NSApplication.ModalResponse.OK { - let result = dialog.url - - if result != nil { - - image.writePNG(toURL: result!) - print("saved at \(result!)") - } - } else { - print("Cancel") - } - window.orderOut(nil) - - case .exportMapAsText(let map): - let dialog = NSSavePanel() - - dialog.title = "Save Map Text" - dialog.showsResizeIndicator = false - dialog.canCreateDirectories = true - dialog.showsHiddenFiles = false - dialog.allowedContentTypes = [.text] - dialog.nameFieldStringValue = map.title ?? "Untitled Map" - - if let content = map.content { - - if dialog.runModal() == NSApplication.ModalResponse.OK { - let result = dialog.url - - if let result = result { - try? content.write(to: result, atomically: true, encoding: String.Encoding.utf8) - } - } else { - print("Cancel") - } - } - case .deleteMap(let map): - let context = PersistenceController.shared.container.viewContext - context.delete(map) - - try? context.save() - } -} - -typealias AppStore = Store<AppState, AppAction> diff --git a/Map/Data/MapDocument.swift b/Map/Data/MapDocument.swift new file mode 100644 index 0000000..9340684 --- /dev/null +++ b/Map/Data/MapDocument.swift @@ -0,0 +1,42 @@ +import SwiftUI +import UniformTypeIdentifiers + +extension UTType { + static var exampleText: UTType { + UTType(importedAs: "systems.tranquil.map.wmap") + } +} + +struct MapDocument: FileDocument { + var text: String + + init(text: String = "Hello, world!") { + self.text = text + } + + static var readableContentTypes: [UTType] { [.exampleText] } + + init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents, + let string = String(data: data, encoding: .utf8) + else { + throw CocoaError(.fileReadCorruptFile) + } + text = string + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let data = text.data(using: .utf8)! + return .init(regularFileWithContents: data) + } + + @MainActor + func exportAsImage(withEvolution selectedEvolution: StageType) -> NSImage? { + let renderView = MapRenderView( + document: .constant(self), + evolution: .constant(selectedEvolution)) + let renderer = ImageRenderer(content: renderView) + + return renderer.nsImage + } +} diff --git a/Map/Data/Models/Map+parse.swift b/Map/Data/Models/Map+parse.swift deleted file mode 100644 index 5181daf..0000000 --- a/Map/Data/Models/Map+parse.swift +++ /dev/null @@ -1,29 +0,0 @@ -extension Map { - static func parse(content: String) -> ParsedMap { - - let parsers = [ - AnyMapParserStrategy(NoteParserStrategy()), - AnyMapParserStrategy(VertexParserStrategy()), - AnyMapParserStrategy(EdgeParserStrategy()), - AnyMapParserStrategy(BlockerParserStrategy()), - AnyMapParserStrategy(OpportunityParserStrategy()), - AnyMapParserStrategy(StageParserStrategy()), - ] - let builder = MapBuilder() - - let lines = content.split(whereSeparator: \.isNewline) - - for (index, line) in lines.enumerated() { - for parser in parsers { - if parser.canHandle(line: String(line)) { - let (type, object) = parser.handle( - index: index, line: String(line), vertices: builder.vertices) - builder.addObjectToMap(type: type, object: object) - break - } - } - } - - return builder.build() - } -} diff --git a/Map/Data/Persistence.swift b/Map/Data/Persistence.swift deleted file mode 100644 index 1eb09e8..0000000 --- a/Map/Data/Persistence.swift +++ /dev/null @@ -1,42 +0,0 @@ -import CoreData - -struct PersistenceController { - static let shared = PersistenceController() - - static var preview: PersistenceController = { - let result = PersistenceController(inMemory: true) - let viewContext = result.container.viewContext - for _ in 0..<10 { - let newMap = Map(context: viewContext) - newMap.uuid = UUID() - newMap.createdAt = Date() - newMap.title = "Map \(newMap.createdAt!.format())" - newMap.content = "" - } - do { - try viewContext.save() - } catch { - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") - } - return result - }() - - let container: NSPersistentCloudKitContainer - - init(inMemory: Bool = false) { - container = NSPersistentCloudKitContainer(name: "Map") - - container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy - container.viewContext.automaticallyMergesChangesFromParent = true - - if inMemory { - container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") - } - container.loadPersistentStores(completionHandler: { (storeDescription, error) in - if let error = error as NSError? { - fatalError("Unresolved error \(error), \(error.userInfo)") - } - }) - } -} diff --git a/Map/Data/Store.swift b/Map/Data/Store.swift deleted file mode 100644 index 7860f33..0000000 --- a/Map/Data/Store.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -final class Store<State, Action>: ObservableObject { - @Published private(set) var state: State - - private let reducer: Reducer<State, Action> - - init(initialState: State, reducer: @escaping Reducer<State, Action>) { - self.state = initialState - self.reducer = reducer - } - - func send(_ action: Action) { - reducer(&state, action) - } -} - -typealias Reducer<State, Action> = (inout State, Action) -> Void diff --git a/Map/Info.plist b/Map/Info.plist index 6ba8cf0..61150cf 100644 --- a/Map/Info.plist +++ b/Map/Info.plist @@ -2,25 +2,42 @@ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> - <key>CFBundleDevelopmentRegion</key> - <string>$(DEVELOPMENT_LANGUAGE)</string> - <key>CFBundleExecutable</key> - <string>$(EXECUTABLE_NAME)</string> - <key>CFBundleIdentifier</key> - <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> - <key>CFBundleInfoDictionaryVersion</key> - <string>6.0</string> - <key>CFBundleName</key> - <string>$(PRODUCT_NAME)</string> - <key>CFBundlePackageType</key> - <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> - <key>CFBundleShortVersionString</key> - <string>$(MARKETING_VERSION)</string> - <key>CFBundleVersion</key> - <string>$(CURRENT_PROJECT_VERSION)</string> - <key>LSApplicationCategoryType</key> - <string>public.app-category.productivity</string> - <key>LSMinimumSystemVersion</key> - <string>$(MACOSX_DEPLOYMENT_TARGET)</string> + <key>CFBundleDocumentTypes</key> + <array> + <dict> + <key>CFBundleTypeRole</key> + <string>Editor</string> + <key>LSHandlerRank</key> + <string>Default</string> + <key>LSItemContentTypes</key> + <array> + <string>systems.tranquil.map.wmap</string> + </array> + <key>NSUbiquitousDocumentUserActivityType</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER).exampledocument</string> + </dict> + </array> + <key>UTImportedTypeDeclarations</key> + <array> + <dict> + <key>UTTypeConformsTo</key> + <array> + <string>public.plain-text</string> + </array> + <key>UTTypeDescription</key> + <string>Wardley Map written in Map's wmap syntax</string> + <key>UTTypeIcons</key> + <dict/> + <key>UTTypeIdentifier</key> + <string>systems.tranquil.map.wmap</string> + <key>UTTypeTagSpecification</key> + <dict> + <key>public.filename-extension</key> + <array> + <string>wmap</string> + </array> + </dict> + </dict> + </array> </dict> </plist> diff --git a/Map/Logic/Constants.swift b/Map/Logic/Constants.swift new file mode 100644 index 0000000..8cb95e5 --- /dev/null +++ b/Map/Logic/Constants.swift @@ -0,0 +1,4 @@ +struct Constants { + static let kMaxZoom = 2.0 + static let kMinZoom = 0.1 +} diff --git a/Map/Logic/MapParser/MapParser.swift b/Map/Logic/MapParser/MapParser.swift index 5f78d5d..1a66f9a 100644 --- a/Map/Logic/MapParser/MapParser.swift +++ b/Map/Logic/MapParser/MapParser.swift @@ -1,25 +1,60 @@ import CoreGraphics import Foundation +struct MapParser { + static func parse(content: String) -> ParsedMap { + + let parsers = [ + AnyMapParserStrategy(NoteParserStrategy()), + AnyMapParserStrategy(VertexParserStrategy()), + AnyMapParserStrategy(EdgeParserStrategy()), + AnyMapParserStrategy(BlockerParserStrategy()), + AnyMapParserStrategy(OpportunityParserStrategy()), + AnyMapParserStrategy(StageParserStrategy()), + AnyMapParserStrategy(GroupParserStrategy()), + ] + let builder = MapBuilder() + + let lines = content.split(whereSeparator: \.isNewline) + + for (index, line) in lines.enumerated() { + for parser in parsers { + if parser.canHandle(line: String(line)) { + let (type, object) = parser.handle( + index: index, line: String(line), vertices: builder.vertices) + builder.addObjectToMap(type: type, object: object) + break + } + } + } + + return builder.build() + } +} + // MARK: - Types struct MapParsingPatterns { static let vertex = try! NSRegularExpression( pattern: - "([^\\(\\[\\]]*?)[\\s]*\\([\\s]*([0-9]+.?[0-9]*)[\\s]*,[\\s]*([0-9]+.?[0-9]*)[\\s]*\\)[\\s]*(?:\\[(.*?)\\])?", - options: .caseInsensitive) + "^([^\\(\\[\\]]*?)[\\s]*\\([\\s]*([0-9]+.?[0-9]*)[\\s]*,[\\s]*([0-9]+.?[0-9]*)[\\s]*\\)[\\s]*(?:\\[(.*?)\\])?[\\s]*$", + options: [.caseInsensitive, .anchorsMatchLines]) static let edge = try! NSRegularExpression( - pattern: "(.+?)[\\s]*-([->])[\\s]*(.+)", options: .caseInsensitive) + pattern: "^(.+?)[\\s]*-([->])[\\s]*(.+)", options: [.caseInsensitive, .anchorsMatchLines]) static let blocker = try! NSRegularExpression( - pattern: "\\[(Blocker)\\][\\s]*(.+)", options: .caseInsensitive) + pattern: "^\\[(Blocker)\\][\\s]*(.+)", options: [.caseInsensitive, .anchorsMatchLines]) static let opportunity = try! NSRegularExpression( - pattern: "\\[(Evolution)\\][\\s]*(.+)[\\s]+([-+])[\\s]*([0-9]+.?[0-9]*)", - options: .caseInsensitive) + pattern: "^\\[(Evolution)\\][\\s]*(.+)[\\s]+([-+])[\\s]*([0-9]+.?[0-9]*)", + options: [.caseInsensitive, .anchorsMatchLines]) static let note = try! NSRegularExpression( - pattern: "\\[(Note)\\][\\s]*\\([\\s]*([0-9]+.?[0-9]*)[\\s]*,[\\s]*([0-9]+.?[0-9]*)[\\s]*\\)[\\s]*(.*)", - options: .caseInsensitive) + pattern: + "^\\[(Note)\\][\\s]*\\([\\s]*([0-9]+.?[0-9]*)[\\s]*,[\\s]*([0-9]+.?[0-9]*)[\\s]*\\)[\\s]*(.*)", + options: [.caseInsensitive, .anchorsMatchLines]) static let stage = try! NSRegularExpression( - pattern: "\\[(I{1,3})\\][\\s]*([0-9]+.?[0-9]*)", options: .caseInsensitive) + pattern: "^\\[(I{1,3})\\][\\s]*([0-9]+.?[0-9]*)", + options: [.caseInsensitive, .anchorsMatchLines]) + static let group = try! NSRegularExpression( + pattern: "^\\[(Group)\\][\\s]*(.+)", options: [.caseInsensitive, .anchorsMatchLines]) } struct ParsedMap { @@ -29,12 +64,14 @@ struct ParsedMap { let opportunities: [Opportunity] let notes: [Note] let stages: [CGFloat] + let groups: [[Vertex]] static let empty: ParsedMap = ParsedMap( - vertices: [], edges: [], blockers: [], opportunities: [], notes: [], stages: defaultDimensions) + vertices: [], edges: [], blockers: [], opportunities: [], notes: [], stages: defaultDimensions, + groups: []) } -struct Vertex { +struct Vertex: Identifiable, Hashable { let id: Int let label: String let position: CGPoint @@ -115,6 +152,7 @@ class MapBuilder { private var opportunities: [Opportunity] = [] private var notes: [Note] = [] private var stages: [CGFloat] = defaultDimensions + private var groups: [[Vertex]] = [] func addObjectToMap(type: Any.Type, object: Any) { if type == Vertex.self { @@ -136,7 +174,7 @@ class MapBuilder { let opportunity = object as! Opportunity opportunities.append(opportunity) } - + if type == Note.self { let note = object as! Note notes.append(note) @@ -146,12 +184,18 @@ class MapBuilder { let stageDimensions = object as! StageDimensions stages[stageDimensions.index] = stageDimensions.dimensions } + + if type == [Vertex].self { + let group = object as! [Vertex] + groups.append(group) + } } func build() -> ParsedMap { let mappedVertices = vertices.map { label, vertex in return vertex } return ParsedMap( - vertices: mappedVertices, edges: edges, blockers: blockers, opportunities: opportunities, notes: notes, - stages: stages) + vertices: mappedVertices, edges: edges, blockers: blockers, opportunities: opportunities, + notes: notes, + stages: stages, groups: groups) } } diff --git a/Map/Logic/MapParser/Strategies/GroupParserStrategy.swift b/Map/Logic/MapParser/Strategies/GroupParserStrategy.swift new file mode 100644 index 0000000..7924935 --- /dev/null +++ b/Map/Logic/MapParser/Strategies/GroupParserStrategy.swift @@ -0,0 +1,34 @@ +import Foundation + +struct GroupParserStrategy: MapParserStrategy { + private let regex = MapParsingPatterns.group + + func canHandle(line: String) -> Bool { + let range = NSRange(location: 0, length: line.utf16.count) + let matches = regex.matches(in: String(line), options: [], range: range) + return matches.count > 0 && matches[0].numberOfRanges == 3 + } + + func handle(index: Int, line: String, vertices: [String: Vertex]) -> (Any.Type, Any) { + let range = NSRange(location: 0, length: line.utf16.count) + let matches = regex.matches(in: String(line), options: [], range: range) + + let match = matches[0] + var groupVertices: [Vertex] = [] + let vertexIdString = String(line[Range(match.range(at: 2), in: line)!]) + let vertexIds = vertexIdString.split(separator: " ", omittingEmptySubsequences: true).map( + String.init) + + for vertexId in vertexIds { + if let vertex = vertices[vertexId] { + groupVertices.append(vertex) + } + } + + if groupVertices.count > 0 { + return ([Vertex].self, groupVertices) + } + + return (NSObject.self, NSObject()) + } +} diff --git a/Map/Map.entitlements b/Map/Map.entitlements index 6060301..6d968ed 100644 --- a/Map/Map.entitlements +++ b/Map/Map.entitlements @@ -2,19 +2,9 @@ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> - <key>com.apple.developer.aps-environment</key> - <string>development</string> - <key>com.apple.developer.icloud-container-identifiers</key> - <array> - <string>iCloud.pizza.unlimited.map</string> - </array> - <key>com.apple.developer.icloud-services</key> - <array> - <string>CloudKit</string> - </array> - <key>com.apple.security.app-sandbox</key> - <true/> - <key>com.apple.security.files.user-selected.read-write</key> - <true/> + <key>com.apple.security.app-sandbox</key> + <true/> + <key>com.apple.security.files.user-selected.read-write</key> + <true/> </dict> </plist> diff --git a/Map/Map.xcdatamodeld/.xccurrentversion b/Map/Map.xcdatamodeld/.xccurrentversion deleted file mode 100644 index ee72e60..0000000 --- a/Map/Map.xcdatamodeld/.xccurrentversion +++ /dev/null @@ -1,8 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>_XCCurrentVersionName</key> - <string>Map.xcdatamodel</string> -</dict> -</plist> diff --git a/Map/Map.xcdatamodeld/Map.xcdatamodel/contents b/Map/Map.xcdatamodeld/Map.xcdatamodel/contents deleted file mode 100644 index ea8b276..0000000 --- a/Map/Map.xcdatamodeld/Map.xcdatamodel/contents +++ /dev/null @@ -1,12 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="yes"?> -<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D5029f" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier=""> - <entity name="Map" representedClassName="Map" syncable="YES" codeGenerationType="class"> - <attribute name="content" optional="YES" attributeType="String"/> - <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> - <attribute name="title" optional="YES" attributeType="String"/> - <attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/> - </entity> - <elements> - <element name="Map" positionX="-63" positionY="-18" width="128" height="89"/> - </elements> -</model>
\ No newline at end of file diff --git a/Map/MapApp.swift b/Map/MapApp.swift index 14e5605..9aff010 100644 --- a/Map/MapApp.swift +++ b/Map/MapApp.swift @@ -1,23 +1,12 @@ -// -// MapApp.swift -// Map -// -// Created by Ruben Beltran del Rio on 2/1/21. -// - import SwiftUI @main struct MapApp: App { - let persistenceController = PersistenceController.shared - var body: some Scene { - WindowGroup { - MapEditorWindow() - .environment(\.managedObjectContext, persistenceController.container.viewContext) - .environmentObject(AppStore(initialState: AppState(), reducer: appStateReducer)) - }.windowStyle(HiddenTitleBarWindowStyle()).commands { - SidebarCommands() + DocumentGroup(newDocument: MapDocument()) { file in + MapEditor(document: file.$document, url: file.fileURL) + }.commands { + MapCommands() } } } diff --git a/Map/Presentation/Base Components/EvolutionPicker.swift b/Map/Presentation/Base Components/EvolutionPicker.swift index c68e90c..c30798a 100644 --- a/Map/Presentation/Base Components/EvolutionPicker.swift +++ b/Map/Presentation/Base Components/EvolutionPicker.swift @@ -2,40 +2,30 @@ import SwiftUI struct EvolutionPicker: View { - @EnvironmentObject private var store: AppStore - - private var selectedEvolution: Binding<StageType> { - Binding( - get: { store.state.selectedEvolution }, - set: { evolution in - store.send(.selectEvolution(evolution: evolution)) - } - ) - } + @Binding var selectedEvolution: StageType var body: some View { - Picker("Evolution", selection: selectedEvolution) { + Picker("Evolution", selection: $selectedEvolution) { ForEach(StageType.types) { stage in - Text(Stage.title(stage)).tag(stage).padding(4.0) + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) } Divider() ForEach(StageType.characteristics) { stage in - Text(Stage.title(stage)).tag(stage).padding(4.0) + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0).font(.theme.body) } Divider() ForEach(StageType.properties) { stage in - Text(Stage.title(stage)).tag(stage).padding(4.0) + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) } Divider() ForEach(StageType.custom) { stage in - Text(Stage.title(stage)).tag(stage).padding(4.0) + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) } - }.padding(.horizontal, 8.0).padding(.vertical, 4.0) + }.font(.theme.body).padding(.horizontal, 8.0).padding(.vertical, 4.0) } } -struct EvolutionPicker_Previews: PreviewProvider { - static var previews: some View { - EvolutionPicker() - } +#Preview { + let selectedEvolution: StageType = .behavior + EvolutionPicker(selectedEvolution: .constant(selectedEvolution)) } diff --git a/Map/Presentation/Base Components/MapRender/MapAxes.swift b/Map/Presentation/Base Components/MapRender/MapAxes.swift index 6ba758f..1b9c226 100644 --- a/Map/Presentation/Base Components/MapRender/MapAxes.swift +++ b/Map/Presentation/Base Components/MapRender/MapAxes.swift @@ -22,10 +22,14 @@ struct MapAxes: View { }.stroke(Color.map.axisColor, lineWidth: lineWidth * 2) // Y Labels - Text("Visible").font(.theme.axisLabel).foregroundColor(.map.labelColor).rotationEffect(Angle(degrees: -90.0)) - .offset(CGSize(width: -35.0, height: 0.0)) - Text("Invisible").font(.theme.axisLabel).foregroundColor(.map.labelColor).rotationEffect(Angle(degrees: -90.0)) - .offset(CGSize(width: -40.0, height: mapSize.height - 20)) + Text("Visible").font(.theme.axisLabel).foregroundColor(.map.labelColor).rotationEffect( + Angle(degrees: -90.0) + ) + .offset(CGSize(width: -35.0, height: 0.0)) + Text("Invisible").font(.theme.axisLabel).foregroundColor(.map.labelColor).rotationEffect( + Angle(degrees: -90.0) + ) + .offset(CGSize(width: -40.0, height: mapSize.height - 20)) // X Labels @@ -71,11 +75,9 @@ struct MapAxes: View { } } -struct MapAxes_Previews: PreviewProvider { - static var previews: some View { - MapAxes( - mapSize: CGSize(width: 200.0, height: 200.0), lineWidth: CGFloat(1.0), - evolution: Stage.stages(.general), stages: [25.0, 50.0, 75.0] - ).padding(50.0) - } +#Preview { + MapAxes( + mapSize: CGSize(width: 200.0, height: 200.0), lineWidth: CGFloat(1.0), + evolution: Stage.stages(.general), stages: [25.0, 50.0, 75.0] + ).padding(50.0) } diff --git a/Map/Presentation/Base Components/MapRender/MapBlockers.swift b/Map/Presentation/Base Components/MapRender/MapBlockers.swift index d943ba8..efd84fa 100644 --- a/Map/Presentation/Base Components/MapRender/MapBlockers.swift +++ b/Map/Presentation/Base Components/MapRender/MapBlockers.swift @@ -31,13 +31,11 @@ struct MapBlockers: View { } } -struct MapBlockers_Previews: PreviewProvider { - static var previews: some View { - MapBlockers( - mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0), - blockers: [ - Blocker(id: 0, position: CGPoint(x: 50.0, y: 50.0)), - Blocker(id: 1, position: CGPoint(x: 10.0, y: 20.0)), - ]) - } +#Preview { + MapBlockers( + mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0), + blockers: [ + Blocker(id: 0, position: CGPoint(x: 50.0, y: 50.0)), + Blocker(id: 1, position: CGPoint(x: 10.0, y: 20.0)), + ]) } diff --git a/Map/Presentation/Base Components/MapRender/MapEdges.swift b/Map/Presentation/Base Components/MapRender/MapEdges.swift index 3814495..2af7089 100644 --- a/Map/Presentation/Base Components/MapRender/MapEdges.swift +++ b/Map/Presentation/Base Components/MapRender/MapEdges.swift @@ -65,15 +65,13 @@ struct MapEdges: View { } } -struct MapEdges_Previews: PreviewProvider { - static var previews: some View { - MapEdges( - mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0, - vertexSize: CGSize(width: 25.0, height: 25.0), - edges: [ - MapEdge( - id: 1, origin: CGPoint(x: 2.0, y: 34.0), destination: CGPoint(x: 23.0, y: 76.2), - arrowhead: true) - ]) - } +#Preview { + MapEdges( + mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0, + vertexSize: CGSize(width: 25.0, height: 25.0), + edges: [ + MapEdge( + id: 1, origin: CGPoint(x: 2.0, y: 34.0), destination: CGPoint(x: 23.0, y: 76.2), + arrowhead: true) + ]) } diff --git a/Map/Presentation/Base Components/MapRender/MapGroup.swift b/Map/Presentation/Base Components/MapRender/MapGroup.swift new file mode 100644 index 0000000..9df3338 --- /dev/null +++ b/Map/Presentation/Base Components/MapRender/MapGroup.swift @@ -0,0 +1,83 @@ +import ConcaveHull +import SwiftUI + +struct MapGroup: View { + + let mapSize: CGSize + let vertexSize: CGSize + let group: [Vertex] + let color: Color + + let cornerSize = CGSize(width: 2.0, height: 2.0) + var strokeSize: CGFloat { 1.75 * vertexSize.width } + + var hull: [CGPoint] { + let groupList = group.map({ vertex in + return [Double(vertex.position.x), Double(vertex.position.y)] + }) + let hull = Hull() + let hullPoints = hull.hull(groupList, nil) + return hullPoints.compactMap({ object in + if let point = object as? [Double] { + return CGPoint(x: point[0], y: point[1]) + } + return nil + }) + } + + var body: some View { + Path { path in + var initialMove: CGPoint? + + for point in hull { + let offsetPoint = CGPoint(x: w(point.x), y: h(point.y)) + + if initialMove == nil { + path.move(to: offsetPoint) + initialMove = offsetPoint + } else { + path.addLine(to: offsetPoint) + } + } + + if let initialMove = initialMove { + path.addLine(to: initialMove) + } + + } + .applying( + CGAffineTransform(translationX: vertexSize.width / 2.0, y: vertexSize.height / 2.0) + ) + .fill(color) + .stroke( + color, + style: StrokeStyle( + lineWidth: strokeSize, + lineCap: .round, + lineJoin: .round, + miterLimit: 0, + dash: [], + dashPhase: 0 + ) + ) + } + + func h(_ dimension: CGFloat) -> CGFloat { + max(0.0, min(mapSize.height, dimension * mapSize.height / 100.0)) + } + + func w(_ dimension: CGFloat) -> CGFloat { + max(0.0, min(mapSize.width, dimension * mapSize.width / 100.0)) + } +} + +#Preview { + MapGroup( + mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0), + group: [ + Vertex(id: 0, label: "A Circle", position: CGPoint(x: 50.0, y: 50.0)), + Vertex(id: 1, label: "A Square", position: CGPoint(x: 10.0, y: 20.0), shape: .square), + Vertex(id: 2, label: "A triangle", position: CGPoint(x: 25, y: 32.0), shape: .triangle), + Vertex(id: 3, label: "An X", position: CGPoint(x: 70.0, y: 70.0), shape: .x), + ], color: .red) +} diff --git a/Map/Presentation/Base Components/MapRender/MapGroups.swift b/Map/Presentation/Base Components/MapRender/MapGroups.swift new file mode 100644 index 0000000..84f7fb2 --- /dev/null +++ b/Map/Presentation/Base Components/MapRender/MapGroups.swift @@ -0,0 +1,32 @@ +import ConcaveHull +import SwiftUI + +struct MapGroups: View { + + let mapSize: CGSize + let vertexSize: CGSize + let groups: [[Vertex]] + + var body: some View { + ForEach(Array(groups.enumerated()), id: \.element) { index, group in + MapGroup(mapSize: mapSize, vertexSize: vertexSize, group: group, color: color(index)) + } + } + + private func color(_ index: Int) -> Color { + return .map.groupColors[index % Color.map.groupColors.count] + } +} + +#Preview { + MapGroups( + mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0), + groups: [ + [ + Vertex(id: 0, label: "A Circle", position: CGPoint(x: 50.0, y: 50.0)), + Vertex(id: 1, label: "A Square", position: CGPoint(x: 10.0, y: 20.0), shape: .square), + Vertex(id: 2, label: "A triangle", position: CGPoint(x: 25, y: 32.0), shape: .triangle), + Vertex(id: 3, label: "An X", position: CGPoint(x: 70.0, y: 70.0), shape: .x), + ] + ]) +} diff --git a/Map/Presentation/Base Components/MapRender/MapNotes.swift b/Map/Presentation/Base Components/MapRender/MapNotes.swift index f35b3fe..bbb1aba 100644 --- a/Map/Presentation/Base Components/MapRender/MapNotes.swift +++ b/Map/Presentation/Base Components/MapRender/MapNotes.swift @@ -5,13 +5,12 @@ struct MapNotes: View { let mapSize: CGSize let lineWidth: CGFloat let notes: [Note] - + let maxWidth = 400.0 - var body: some View { ForEach(notes, id: \.id) { note in - Text(note.text.replacingOccurrences(of: "\\n", with: "\n")).font(.theme.axisLabel) + Text(note.text.replacingOccurrences(of: "\\n", with: "\n")).font(.theme.note) .padding(2.0) .background(.white) .foregroundColor(.map.labelColor) @@ -35,12 +34,13 @@ struct MapNotes: View { } } -struct MapNotes_Previews: PreviewProvider { - static var previews: some View { - MapNotes( - mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0, - notes: [ - Note(id: 0, position: CGPoint(x: 50.0, y: 50.0), text: "Notes can have a lot more text, so we need to make sure that they're resized correctly"), - ]) - } +#Preview { + MapNotes( + mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0, + notes: [ + Note( + id: 0, position: CGPoint(x: 50.0, y: 50.0), + text: + "Notes can have a lot more text, so we need to make sure that they're resized correctly") + ]) } diff --git a/Map/Presentation/Base Components/MapRender/MapOpportunities.swift b/Map/Presentation/Base Components/MapRender/MapOpportunities.swift index 7fcadff..b3051ee 100644 --- a/Map/Presentation/Base Components/MapRender/MapOpportunities.swift +++ b/Map/Presentation/Base Components/MapRender/MapOpportunities.swift @@ -46,7 +46,8 @@ struct MapOpportunities: View { path.closeSubpath() }.applying( CGAffineTransform(translationX: vertexSize.width / 2.0, y: vertexSize.height / 2.0) - ).strokedPath(StrokeStyle(lineWidth: lineWidth / 4, dash: [10.0])).stroke(Color.map.opportunityColor) + ).strokedPath(StrokeStyle(lineWidth: lineWidth / 4, dash: [10.0])).stroke( + Color.map.opportunityColor) } } @@ -59,13 +60,11 @@ struct MapOpportunities: View { } } -struct MapOpportunities_Previews: PreviewProvider { - static var previews: some View { - MapOpportunities( - mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0, - vertexSize: CGSize(width: 25.0, height: 25.0), - opportunities: [ - Opportunity(id: 1, origin: CGPoint(x: 2.0, y: 34.0), destination: CGPoint(x: 23.0, y: 76.2)) - ]) - } +#Preview { + MapOpportunities( + mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0, + vertexSize: CGSize(width: 25.0, height: 25.0), + opportunities: [ + Opportunity(id: 1, origin: CGPoint(x: 2.0, y: 34.0), destination: CGPoint(x: 23.0, y: 76.2)) + ]) } diff --git a/Map/Presentation/Base Components/MapRender/MapStages.swift b/Map/Presentation/Base Components/MapRender/MapStages.swift index 0fc8f48..fc3bfa1 100644 --- a/Map/Presentation/Base Components/MapRender/MapStages.swift +++ b/Map/Presentation/Base Components/MapRender/MapStages.swift @@ -1,5 +1,5 @@ -import SwiftUI import Patterns +import SwiftUI struct MapStages: View { @@ -10,17 +10,29 @@ struct MapStages: View { var body: some View { ZStack(alignment: .topLeading) { - PatternView(design: .constant(.stitch), pixelSize: 1.0, foregroundColor: .map.stageForeground, backgroundColor: .map.stageBackground) - .frame(width: w(stages[0]), height: mapSize.height) - PatternView(design: .constant(.shingles), pixelSize: 1.0, foregroundColor: .map.stageForeground, backgroundColor: .map.stageBackground) - .offset(CGSize(width: w(stages[0]), height: 0)) - .frame(width: w(stages[1]) - w(stages[0]), height: mapSize.height) - PatternView(design: .constant(.shadowGrid), pixelSize: 1.0, foregroundColor: .map.stageForeground, backgroundColor: .map.stageBackground) - .offset(CGSize(width: w(stages[1]), height: 0)) - .frame(width: w(stages[2]) - w(stages[1]), height: mapSize.height) - PatternView(design: .constant(.wicker), pixelSize: 1.0, foregroundColor: .map.stageForeground, backgroundColor: .map.stageBackground) - .offset(CGSize(width: w(stages[2]), height: 0)) - .frame(width: mapSize.width - w(stages[2]), height: mapSize.height) + PatternView( + design: .constant(.stitch), pixelSize: 1.0, foregroundColor: .map.stageForeground, + backgroundColor: .map.stageBackground + ) + .frame(width: w(stages[0]), height: mapSize.height) + PatternView( + design: .constant(.shingles), pixelSize: 1.0, foregroundColor: .map.stageForeground, + backgroundColor: .map.stageBackground + ) + .offset(CGSize(width: w(stages[0]), height: 0)) + .frame(width: w(stages[1]) - w(stages[0]), height: mapSize.height) + PatternView( + design: .constant(.shadowGrid), pixelSize: 1.0, foregroundColor: .map.stageForeground, + backgroundColor: .map.stageBackground + ) + .offset(CGSize(width: w(stages[1]), height: 0)) + .frame(width: w(stages[2]) - w(stages[1]), height: mapSize.height) + PatternView( + design: .constant(.wicker), pixelSize: 1.0, foregroundColor: .map.stageForeground, + backgroundColor: .map.stageBackground + ) + .offset(CGSize(width: w(stages[2]), height: 0)) + .frame(width: mapSize.width - w(stages[2]), height: mapSize.height) Path { path in path.move(to: CGPoint(x: w(stages[0]), y: 0)) @@ -34,7 +46,8 @@ struct MapStages: View { path.closeSubpath() path.move(to: CGPoint(x: w(stages[0]), y: 0)) path.closeSubpath() - }.strokedPath(StrokeStyle(lineWidth: lineWidth / 4, dash: [10.0, 18.0])).stroke(Color.map.axisColor) + }.strokedPath(StrokeStyle(lineWidth: lineWidth / 4, dash: [10.0, 18.0])).stroke( + Color.map.axisColor) } } @@ -43,10 +56,8 @@ struct MapStages: View { } } -struct MapStages_Previews: PreviewProvider { - static var previews: some View { - MapStages( - mapSize: CGSize(width: 200.0, height: 200.0), lineWidth: CGFloat(0.5), - stages: [25.0, 50.0, 75.0]) - } +#Preview { + MapStages( + mapSize: CGSize(width: 200.0, height: 200.0), lineWidth: CGFloat(0.5), + stages: [25.0, 50.0, 75.0]) } diff --git a/Map/Presentation/Base Components/MapRender/MapVertices.swift b/Map/Presentation/Base Components/MapRender/MapVertices.swift index 74cac6d..5bc0a96 100644 --- a/Map/Presentation/Base Components/MapRender/MapVertices.swift +++ b/Map/Presentation/Base Components/MapRender/MapVertices.swift @@ -7,18 +7,29 @@ struct MapVertices: View { let vertices: [Vertex] let padding = CGFloat(5.0) + var onDragVertex: (Vertex, CGFloat, CGFloat) -> Void = { _, _, _ in } + var body: some View { ZStack(alignment: .topLeading) { ForEach(vertices, id: \.id) { vertex in - getVertexShape(vertex).fill(Color.map.vertexColor) - Text(vertex.label.replacingOccurrences(of: "\\n", with: "\n")).font(.theme.vertexLabel) + ZStack(alignment: .topLeading) { + getVertexShape(vertex).fill(Color.map.vertexColor) + Text(vertex.label.replacingOccurrences(of: "\\n", with: "\n")).font(.theme.vertexLabel) .foregroundColor(.map.labelColor) .shadow(color: .white, radius: 0, x: -0.5, y: -0.5) .shadow(color: .white, radius: 0, x: 0.5, y: 0.5) .offset( - CGSize( - width: w(vertex.position.x) + vertexSize.width + padding, - height: h(vertex.position.y) + 7.0)) + CGSize( + width: w(vertex.position.x) + vertexSize.width + padding, + height: h(vertex.position.y) + 7.0)) + }.gesture( + DragGesture() + .onChanged { value in + let deltaX = value.startLocation.x - value.location.x + let deltaY = value.startLocation.y - value.location.y + onDragVertex(vertex, deltaX, deltaY) + } + ) } } } @@ -78,15 +89,13 @@ struct MapVertices: View { } } -struct MapVertices_Previews: PreviewProvider { - static var previews: some View { - MapVertices( - mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0), - vertices: [ - Vertex(id: 0, label: "A Circle", position: CGPoint(x: 50.0, y: 50.0)), - Vertex(id: 1, label: "A Square", position: CGPoint(x: 10.0, y: 20.0), shape: .square), - Vertex(id: 2, label: "A triangle", position: CGPoint(x: 25, y: 32.0), shape: .triangle), - Vertex(id: 3, label: "An X", position: CGPoint(x: 70.0, y: 70.0), shape: .x), - ]) - } +#Preview { + MapVertices( + mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0), + vertices: [ + Vertex(id: 0, label: "A Circle", position: CGPoint(x: 50.0, y: 50.0)), + Vertex(id: 1, label: "A Square", position: CGPoint(x: 10.0, y: 20.0), shape: .square), + Vertex(id: 2, label: "A triangle", position: CGPoint(x: 25, y: 32.0), shape: .triangle), + Vertex(id: 3, label: "An X", position: CGPoint(x: 70.0, y: 70.0), shape: .x), + ]) } diff --git a/Map/Presentation/Base Components/MapTextEditor.swift b/Map/Presentation/Base Components/MapTextEditor.swift index f3838a6..d7aa9f8 100644 --- a/Map/Presentation/Base Components/MapTextEditor.swift +++ b/Map/Presentation/Base Components/MapTextEditor.swift @@ -3,7 +3,7 @@ import SwiftUI class MapTextEditorController: NSViewController { - @Binding var text: String + @Binding var document: MapDocument let onChange: () -> Void private let vertexRegex = MapParsingPatterns.vertex @@ -12,11 +12,12 @@ class MapTextEditorController: NSViewController { private let opportunityRegex = MapParsingPatterns.opportunity private let noteRegex = MapParsingPatterns.note private let stageRegex = MapParsingPatterns.stage + private let groupRegex = MapParsingPatterns.group private let changeDebouncer: Debouncer = Debouncer(seconds: 1) - init(text: Binding<String>, onChange: @escaping () -> Void) { - self._text = text + init(document: Binding<MapDocument>, onChange: @escaping () -> Void) { + self._document = document self.onChange = onChange super.init(nibName: nil, bundle: nil) } @@ -31,10 +32,11 @@ class MapTextEditorController: NSViewController { scrollView.translatesAutoresizingMaskIntoConstraints = false + textView.backgroundColor = .ui.background textView.allowsUndo = true textView.delegate = self textView.textStorage?.delegate = self - textView.string = self.text + textView.string = self.document.text textView.isEditable = true textView.font = .monospacedSystemFont(ofSize: 16.0, weight: .regular) self.view = scrollView @@ -49,14 +51,13 @@ extension MapTextEditorController: NSTextViewDelegate { func textDidChange(_ obj: Notification) { if let textField = obj.object as? NSTextView { - self.text = textField.string - - - changeDebouncer.debounce { - DispatchQueue.main.async { - self.onChange() - } + self.document.text = textField.string + + changeDebouncer.debounce { + DispatchQueue.main.async { + self.onChange() } + } } } @@ -86,65 +87,91 @@ extension MapTextEditorController: NSTextStorageDelegate { var matches = vertexRegex.matches(in: textStorage.string, options: [], range: range) for match in matches { - textStorage.addAttributes([.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 1)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.number], range: match.range(at: 3)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.option], range: match.range(at: 4)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.number], range: match.range(at: 3)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.option], range: match.range(at: 4)) } matches = edgeRegex.matches(in: textStorage.string, options: [], range: range) for match in matches { - textStorage.addAttributes([.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 1)) let arrowRange = match.range(at: 2) textStorage.addAttributes( [.foregroundColor: NSColor.syntax.symbol], range: NSMakeRange(arrowRange.lowerBound - 1, arrowRange.length + 1)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 3)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 3)) } matches = opportunityRegex.matches(in: textStorage.string, options: [], range: range) for match in matches { - textStorage.addAttributes([.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 2)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.symbol], range: match.range(at: 3)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.number], range: match.range(at: 4)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 2)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.symbol], range: match.range(at: 3)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.number], range: match.range(at: 4)) } matches = blockerRegex.matches(in: textStorage.string, options: [], range: range) for match in matches { - textStorage.addAttributes([.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 2)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 2)) } - + matches = noteRegex.matches(in: textStorage.string, options: [], range: range) for match in matches { - textStorage.addAttributes([.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.number], range: match.range(at: 3)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.number], range: match.range(at: 3)) } matches = stageRegex.matches(in: textStorage.string, options: [], range: range) for match in matches { - textStorage.addAttributes([.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) + } + + matches = groupRegex.matches(in: textStorage.string, options: [], range: range) + + for match in matches { + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 2)) } } } struct MapTextEditor: NSViewControllerRepresentable { - @Binding var text: String + @Binding var document: MapDocument var onChange: () -> Void = {} func makeNSViewController( context: NSViewControllerRepresentableContext<MapTextEditor> ) -> MapTextEditorController { - return MapTextEditorController(text: $text, onChange: onChange) + return MapTextEditorController(document: $document, onChange: onChange) } func updateNSViewController( diff --git a/Map/Presentation/Commands/MapCommands.swift b/Map/Presentation/Commands/MapCommands.swift new file mode 100644 index 0000000..32b4958 --- /dev/null +++ b/Map/Presentation/Commands/MapCommands.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct MapCommands: Commands { + + @AppStorage("viewStyle") var viewStyle: ViewStyle = .horizontal + @AppStorage("zoom") var zoom = 1.0 + + var body: some Commands { + + // View + + CommandGroup(after: CommandGroupPlacement.toolbar) { + if viewStyle == .horizontal { + Button("Use Vertical Layout") { + viewStyle = .vertical + }.keyboardShortcut( + "l", modifiers: EventModifiers([.command]) + ) + } else { + Button("Use Horizontal Layout") { + viewStyle = .horizontal + }.keyboardShortcut( + "l", modifiers: EventModifiers([.command]) + ) + } + Divider() + Button("Zoom In") { + zoom = min(Constants.kMaxZoom, zoom + 0.1) + }.keyboardShortcut( + "+", modifiers: EventModifiers([.command]) + ) + Button("Zoom Out") { + zoom = max(Constants.kMinZoom, zoom - 0.1) + }.keyboardShortcut( + "-", modifiers: EventModifiers([.command]) + ) + Divider() + } + + CommandGroup(replacing: CommandGroupPlacement.help) { + Button("Map Help") { NSWorkspace.shared.open(URL(string: "https://map.tranquil.systems")!) } + } + } +} diff --git a/Map/Presentation/Complex Components/MapRender/MapRenderView.swift b/Map/Presentation/Complex Components/MapRender/MapRenderView.swift index b12dabc..b256861 100644 --- a/Map/Presentation/Complex Components/MapRender/MapRenderView.swift +++ b/Map/Presentation/Complex Components/MapRender/MapRenderView.swift @@ -5,20 +5,24 @@ import SwiftUI struct MapRenderView: View { - @Binding var content: String + @Binding var document: MapDocument @Binding var evolution: StageType - + var stage: Stage { Stage.stages(evolution) } - @State var parsedMap: ParsedMap = ParsedMap.empty + var parsedMap: ParsedMap { + MapParser.parse(content: document.text) + } - let mapSize = CGSize(width: 1300.0, height: 1000.0) + let mapSize = Dimensions.mapSize + let padding = Dimensions.mapPadding let lineWidth = CGFloat(0.5) let vertexSize = CGSize(width: 25.0, height: 25.0) - let padding = CGFloat(30.0) + + var onDragVertex: (Vertex, CGFloat, CGFloat) -> Void = { _, _, _ in } var body: some View { ZStack(alignment: .topLeading) { @@ -36,28 +40,27 @@ struct MapRenderView: View { MapEdges( mapSize: mapSize, lineWidth: lineWidth, vertexSize: vertexSize, edges: parsedMap.edges) MapBlockers(mapSize: mapSize, vertexSize: vertexSize, blockers: parsedMap.blockers) - MapVertices(mapSize: mapSize, vertexSize: vertexSize, vertices: parsedMap.vertices) + MapVertices( + mapSize: mapSize, vertexSize: vertexSize, vertices: parsedMap.vertices, + onDragVertex: onDragVertex) MapOpportunities( mapSize: mapSize, lineWidth: lineWidth, vertexSize: vertexSize, opportunities: parsedMap.opportunities) + MapGroups(mapSize: mapSize, vertexSize: vertexSize, groups: parsedMap.groups).drawingGroup( + opaque: true + ).opacity(0.1) MapNotes( mapSize: mapSize, lineWidth: lineWidth, notes: parsedMap.notes) - }.frame( - width: mapSize.width, + }.offset(x: padding, y: padding).frame( + width: mapSize.width + 2 * padding, height: mapSize.height + 2 * padding, alignment: .topLeading - ).onAppear { - self.parsedMap = Map.parse(content: content) - }.padding(padding).onChange(of: content) { newState in - self.parsedMap = Map.parse(content: newState) - } + ) } } -struct MapRenderView_Previews: PreviewProvider { - static var previews: some View { - MapRenderView( - content: Binding.constant(""), evolution: Binding.constant(StageType.general) - ).environment( - \.managedObjectContext, PersistenceController.preview.container.viewContext) - } +#Preview { + MapRenderView( + document: Binding.constant(MapDocument(text: "")), + evolution: Binding.constant(StageType.general) + ) } diff --git a/Map/Presentation/EvolutionPicker.swift b/Map/Presentation/EvolutionPicker.swift new file mode 100644 index 0000000..0f4c954 --- /dev/null +++ b/Map/Presentation/EvolutionPicker.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct EvolutionPicker: View { + + @EnvironmentObject private var store: AppStore + + private var selectedEvolution: Binding<StageType> { + Binding( + get: { store.state.selectedEvolution }, + set: { evolution in + store.send(.selectEvolution(evolution: evolution)) + } + ) + } + + var body: some View { + Picker("Evolution", selection: selectedEvolution) { + ForEach(StageType.types) { stage in + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) + } + Divider() + ForEach(StageType.characteristics) { stage in + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0).font(.theme.body) + } + Divider() + ForEach(StageType.properties) { stage in + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) + } + Divider() + ForEach(StageType.custom) { stage in + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) + } + }.font(.theme.body).padding(.horizontal, 8.0).padding(.vertical, 4.0) + } +} + +struct EvolutionPicker_Previews: PreviewProvider { + static var previews: some View { + EvolutionPicker() + } +} diff --git a/Map/Presentation/MapEditor.swift b/Map/Presentation/MapEditor.swift new file mode 100644 index 0000000..bf33f75 --- /dev/null +++ b/Map/Presentation/MapEditor.swift @@ -0,0 +1,134 @@ +import SwiftUI + +struct MapEditor: View { + @Binding var document: MapDocument + var url: URL? + @State var selectedEvolution: StageType = .behavior + + @AppStorage("viewStyle") var viewStyle: ViewStyle = .horizontal + + let zoomRange = Constants.kMinZoom...Constants.kMaxZoom + @AppStorage("zoom") var zoom = 1.0 + @State var lastZoom = 1.0 + + var body: some View { + VStack(spacing: 0) { + adaptiveStack { + ZStack(alignment: .topLeading) { + MapTextEditor(document: $document) + .background(Color.ui.background) + .foregroundColor(Color.ui.foreground) + .frame(minHeight: 96.0) + }.padding(.top, 8.0).padding(.leading, 8.0).background(Color.ui.background).cornerRadius( + 5.0) + GeometryReader { geometry in + ScrollView([.horizontal, .vertical]) { + MapRenderView( + document: $document, evolution: $selectedEvolution, onDragVertex: onDragVertex + ).scaleEffect(zoom, anchor: .center).frame( + width: (Dimensions.mapSize.width + 2 * Dimensions.mapPadding) * zoom, + height: (Dimensions.mapSize.height + 2 * Dimensions.mapPadding) * zoom) + }.background(Color.ui.background) + .gesture( + MagnificationGesture() + .onChanged { value in + let delta = value / lastZoom + lastZoom = value + zoom = min(max(zoom * delta, zoomRange.lowerBound), zoomRange.upperBound) + } + .onEnded { _ in + lastZoom = 1.0 + } + ) + } + } + Divider() + HStack { + Spacer() + Slider( + value: $zoom, in: zoomRange, step: 0.1, + label: { + Text(formatZoom(zoom)) + .font(.theme.smallControl) + }, + minimumValueLabel: { + Image(systemName: "minus.magnifyingglass") + .font(.theme.smallControl) + .help("Zoom Out (⌘-)") + }, + maximumValueLabel: { + Image(systemName: "plus.magnifyingglass") + .font(.theme.smallControl) + .help("Zoom In (⌘+)") + } + ).frame(width: 200).padding(.trailing, 10.0) + }.padding(4.0) + }.toolbar { + HStack { + Button(action: saveImage) { + Image(systemName: "photo") + } + .help("Export Image (⌘E)") + .padding(.vertical, 4.0).padding(.leading, 4.0).padding(.trailing, 8.0) + } + EvolutionPicker(selectedEvolution: $selectedEvolution) + } + } + + @ViewBuilder + func adaptiveStack<Content: View>(@ViewBuilder content: () -> Content) -> some View { + if viewStyle == .horizontal { + VSplitView { + content() + } + } else { + HSplitView { + content() + } + } + } + + private func formatZoom(_ number: CGFloat) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 1 + formatter.minimumFractionDigits = 1 + return (formatter.string(from: NSNumber(value: number)) ?? "") + "x" + } + + private func onDragVertex(vertex: Vertex, x: CGFloat, y: CGFloat) { + print("Dragging: \(vertex), \(x), \(y)") + } + + private func saveImage() { + if let image = document.exportAsImage(withEvolution: selectedEvolution) { + + let filename = url?.deletingPathExtension().lastPathComponent ?? "Untitled" + + let savePanel = NSSavePanel() + savePanel.allowedContentTypes = [.png] + savePanel.canCreateDirectories = true + savePanel.isExtensionHidden = false + savePanel.title = "Save \(filename) as image" + savePanel.message = "Choose a location to save the image" + savePanel.nameFieldStringValue = "\(filename).png" + savePanel.begin { result in + if result == .OK, let url = savePanel.url { + if let tiffRepresentation = image.tiffRepresentation { + let bitmapImage = NSBitmapImageRep(data: tiffRepresentation) + let pngData = bitmapImage?.representation(using: .png, properties: [:]) + do { + try pngData?.write(to: url) + } catch { + return + } + } + } + } + } + } +} + +#Preview { + MapEditor(document: .constant(MapDocument()), url: URL(filePath: "test.png")!) +} diff --git a/Map/Presentation/Screens/EmptyMapDetailScreen.swift b/Map/Presentation/Screens/EmptyMapDetailScreen.swift deleted file mode 100644 index f3c6d75..0000000 --- a/Map/Presentation/Screens/EmptyMapDetailScreen.swift +++ /dev/null @@ -1,16 +0,0 @@ -import CoreData -import SwiftUI - -struct EmptyMapDetailScreen: View { - - var body: some View { - Text("Select a map from the left hand side, or click on + to create one.") - } -} - -struct DefaultMapView_Previews: PreviewProvider { - static var previews: some View { - EmptyMapDetailScreen().environment( - \.managedObjectContext, PersistenceController.preview.container.viewContext) - } -} diff --git a/Map/Presentation/Screens/MapDetailScreen.swift b/Map/Presentation/Screens/MapDetailScreen.swift deleted file mode 100644 index a5c32fd..0000000 --- a/Map/Presentation/Screens/MapDetailScreen.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Combine -import CoreData -import SwiftUI - -struct MapDetailScreen: View { - @Environment(\.managedObjectContext) private var viewContext - - @EnvironmentObject var store: AppStore - - @ObservedObject var map: Map - - @State var title: String - @State var content: String - - var body: some View { - if map.uuid != nil { - VSplitView { - VStack { - HStack { - TextField( - "Title", text: $title, onCommit: saveModel - ).font(.title2).textFieldStyle(PlainTextFieldStyle()).padding(.vertical, 4.0).padding( - .leading, 4.0) - Button(action: saveText) { - Image(systemName: "doc.text") - }.padding(.vertical, 4.0).padding(.leading, 4.0) - Button(action: saveImage) { - Image(systemName: "photo") - }.padding(.vertical, 4.0).padding(.leading, 4.0).padding(.trailing, 8.0) - } - EvolutionPicker() - - ZStack(alignment: .topLeading) { - MapTextEditor(text: $content, onChange: saveModel) - .background(Color.ui.background) - .foregroundColor(Color.ui.foreground) - .frame(minHeight: 96.0) - }.padding(.top, 8.0).padding(.leading, 8.0).background(Color.ui.background).cornerRadius( - 5.0) - }.padding(.horizontal, 8.0) - ScrollView([.horizontal, .vertical]) { - MapRenderView(content: $content, evolution: .constant(store.state.selectedEvolution)) - } - }.onDisappear { - saveModel() - } - } else { - EmptyMapDetailScreen() - } - } - - private func saveModel() { - map.content = content - map.title = title - try? viewContext.save() - } - - private func saveText() { - store.send(.exportMapAsText(map: map)) - } - - private func saveImage() { - store.send(.exportMapAsImage(map: map)) - } -} - -struct MapDetailView_Previews: PreviewProvider { - static var previews: some View { - MapDetailScreen(map: Map(), title: "", content: "").environment( - \.managedObjectContext, PersistenceController.preview.container.viewContext) - } -} diff --git a/Map/Core Extensions/Color+Theme.swift b/Map/Presentation/Theme/Color+theme.swift index ffbd224..1bd9b8e 100644 --- a/Map/Core Extensions/Color+Theme.swift +++ b/Map/Presentation/Theme/Color+theme.swift @@ -5,11 +5,15 @@ extension Color { static let darkSlate = Color("Dark Slate") static let jasperRed = Color("Jasper Red") static let olympicBlue = Color("Olympic Blue") + static let lightPorcelainGreen = Color("Light Porcelain Green") + static let naplesYellow = Color("Naples Yellow") + static let hermosaPink = Color("Hermosa Pink") static let neutralGray = Color("Neutral Gray") static let lightNeutralGray = Color("Light Neutral Gray") static let darkNeutralGray = Color("Dark Neutral Gray") + static let darkerNeutralGray = Color("Darker Neutral Gray") } - + struct map { static let labelColor = Color.theme.darkSlate static let axisColor = Color.theme.darkSlate @@ -18,8 +22,15 @@ extension Color { static let opportunityColor = Color.theme.olympicBlue static let stageForeground = Color.theme.lightNeutralGray static let stageBackground = Color.white + static let groupColors = [ + Color.theme.olympicBlue, + Color.theme.jasperRed, + Color.theme.lightPorcelainGreen, + Color.theme.naplesYellow, + Color.theme.hermosaPink, + ] } - + struct ui { static let foreground = Color("Foreground") static let background = Color("Background") diff --git a/Map/Presentation/Theme/Dimensions.swift b/Map/Presentation/Theme/Dimensions.swift new file mode 100644 index 0000000..6a58d58 --- /dev/null +++ b/Map/Presentation/Theme/Dimensions.swift @@ -0,0 +1,6 @@ +import Foundation + +struct Dimensions { + static let mapSize = CGSize(width: 1300.0, height: 1000.0) + static let mapPadding: CGFloat = 42.0 +} diff --git a/Map/Presentation/Theme/Font+theme.swift b/Map/Presentation/Theme/Font+theme.swift new file mode 100644 index 0000000..07e3b9b --- /dev/null +++ b/Map/Presentation/Theme/Font+theme.swift @@ -0,0 +1,15 @@ +import SwiftUI + +extension Font { + public struct theme { + + // Map + static let note = Font.system(size: 12, design: .serif) + static let axisLabel = Font.system(size: 14, design: .serif) + static let vertexLabel = Font.system(size: 12, design: .serif) + + // UI + static let smallControl = Font.system(size: 9) + static let body = Font.system(size: 13) + } +} diff --git a/Map/Core Extensions/NSColor+Theme.swift b/Map/Presentation/Theme/NSColor+theme.swift index 0ef1094..d30332a 100644 --- a/Map/Core Extensions/NSColor+Theme.swift +++ b/Map/Presentation/Theme/NSColor+theme.swift @@ -7,4 +7,8 @@ extension NSColor { static let option = NSColor(named: "Option") ?? .textColor static let symbol = NSColor(named: "Symbol") ?? .textColor } + + struct ui { + static let background = NSColor(named: "Background") ?? .windowBackgroundColor + } } diff --git a/Map/Presentation/ViewStyle.swift b/Map/Presentation/ViewStyle.swift new file mode 100644 index 0000000..cea3113 --- /dev/null +++ b/Map/Presentation/ViewStyle.swift @@ -0,0 +1,3 @@ +enum ViewStyle: String { + case vertical, horizontal +} diff --git a/Map/Presentation/Windows/MapEditorWindow.swift b/Map/Presentation/Windows/MapEditorWindow.swift deleted file mode 100644 index 65316cc..0000000 --- a/Map/Presentation/Windows/MapEditorWindow.swift +++ /dev/null @@ -1,109 +0,0 @@ -import CoreData -import SwiftUI - -struct MapEditorWindow: View { - @Environment(\.managedObjectContext) private var viewContext - - @EnvironmentObject var store: AppStore - - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \Map.createdAt, ascending: true)], - animation: .default) - private var maps: FetchedResults<Map> - - var body: some View { - NavigationView { - List { - if maps.count == 0 { - EmptyMapDetailScreen() - } - ForEach(maps) { map in - NavigationLink( - destination: MapDetailScreen(map: map, title: map.title ?? "", content: map.content ?? "") - ) { - HStack { - Text(map.title ?? "Untitled Map") - Spacer() - Text(mapFormatter.string(from: (map.createdAt ?? Date()))) - .font(.caption) - .padding(.vertical, 2.0) - .padding(.horizontal, 4.0) - .background(Color.accentColor) - .foregroundColor(Color.black) - .cornerRadius(2.0) - }.padding(.leading, 8.0) - }.contextMenu { - Button( - action: { store.send(.deleteMap(map: map)) }, - label: { - Image(systemName: "trash") - Text("Delete") - }) - } - } - .onDelete(perform: deleteMaps) - }.frame(minWidth: 250.0, alignment: .leading) - .toolbar { - HStack { - Button(action: toggleSidebar) { - Label("Toggle Sidebar", systemImage: "sidebar.left") - } - Button(action: addMap) { - Label("Add Map", systemImage: "plus") - } - } - } - EmptyMapDetailScreen() - } - } - - private func toggleSidebar() { - NSApp.keyWindow?.firstResponder?.tryToPerform( - #selector(NSSplitViewController.toggleSidebar(_:)), with: nil) - } - - private func addMap() { - withAnimation { - let newMap = Map(context: viewContext) - newMap.uuid = UUID() - newMap.createdAt = Date() - newMap.title = "Map \(newMap.createdAt!.format())" - newMap.content = "" - - do { - try viewContext.save() - } catch { - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") - } - } - } - - private func deleteMaps(offsets: IndexSet) { - - withAnimation { - offsets.map { maps[$0] }.forEach(viewContext.delete) - - do { - try viewContext.save() - } catch { - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") - } - } - } -} - -private let mapFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .none - return formatter -}() - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - MapEditorWindow().environment( - \.managedObjectContext, PersistenceController.preview.container.viewContext) - } -} |