From: Ruben Beltran del Rio Date: Thu, 4 Feb 2021 22:46:55 +0000 (+0100) Subject: Release 1.1.0 X-Git-Tag: 1.1.0 X-Git-Url: https://git.r.bdr.sh/rbdr/map/commitdiff_plain/77d0155b661e813a85a7312ed809fc7e5a9f7440?hp=491463f421c86d3a7a5482108b818aa5b5e441ea Release 1.1.0 --- diff --git a/Map.xcodeproj/project.pbxproj b/Map.xcodeproj/project.pbxproj index 275bb9e..12a5d1c 100644 --- a/Map.xcodeproj/project.pbxproj +++ b/Map.xcodeproj/project.pbxproj @@ -33,6 +33,14 @@ B539516C25CB0C9300959F72 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = B539516B25CB0C9200959F72 /* Store.swift */; }; B539517425CB0CA400959F72 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B539517325CB0CA400959F72 /* AppState.swift */; }; B539518125CB2D7A00959F72 /* MapTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B539518025CB2D7A00959F72 /* MapTextEditor.swift */; }; + B5CF75C925CC19FC003BFF3D /* EvolutionPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CF75C825CC19FC003BFF3D /* EvolutionPicker.swift */; }; + B5CF75CF25CC7965003BFF3D /* MapParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CF75CE25CC7965003BFF3D /* MapParser.swift */; }; + B5CF75D825CC79BC003BFF3D /* VertexParserStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CF75D725CC79BC003BFF3D /* VertexParserStrategy.swift */; }; + B5CF75DD25CC79D7003BFF3D /* EdgeParserStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CF75DC25CC79D7003BFF3D /* EdgeParserStrategy.swift */; }; + B5CF75E225CC79ED003BFF3D /* BlockerParserStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CF75E125CC79ED003BFF3D /* BlockerParserStrategy.swift */; }; + B5CF75EA25CC7A13003BFF3D /* OpportunityParserStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CF75E925CC7A13003BFF3D /* OpportunityParserStrategy.swift */; }; + B5CF75EF25CC7A4A003BFF3D /* StageParserStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CF75EE25CC7A4A003BFF3D /* StageParserStrategy.swift */; }; + B5CF75F725CC97CA003BFF3D /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CF75F625CC97CA003BFF3D /* Debouncer.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -86,6 +94,14 @@ B539516B25CB0C9200959F72 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; B539517325CB0CA400959F72 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; B539518025CB2D7A00959F72 /* MapTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTextEditor.swift; sourceTree = ""; }; + B5CF75C825CC19FC003BFF3D /* EvolutionPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvolutionPicker.swift; sourceTree = ""; }; + B5CF75CE25CC7965003BFF3D /* MapParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapParser.swift; sourceTree = ""; }; + B5CF75D725CC79BC003BFF3D /* VertexParserStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VertexParserStrategy.swift; sourceTree = ""; }; + B5CF75DC25CC79D7003BFF3D /* EdgeParserStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EdgeParserStrategy.swift; sourceTree = ""; }; + B5CF75E125CC79ED003BFF3D /* BlockerParserStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockerParserStrategy.swift; sourceTree = ""; }; + B5CF75E925CC7A13003BFF3D /* OpportunityParserStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpportunityParserStrategy.swift; sourceTree = ""; }; + B5CF75EE25CC7A4A003BFF3D /* StageParserStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StageParserStrategy.swift; sourceTree = ""; }; + B5CF75F625CC97CA003BFF3D /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -130,7 +146,6 @@ B523C76625CA071B00C44061 /* MapVertices.swift */, B523C76B25CA0DFA00C44061 /* MapEdges.swift */, B523C77025CA121300C44061 /* MapBlockers.swift */, - B523C77525CA181100C44061 /* MapColor.swift */, B523C77D25CA294C00C44061 /* MapOpportunities.swift */, ); path = MapRenderComponents; @@ -160,6 +175,8 @@ B526257025C874F9003E73B7 /* Map */ = { isa = PBXGroup; children = ( + B523C77525CA181100C44061 /* MapColor.swift */, + B5CF75CD25CC7953003BFF3D /* MapParser */, B539516A25CB0C7800959F72 /* State */, B52625B425C87D54003E73B7 /* Extensions */, B539517925CB0D6100959F72 /* Views */, @@ -169,6 +186,7 @@ B526258025C874FA003E73B7 /* Map.entitlements */, B526257C25C874FA003E73B7 /* Map.xcdatamodeld */, B526257725C874FA003E73B7 /* Preview Content */, + B5CF75F625CC97CA003BFF3D /* Debouncer.swift */, ); path = Map; sourceTree = ""; @@ -212,6 +230,7 @@ B539516A25CB0C7800959F72 /* State */ = { isa = PBXGroup; children = ( + B52625C525C8BD2A003E73B7 /* Stage.swift */, B526257A25C874FA003E73B7 /* Persistence.swift */, B539516B25CB0C9200959F72 /* Store.swift */, B539517325CB0CA400959F72 /* AppState.swift */, @@ -225,14 +244,35 @@ B526257125C874F9003E73B7 /* MapApp.swift */, B526257325C874F9003E73B7 /* ContentView.swift */, B52625A525C876C3003E73B7 /* MapDetail.swift */, - B52625C525C8BD2A003E73B7 /* Stage.swift */, B523C74A25C9C1BA00C44061 /* DefaultMapView.swift */, B539518025CB2D7A00959F72 /* MapTextEditor.swift */, B52625AF25C87C14003E73B7 /* MapRender.swift */, + B5CF75C825CC19FC003BFF3D /* EvolutionPicker.swift */, ); path = Views; sourceTree = ""; }; + B5CF75CD25CC7953003BFF3D /* MapParser */ = { + isa = PBXGroup; + children = ( + B5CF75D625CC79A4003BFF3D /* Strategies */, + B5CF75CE25CC7965003BFF3D /* MapParser.swift */, + ); + path = MapParser; + sourceTree = ""; + }; + B5CF75D625CC79A4003BFF3D /* Strategies */ = { + isa = PBXGroup; + children = ( + B5CF75D725CC79BC003BFF3D /* VertexParserStrategy.swift */, + B5CF75DC25CC79D7003BFF3D /* EdgeParserStrategy.swift */, + B5CF75E125CC79ED003BFF3D /* BlockerParserStrategy.swift */, + B5CF75E925CC7A13003BFF3D /* OpportunityParserStrategy.swift */, + B5CF75EE25CC7A4A003BFF3D /* StageParserStrategy.swift */, + ); + path = Strategies; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -365,8 +405,12 @@ B52625B025C87C14003E73B7 /* MapRender.swift in Sources */, B523C77E25CA294C00C44061 /* MapOpportunities.swift in Sources */, B52625AB25C87909003E73B7 /* Date+format.swift in Sources */, + B5CF75D825CC79BC003BFF3D /* VertexParserStrategy.swift in Sources */, + B5CF75EA25CC7A13003BFF3D /* OpportunityParserStrategy.swift in Sources */, B523C77125CA121300C44061 /* MapBlockers.swift in Sources */, B523C76725CA071B00C44061 /* MapVertices.swift in Sources */, + B5CF75CF25CC7965003BFF3D /* MapParser.swift in Sources */, + B5CF75E225CC79ED003BFF3D /* BlockerParserStrategy.swift in Sources */, B539517425CB0CA400959F72 /* AppState.swift in Sources */, B523C75A25C9FD4900C44061 /* MapAxes.swift in Sources */, B539518125CB2D7A00959F72 /* MapTextEditor.swift in Sources */, @@ -374,14 +418,18 @@ B52625C625C8BD2A003E73B7 /* Stage.swift in Sources */, B523C73D25C98D9800C44061 /* NSImage+writePNG.swift in Sources */, B526257B25C874FA003E73B7 /* Persistence.swift in Sources */, + B5CF75EF25CC7A4A003BFF3D /* StageParserStrategy.swift in Sources */, B523C77625CA181100C44061 /* MapColor.swift in Sources */, + B5CF75DD25CC79D7003BFF3D /* EdgeParserStrategy.swift in Sources */, B526257425C874F9003E73B7 /* ContentView.swift in Sources */, B526257E25C874FA003E73B7 /* Map.xcdatamodeld in Sources */, + B5CF75C925CC19FC003BFF3D /* EvolutionPicker.swift in Sources */, B523C74B25C9C1BA00C44061 /* DefaultMapView.swift in Sources */, B539516C25CB0C9300959F72 /* Store.swift in Sources */, B526257225C874F9003E73B7 /* MapApp.swift in Sources */, B52625A625C876C3003E73B7 /* MapDetail.swift in Sources */, B523C76225CA05A300C44061 /* MapStages.swift in Sources */, + B5CF75F725CC97CA003BFF3D /* Debouncer.swift in Sources */, B523C76C25CA0DFA00C44061 /* MapEdges.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -551,7 +599,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 11; - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = pizza.unlimited.Map; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -576,7 +624,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 11; - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = pizza.unlimited.Map; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; diff --git a/Map/Debouncer.swift b/Map/Debouncer.swift new file mode 100644 index 0000000..f119d8a --- /dev/null +++ b/Map/Debouncer.swift @@ -0,0 +1,21 @@ +import Foundation + +class Debouncer { + + // MARK: - Properties + private let queue = DispatchQueue.main + private var workItem = DispatchWorkItem(block: {}) + private var interval: TimeInterval + + // MARK: - Initializer + init(seconds: TimeInterval) { + self.interval = seconds + } + + // MARK: - Debouncing function + func debounce(action: @escaping (() -> Void)) { + workItem.cancel() + workItem = DispatchWorkItem(block: { action() }) + queue.asyncAfter(deadline: .now() + interval, execute: workItem) + } +} diff --git a/Map/Extensions/Map+parse.swift b/Map/Extensions/Map+parse.swift index d82ba99..904f492 100644 --- a/Map/Extensions/Map+parse.swift +++ b/Map/Extensions/Map+parse.swift @@ -1,215 +1,28 @@ -import CoreGraphics -import Foundation - -let vertexPattern = - "([^\\(]+?)[\\s]*\\([\\s]*([0-9]+.?[0-9]*)[\\s]*,[\\s]*([0-9]+.?[0-9]*)[\\s]*\\)" -let edgePattern = "(.+?)[\\s]*-([->])[\\s]*(.+)" -let blockerPattern = "\\[Blocker\\][\\s]*(.+)" -let opportunityPattern = "\\[Opportunity\\][\\s]*(.+)[\\s]+([-+])[\\s]*([0-9]+.?[0-9]*)" -let stagePattern = "\\[(I{1,3})\\][\\s]*([0-9]+.?[0-9]*)" - -struct ParsedMap { - let vertices: [Vertex] - let edges: [MapEdge] - let blockers: [Blocker] - let opportunities: [Opportunity] - let stages: [CGFloat] -} - -struct Vertex { - let id: Int - let label: String - let position: CGPoint -} - -struct MapEdge { - let id: Int - let origin: CGPoint - let destination: CGPoint - let arrowhead: Bool -} - -struct Blocker { - let id: Int - let position: CGPoint -} - -struct Opportunity { - let id: Int - let origin: CGPoint - let destination: CGPoint -} - -let defaultDimensions: [CGFloat] = [ - 25.0, - 50.0, - 75.0, -] - -// Extracts the vertices from the text - -func parseVertices(_ text: String) -> [String: CGPoint] { - - var result: [String: CGPoint] = [:] - let regex = try! NSRegularExpression(pattern: vertexPattern, options: .caseInsensitive) - - let lines = text.split(whereSeparator: \.isNewline) - - for line in lines { - let range = NSRange(location: 0, length: line.utf16.count) - let matches = regex.matches(in: String(line), options: [], range: range) - - if matches.count > 0 && matches[0].numberOfRanges == 4 { - - let match = matches[0] - let key = String(line[Range(match.range(at: 1), in: line)!]) - let xString = String(line[Range(match.range(at: 2), in: line)!]) - let yString = String(line[Range(match.range(at: 3), in: line)!]) - let x = CGFloat(truncating: NumberFormatter().number(from: xString) ?? 0.0) - let y = CGFloat(truncating: NumberFormatter().number(from: yString) ?? 0.0) - let point = CGPoint(x: x, y: y) - - result[key] = point - } - } - - return result -} - -func parseEdges(_ text: String, vertices: [String: CGPoint]) -> [MapEdge] { - - var result: [MapEdge] = [] - let regex = try! NSRegularExpression(pattern: edgePattern, options: .caseInsensitive) - - let lines = text.split(whereSeparator: \.isNewline) - - for (index, line) in lines.enumerated() { - let range = NSRange(location: 0, length: line.utf16.count) - let matches = regex.matches(in: String(line), options: [], range: range) - - if matches.count > 0 && matches[0].numberOfRanges == 4 { - - let match = matches[0] - let arrowhead = String(line[Range(match.range(at: 2), in: line)!]) == ">" - let vertexA = String(line[Range(match.range(at: 1), in: line)!]) - let vertexB = String(line[Range(match.range(at: 3), in: line)!]) - - if let origin = vertices[vertexA] { - if let destination = vertices[vertexB] { - result.append( - MapEdge(id: index, origin: origin, destination: destination, arrowhead: arrowhead)) +extension Map { + static func parse(content: String) -> ParsedMap { + + let parsers = [ + 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 result -} - -func parseOpportunities(_ text: String, vertices: [String: CGPoint]) -> [Opportunity] { - - var result: [Opportunity] = [] - let regex = try! NSRegularExpression(pattern: opportunityPattern, options: .caseInsensitive) - - let lines = text.split(whereSeparator: \.isNewline) - - for (index, line) in lines.enumerated() { - let range = NSRange(location: 0, length: line.utf16.count) - let matches = regex.matches(in: String(line), options: [], range: range) - - if matches.count > 0 && matches[0].numberOfRanges == 4 { - - let match = matches[0] - let multiplier = CGFloat( - String(line[Range(match.range(at: 2), in: line)!]) == "-" ? -1.0 : 1.0) - let vertex = String(line[Range(match.range(at: 1), in: line)!]) - let opportunityString = String(line[Range(match.range(at: 3), in: line)!]) - let opportunity = CGFloat( - truncating: NumberFormatter().number(from: opportunityString) ?? 0.0) - - if let origin = vertices[vertex] { - let destination = CGPoint(x: origin.x + opportunity * multiplier, y: origin.y) - result.append(Opportunity(id: index, origin: origin, destination: destination)) - } - } - } - - return result -} - -func parseBlockers(_ text: String, vertices: [String: CGPoint]) -> [Blocker] { - - var result: [Blocker] = [] - let regex = try! NSRegularExpression(pattern: blockerPattern, options: .caseInsensitive) - - let lines = text.split(whereSeparator: \.isNewline) - - for (index, line) in lines.enumerated() { - let range = NSRange(location: 0, length: line.utf16.count) - let matches = regex.matches(in: String(line), options: [], range: range) - - if matches.count > 0 && matches[0].numberOfRanges == 2 { - - let match = matches[0] - let vertexA = String(line[Range(match.range(at: 1), in: line)!]) - - if let position = vertices[vertexA] { - result.append(Blocker(id: index, position: position)) - } - } - } - - return result -} - -func parseStages(_ text: String) -> [CGFloat] { - - var result = defaultDimensions - let regex = try! NSRegularExpression(pattern: stagePattern, options: .caseInsensitive) - - let lines = text.split(whereSeparator: \.isNewline) - - for line in lines { - let range = NSRange(location: 0, length: line.utf16.count) - let matches = regex.matches(in: String(line), options: [], range: range) - - if matches.count > 0 && matches[0].numberOfRanges == 3 { - - let match = matches[0] - let stage = String(line[Range(match.range(at: 1), in: line)!]) - let dimensionsString = String(line[Range(match.range(at: 2), in: line)!]) - let dimensions = CGFloat(truncating: NumberFormatter().number(from: dimensionsString) ?? 0.0) - - result[stage.count - 1] = dimensions - } - } - - return result -} - -// Converts vetex dictionary to array - -func mapVertices(_ vertices: [String: CGPoint]) -> [Vertex] { - var i = 0 - return vertices.map { label, position in - i += 1 - return Vertex(id: i, label: label, position: position) - } -} - -extension Map { - func parse() -> ParsedMap { - - let text = self.content ?? "" - let vertices = parseVertices(text) - let mappedVertices = mapVertices(vertices) - let edges = parseEdges(text, vertices: vertices) - let blockers = parseBlockers(text, vertices: vertices) - let opportunities = parseOpportunities(text, vertices: vertices) - let stages = parseStages(text) - return ParsedMap( - vertices: mappedVertices, edges: edges, blockers: blockers, opportunities: opportunities, - stages: stages) + return builder.build() } } diff --git a/Map/MapRenderComponents/MapColor.swift b/Map/MapColor.swift similarity index 59% rename from Map/MapRenderComponents/MapColor.swift rename to Map/MapColor.swift index ce3d453..98dd804 100644 --- a/Map/MapRenderComponents/MapColor.swift +++ b/Map/MapColor.swift @@ -7,6 +7,7 @@ struct MapColor { let blocker: Color let opportunity: Color let stages: StageColor + let syntax: SyntaxColor static func colorForScheme(_ colorScheme: ColorScheme) -> MapColor { if colorScheme == .dark { @@ -20,7 +21,13 @@ struct MapColor { i: Color(.sRGB, red: 0.37, green: 0.16, blue: 0.25), ii: Color(.sRGB, red: 0.30, green: 0.29, blue: 0.26), iii: Color(.sRGB, red: 0.15, green: 0.29, blue: 0.23), - iv: Color(.sRGB, red: 0.14, green: 0.22, blue: 0.31))) + iv: Color(.sRGB, red: 0.14, green: 0.22, blue: 0.31)), + syntax: SyntaxColor( + vertex: NSColor(srgbRed: 0.41, green: 0.84, blue: 0.96, alpha: 1.0), + number: NSColor(srgbRed: 0.85, green: 0.78, blue: 0.49, alpha: 1.0), + option: NSColor(srgbRed: 1.0, green: 0.48, blue: 0.7, alpha: 1.0), // #FE7AB3 + symbol: NSColor(srgbRed: 0.85, green: 0.73, blue: 1.0, alpha: 1.0) // #DABBFF + )) } else { return MapColor( foreground: Color(.sRGB, red: 0.13, green: 0.13, blue: 0.13), @@ -32,7 +39,13 @@ struct MapColor { i: Color(.sRGB, red: 1.00, green: 0.93, blue: 0.97), ii: Color(.sRGB, red: 1.00, green: 0.98, blue: 0.92), iii: Color(.sRGB, red: 0.93, green: 1.00, blue: 0.97), - iv: Color(.sRGB, red: 0.93, green: 0.96, blue: 1.00))) + iv: Color(.sRGB, red: 0.93, green: 0.96, blue: 1.00)), + syntax: SyntaxColor( + vertex: NSColor(srgbRed: 0.11, green: 0.42, blue: 0.57, alpha: 1.0), + number: NSColor(srgbRed: 0.27, green: 0.31, blue: 0.87, alpha: 1.0), + option: NSColor(srgbRed: 0.68, green: 0.24, blue: 0.64, alpha: 1.0), + symbol: NSColor(srgbRed: 0.29, green: 0.13, blue: 0.69, alpha: 1.0) + )) } } } @@ -43,3 +56,10 @@ struct StageColor { let iii: Color let iv: Color } + +struct SyntaxColor { + let vertex: NSColor + let number: NSColor + let option: NSColor + let symbol: NSColor +} diff --git a/Map/MapParser/MapParser.swift b/Map/MapParser/MapParser.swift new file mode 100644 index 0000000..3528eb5 --- /dev/null +++ b/Map/MapParser/MapParser.swift @@ -0,0 +1,139 @@ +import CoreGraphics +import Foundation + +// 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) + static let edge = try! NSRegularExpression( + pattern: "(.+?)[\\s]*-([->])[\\s]*(.+)", options: .caseInsensitive) + static let blocker = try! NSRegularExpression( + pattern: "\\[(Blocker)\\][\\s]*(.+)", options: .caseInsensitive) + static let opportunity = try! NSRegularExpression( + pattern: "\\[(Opportunity)\\][\\s]*(.+)[\\s]+([-+])[\\s]*([0-9]+.?[0-9]*)", + options: .caseInsensitive) + static let stage = try! NSRegularExpression( + pattern: "\\[(I{1,3})\\][\\s]*([0-9]+.?[0-9]*)", options: .caseInsensitive) +} + +struct ParsedMap { + let vertices: [Vertex] + let edges: [MapEdge] + let blockers: [Blocker] + let opportunities: [Opportunity] + let stages: [CGFloat] +} + +struct Vertex { + let id: Int + let label: String + let position: CGPoint + var shape: VertexShape = .circle +} + +enum VertexShape: String { + case circle + case square + case triangle + case x +} + +struct MapEdge { + let id: Int + let origin: CGPoint + let destination: CGPoint + let arrowhead: Bool +} + +struct Blocker { + let id: Int + let position: CGPoint +} + +struct Opportunity { + let id: Int + let origin: CGPoint + let destination: CGPoint +} + +struct StageDimensions { + let index: Int + let dimensions: CGFloat +} + +private let defaultDimensions: [CGFloat] = [ + 25.0, + 50.0, + 75.0, +] + +// MARK: - MapParserStrategy protocol + +protocol MapParserStrategy { + func canHandle(line: String) -> Bool + func handle(index: Int, line: String, vertices: [String: Vertex]) -> (Any.Type, Any) +} + +struct AnyMapParserStrategy: MapParserStrategy { + + private let base: MapParserStrategy + + init(_ base: T) { + self.base = base + } + + func canHandle(line: String) -> Bool { + return base.canHandle(line: line) + } + func handle(index: Int, line: String, vertices: [String: Vertex]) -> (Any.Type, Any) { + return base.handle(index: index, line: line, vertices: vertices) + } +} + +// MARK: - Map Builder + +class MapBuilder { + var vertices: [String: Vertex] = [:] + private var edges: [MapEdge] = [] + private var blockers: [Blocker] = [] + private var opportunities: [Opportunity] = [] + private var stages: [CGFloat] = defaultDimensions + + func addObjectToMap(type: Any.Type, object: Any) { + if type == Vertex.self { + let vertex = object as! Vertex + vertices[vertex.label] = vertex + } + + if type == MapEdge.self { + let edge = object as! MapEdge + edges.append(edge) + } + + if type == Blocker.self { + let blocker = object as! Blocker + blockers.append(blocker) + } + + if type == Opportunity.self { + let opportunity = object as! Opportunity + opportunities.append(opportunity) + } + + if type == StageDimensions.self { + let stageDimensions = object as! StageDimensions + stages[stageDimensions.index] = stageDimensions.dimensions + + } + } + + func build() -> ParsedMap { + let mappedVertices = vertices.map { label, vertex in return vertex } + return ParsedMap( + vertices: mappedVertices, edges: edges, blockers: blockers, opportunities: opportunities, + stages: stages) + } +} diff --git a/Map/MapParser/Strategies/BlockerParserStrategy.swift b/Map/MapParser/Strategies/BlockerParserStrategy.swift new file mode 100644 index 0000000..d9b6f22 --- /dev/null +++ b/Map/MapParser/Strategies/BlockerParserStrategy.swift @@ -0,0 +1,26 @@ +import Foundation + +struct BlockerParserStrategy: MapParserStrategy { + private let regex = MapParsingPatterns.blocker + + 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] + let vertexA = String(line[Range(match.range(at: 2), in: line)!]) + + if let vertex = vertices[vertexA] { + let blocker = Blocker(id: index, position: vertex.position) + return (Blocker.self, blocker) + } + + return (NSObject.self, NSObject()) // No matching object + } +} diff --git a/Map/MapParser/Strategies/EdgeParserStrategy.swift b/Map/MapParser/Strategies/EdgeParserStrategy.swift new file mode 100644 index 0000000..0df1ff4 --- /dev/null +++ b/Map/MapParser/Strategies/EdgeParserStrategy.swift @@ -0,0 +1,32 @@ +import Foundation + +struct EdgeParserStrategy: MapParserStrategy { + private let regex = MapParsingPatterns.edge + + 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 == 4 + } + + 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] + let arrowhead = String(line[Range(match.range(at: 2), in: line)!]) == ">" + let vertexA = String(line[Range(match.range(at: 1), in: line)!]) + let vertexB = String(line[Range(match.range(at: 3), in: line)!]) + + if let origin = vertices[vertexA] { + if let destination = vertices[vertexB] { + let edge = MapEdge( + id: index, origin: origin.position, destination: destination.position, + arrowhead: arrowhead) + return (MapEdge.self, edge) + } + } + + return (NSObject.self, NSObject()) // No matching object + } +} diff --git a/Map/MapParser/Strategies/OpportunityParserStrategy.swift b/Map/MapParser/Strategies/OpportunityParserStrategy.swift new file mode 100644 index 0000000..104fd30 --- /dev/null +++ b/Map/MapParser/Strategies/OpportunityParserStrategy.swift @@ -0,0 +1,33 @@ +import Foundation + +struct OpportunityParserStrategy: MapParserStrategy { + private let regex = MapParsingPatterns.opportunity + + 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 == 5 + } + + 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] + let multiplier = CGFloat( + String(line[Range(match.range(at: 3), in: line)!]) == "-" ? -1.0 : 1.0) + let vertex = String(line[Range(match.range(at: 2), in: line)!]) + let opportunityString = String(line[Range(match.range(at: 4), in: line)!]) + let opportunity = CGFloat( + truncating: NumberFormatter().number(from: opportunityString) ?? 0.0) + + if let origin = vertices[vertex] { + let destination = CGPoint( + x: origin.position.x + opportunity * multiplier, y: origin.position.y) + let opportunity = Opportunity(id: index, origin: origin.position, destination: destination) + return (Opportunity.self, opportunity) + } + + return (NSObject.self, NSObject()) // No matching object + } +} diff --git a/Map/MapParser/Strategies/StageParserStrategy.swift b/Map/MapParser/Strategies/StageParserStrategy.swift new file mode 100644 index 0000000..42535fc --- /dev/null +++ b/Map/MapParser/Strategies/StageParserStrategy.swift @@ -0,0 +1,24 @@ +import Foundation + +struct StageParserStrategy: MapParserStrategy { + private let regex = MapParsingPatterns.stage + + 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] + let stage = String(line[Range(match.range(at: 1), in: line)!]) + let dimensionsString = String(line[Range(match.range(at: 2), in: line)!]) + let dimensions = CGFloat(truncating: NumberFormatter().number(from: dimensionsString) ?? 0.0) + + let stageDimensions = StageDimensions(index: stage.count - 1, dimensions: dimensions) + return (StageDimensions.self, stageDimensions) + } +} diff --git a/Map/MapParser/Strategies/VertexParserStrategy.swift b/Map/MapParser/Strategies/VertexParserStrategy.swift new file mode 100644 index 0000000..10c94a2 --- /dev/null +++ b/Map/MapParser/Strategies/VertexParserStrategy.swift @@ -0,0 +1,36 @@ +import Foundation + +struct VertexParserStrategy: MapParserStrategy { + private let regex = MapParsingPatterns.vertex + + 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 >= 4 + } + + 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] + let key = String(line[Range(match.range(at: 1), in: line)!]) + let xString = String(line[Range(match.range(at: 2), in: line)!]) + let yString = String(line[Range(match.range(at: 3), in: line)!]) + let x = CGFloat(truncating: NumberFormatter().number(from: xString) ?? 0.0) + let y = CGFloat(truncating: NumberFormatter().number(from: yString) ?? 0.0) + + var vertex = Vertex( + id: index, + label: key, + position: CGPoint(x: x, y: y) + ) + + if let range = Range(match.range(at: 4), in: line) { + let shapeString = String(line[range]) + vertex.shape = VertexShape(rawValue: shapeString.lowercased()) ?? .circle + } + + return (Vertex.self, vertex) + } +} diff --git a/Map/MapRenderComponents/MapAxes.swift b/Map/MapRenderComponents/MapAxes.swift index e7d65f4..a6e2f87 100644 --- a/Map/MapRenderComponents/MapAxes.swift +++ b/Map/MapRenderComponents/MapAxes.swift @@ -8,7 +8,7 @@ struct MapAxes: View { let lineWidth: CGFloat let evolution: Stage let stages: [CGFloat] - let stageHeight = CGFloat(50.0) + let stageHeight = CGFloat(100.0) let padding = CGFloat(5.0) var color: Color { @@ -40,13 +40,13 @@ struct MapAxes: View { Text("Uncharted") .font(.title3) .foregroundColor(color) - .frame(width: mapSize.width / 4, height: stageHeight, alignment: .topLeading) - .offset(CGSize(width: 0.0, height: -stageHeight / 2.0)) + .frame(width: mapSize.width / 4, height: stageHeight / 2.0, alignment: .topLeading) + .offset(CGSize(width: 0.0, height: -stageHeight / 4.0)) Text("Industrialised") .font(.title3) .foregroundColor(color) - .frame(width: mapSize.width / 4, height: stageHeight, alignment: .topLeading) - .offset(CGSize(width: mapSize.width - 100.0, height: -stageHeight / 2.0)) + .frame(width: mapSize.width / 4, height: stageHeight / 2.0, alignment: .topLeading) + .offset(CGSize(width: mapSize.width - 100.0, height: -stageHeight / 4.0)) Text(evolution.i) .font(.title3) diff --git a/Map/MapRenderComponents/MapVertices.swift b/Map/MapRenderComponents/MapVertices.swift index 71f85be..24d49b8 100644 --- a/Map/MapRenderComponents/MapVertices.swift +++ b/Map/MapRenderComponents/MapVertices.swift @@ -15,12 +15,7 @@ struct MapVertices: View { var body: some View { ForEach(vertices, id: \.id) { vertex in - Path { path in - path.addEllipse( - in: CGRect( - origin: CGPoint(x: w(vertex.position.x), y: h(vertex.position.y)), size: vertexSize - )) - }.fill(color.foreground) + getVertexShape(vertex).fill(color.foreground) Text(vertex.label).foregroundColor(color.secondary).offset( CGSize( width: w(vertex.position.x) + vertexSize.width + padding, @@ -35,6 +30,52 @@ struct MapVertices: View { func w(_ dimension: CGFloat) -> CGFloat { max(0.0, min(mapSize.width, dimension * mapSize.width / 100.0)) } + + func getVertexShape(_ vertex: Vertex) -> Path { + switch vertex.shape { + case .circle: + return Path { path in + path.addEllipse( + in: CGRect( + origin: CGPoint(x: w(vertex.position.x), y: h(vertex.position.y)), size: vertexSize + )) + } + case .square: + return Path { path in + path.addRect( + CGRect( + x: w(vertex.position.x), y: h(vertex.position.y), width: vertexSize.width, + height: vertexSize.height + )) + } + case .triangle: + return Path { path in + path.move(to: CGPoint(x: w(vertex.position.x), y: h(vertex.position.y) + vertexSize.height)) + path.addLine( + to: CGPoint( + x: w(vertex.position.x) + vertexSize.width, y: h(vertex.position.y) + vertexSize.height) + ) + path.addLine( + to: CGPoint(x: w(vertex.position.x) + vertexSize.width / 2.0, y: h(vertex.position.y))) + path.addLine( + to: CGPoint(x: w(vertex.position.x), y: h(vertex.position.y) + vertexSize.height)) + path.closeSubpath() + } + case .x: + return Path { path in + path.move(to: CGPoint(x: w(vertex.position.x), y: h(vertex.position.y))) + path.addLine( + to: CGPoint( + x: w(vertex.position.x) + vertexSize.width, y: h(vertex.position.y) + vertexSize.height) + ) + path.closeSubpath() + path.move(to: CGPoint(x: w(vertex.position.x) + vertexSize.width, y: h(vertex.position.y))) + path.addLine( + to: CGPoint(x: w(vertex.position.x), y: h(vertex.position.y) + vertexSize.height)) + path.closeSubpath() + }.strokedPath(StrokeStyle(lineWidth: 5.0, lineCap: .butt)) + } + } } struct MapVertices_Previews: PreviewProvider { diff --git a/Map/State/AppState.swift b/Map/State/AppState.swift index e10efea..cbc9ba6 100644 --- a/Map/State/AppState.swift +++ b/Map/State/AppState.swift @@ -3,14 +3,13 @@ import Foundation import SwiftUI struct AppState { - var selectedMap: Map? = nil + var selectedEvolution: StageType = .general var mapBeingDeleted: Map? = nil } enum AppAction { - case selectMap(map: Map?) - case deleteMap(map: Map) - case exportMapAsImage(map: Map, evolution: StageType) + case selectEvolution(evolution: StageType) + case exportMapAsImage(map: Map) case exportMapAsText(map: Map) } @@ -18,16 +17,10 @@ func appStateReducer(state: inout AppState, action: AppAction) { switch action { - case .selectMap(let map): - state.selectedMap = map + case .selectEvolution(let evolution): + state.selectedEvolution = evolution - case .deleteMap(let map): - let context = PersistenceController.shared.container.viewContext - - context.delete(map) - try? context.save() - - case .exportMapAsImage(let map, let evolution): + case .exportMapAsImage(let map): let window = NSWindow( contentRect: .init( origin: .zero, @@ -44,7 +37,8 @@ func appStateReducer(state: inout AppState, action: AppAction) { window.isMovableByWindowBackground = true window.makeKeyAndOrderFront(nil) - let renderView = MapRenderView(map: map, evolution: Stage.stages(evolution)) + let renderView = MapRenderView( + content: map.content ?? "", evolution: Stage.stages(state.selectedEvolution)) let view = NSHostingView(rootView: renderView) window.contentView = view diff --git a/Map/Views/Stage.swift b/Map/State/Stage.swift similarity index 51% rename from Map/Views/Stage.swift rename to Map/State/Stage.swift index 23432ad..26b1929 100644 --- a/Map/Views/Stage.swift +++ b/Map/State/Stage.swift @@ -8,7 +8,7 @@ struct Stage { switch type { case .general: return Stage( - i: "Genesis", ii: "Custom Built", iii: "Product (+rental)", iv: "Commodity (+utility)") + i: "Genesis", ii: "Custom", iii: "Product (+rental)", iv: "Commodity (+utility)") case .practice: return Stage( i: "Novel", ii: "Emerging", iii: "Good", iv: "Best") @@ -20,40 +20,59 @@ struct Stage { i: "Concept", ii: "Hypothesis", iii: "Theory", iv: "Accepted") case .ubiquity: return Stage( - i: "Rare", ii: "Slowly Increasing Consumption", iii: "Rapidly Increasing Consumption", - iv: "Widespread and stabilising") + i: "Rare", ii: "Slowly Increasing", iii: "Rapidly Increasing", + iv: "Widespread in the applicable market / ecosystem") case .certainty: return Stage( - i: "Poorly Understood", ii: "Rapid Increase In Learning", - iii: "Rapid Increase in Use / fit for purpose", iv: "Commonly understood (in terms of use)") + i: "Poorly Understood / exploring the unknown", + ii: "Rapid Increase In Learning / discovery becomes refining", + iii: "Rapid increase in use / increasing fit for purpose", + iv: "Commonly understood (in terms of use)") case .publicationTypes: return Stage( - i: "Normally describing the wonder of the thing", - ii: "Build / construct / awareness and learning", - iii: "Maintenance / operations / installation / feature", iv: "Focused on use") + i: + "Describe the wonder of the thing / the discovery of some marvel / a new land / an unknown frontier", + ii: + "Focused on build / construct / awareness and learning / many models of explanation / no accepted forms / a wild west", + iii: + "Maintenance / operations / installation / comparison between competing forms / feature analysis", + iv: "Focused on use / increasingly an accepted, almost invisible component") case .market: return Stage( - i: "Undefined Market", ii: "Forming Market", iii: "Growing Market", iv: "Mature Market") + i: "Undefined Market", + ii: "Forming Market / an array of competing forms and models of understanding", + iii: "Growing Market / consolidation to a few competing but more accepted forms", + iv: "Mature Market / stabilised to an accepted form") case .knowledgeManagement: return Stage( - i: "Uncertain", ii: "Learning on use", iii: "Learning on operation", iv: "Known / accepted") + i: "Uncertain", ii: "Learning on use / focused on testing prediction", + iii: "Learning on operation / using prediction / verification", iv: "Known / accepted") case .marketPerception: return Stage( - i: "Chaotic (non-linear)", ii: "Domain of experts", iii: "Increasing expectation of use", - iv: "Ordered (appearance of being trivial) / trivial") + i: "Chaotic (non-linear) / domain of the \"crazy\"", ii: "Domain of \"experts\"", + iii: "Increasing expectation of use / domain of \"professionals\"", + iv: "Ordered (appearance of being linear) / trivial / formula to be applied") case .userPerception: return Stage( - i: "Different / confusing / exciting / surprising", ii: "Leading edge / emerging", - iii: "Increasingly common / disappointed if not used", iv: "Standard / expected") + i: "Different / confusing / exciting / surprising / dangerous", + ii: "Leading edge / emerging / unceirtanty over results", + iii: "Increasingly common / disappointed if not used or available / feeling left behind", + iv: "Standard / expected / feeling of shock if not used") case .perceptionInIndustry: return Stage( - i: "Competitive advantage / unpredictable / unknown", - ii: "Competitive advantage / ROI / case examples", - iii: "Advantage through implementation / features", iv: "Cost of doing business") + i: "Future source of competitive advantage / unpredictable / unknown", + ii: "Seen as a scompetitive advantage / a differential / ROI / case examples", + iii: "Advantage through implementation / features / this model is better than that", + iv: "Cost of doing business / accepted / specific defined models") case .focusOfValue: return Stage( - i: "High future worth", ii: "Seeking profit / ROI", iii: "High profitability", - iv: "High volume / reducing margin") + i: "High future worth but immediate investment", + ii: "Seeking ways to profit and a ROI / seeking confirmation of value", + iii: + "High profitability per unit / a valuable model / a feeling of understanding / focus on exploitation", + iv: + "High volume / reducing margin / important but invisible / an essential component of something more complex" + ) case .understanding: return Stage( i: "Poorly Understood / unpredictable", @@ -64,12 +83,15 @@ struct Stage { return Stage( i: "Constantly changing / a differential / unstable", ii: "Learning from others / testing the water / some evidential support", - iii: "Feature difference", iv: "Essential / operational advantage") + iii: "Competing models / feature difference / evidential support", + iv: "Essential / any advantage is operational / accepted norm") case .failure: return Stage( - i: "High / tolerated / assumed", ii: "Moderate / unsurprising but disappointed", - iii: "Not tolerated, focus on constant improvement", - iv: "Operational efficiency and surprised by failure") + i: "High / tolerated / assumed to be wrong", + ii: "Moderate / unsurprising if wrong but disappointed", + iii: + "Not tolerated / focus on constant improvement / assumed to be in the right direction / resistance to changing the model", + iv: "Surprised by failure / focus on operational efficiency") case .marketAction: return Stage( i: "Gambling / driven by gut", ii: "Exploring a \"found\" value", @@ -140,9 +162,11 @@ enum StageType: String, CaseIterable, Identifiable { case practice case data case knowledge + case ubiquity case certainty case publicationTypes + case market case knowledgeManagement case marketPerception @@ -155,7 +179,17 @@ enum StageType: String, CaseIterable, Identifiable { case marketAction case efficiency case decisionDrivers + case behavior var id: String { self.rawValue } + + static let types: [StageType] = [.general, .practice, .data, .knowledge] + static let characteristics: [StageType] = [.ubiquity, .certainty, .publicationTypes] + static let properties: [StageType] = [ + .market, .knowledgeManagement, .marketPerception, .userPerception, + .perceptionInIndustry, .focusOfValue, .understanding, .comparison, .failure, + .marketAction, .efficiency, .decisionDrivers, + ] + static let custom: [StageType] = [.behavior] } diff --git a/Map/Views/ContentView.swift b/Map/Views/ContentView.swift index 4f55c27..cf1f145 100644 --- a/Map/Views/ContentView.swift +++ b/Map/Views/ContentView.swift @@ -11,6 +11,8 @@ import SwiftUI struct ContentView: View { @Environment(\.managedObjectContext) private var viewContext + @EnvironmentObject var store: AppStore + @FetchRequest( sortDescriptors: [NSSortDescriptor(keyPath: \Map.createdAt, ascending: true)], animation: .default) diff --git a/Map/Views/DefaultMapView.swift b/Map/Views/DefaultMapView.swift index 66914f1..1f624d2 100644 --- a/Map/Views/DefaultMapView.swift +++ b/Map/Views/DefaultMapView.swift @@ -17,7 +17,7 @@ struct DefaultMapView: View { struct DefaultMapView_Previews: PreviewProvider { static var previews: some View { - MapDetailView(map: Map()).environment( + DefaultMapView().environment( \.managedObjectContext, PersistenceController.preview.container.viewContext) } } diff --git a/Map/Views/EvolutionPicker.swift b/Map/Views/EvolutionPicker.swift new file mode 100644 index 0000000..815f575 --- /dev/null +++ b/Map/Views/EvolutionPicker.swift @@ -0,0 +1,48 @@ +// +// EvolutionPicker.swift +// Map +// +// Created by Ruben Beltran del Rio on 2/4/21. +// + +import SwiftUI + +struct EvolutionPicker: View { + + @EnvironmentObject private var store: AppStore + + private var selectedEvolution: Binding { + 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)).tag(stage).padding(4.0) + } + Divider() + ForEach(StageType.characteristics) { stage in + Text(Stage.title(stage)).tag(stage).padding(4.0) + } + Divider() + ForEach(StageType.properties) { stage in + Text(Stage.title(stage)).tag(stage).padding(4.0) + } + Divider() + ForEach(StageType.custom) { stage in + Text(Stage.title(stage)).tag(stage).padding(4.0) + } + }.padding(.horizontal, 8.0).padding(.vertical, 4.0) + } +} + +struct EvolutionPicker_Previews: PreviewProvider { + static var previews: some View { + EvolutionPicker() + } +} diff --git a/Map/Views/MapDetail.swift b/Map/Views/MapDetail.swift index a8ea3d1..6ba92bb 100644 --- a/Map/Views/MapDetail.swift +++ b/Map/Views/MapDetail.swift @@ -38,8 +38,6 @@ struct MapDetailView: View { ) } - @State private var selectedEvolution = StageType.general - var body: some View { if map.uuid != nil { VSplitView { @@ -59,14 +57,10 @@ struct MapDetailView: View { Image(systemName: "photo") }.padding(.vertical, 4.0).padding(.leading, 4.0).padding(.trailing, 8.0) } - Picker("Evolution", selection: $selectedEvolution) { - ForEach(StageType.allCases) { stage in - Text(Stage.title(stage)).tag(stage).padding(4.0) - } - }.padding(.horizontal, 8.0).padding(.vertical, 4.0) + EvolutionPicker() ZStack(alignment: .topLeading) { - MapTextEditor(text: content).onChange(of: map.content) { _ in + MapTextEditor(text: content, colorScheme: colorScheme).onChange(of: map.content) { _ in try? viewContext.save() } .background(mapColor.background) @@ -76,7 +70,8 @@ struct MapDetailView: View { 5.0) }.padding(.horizontal, 8.0) ScrollView([.horizontal, .vertical]) { - MapRenderView(map: map, evolution: Stage.stages(selectedEvolution)) + MapRenderView( + content: content.wrappedValue, evolution: Stage.stages(store.state.selectedEvolution)) }.background(mapColor.background) } } else { @@ -89,7 +84,7 @@ struct MapDetailView: View { } private func saveImage() { - store.send(.exportMapAsImage(map: map, evolution: selectedEvolution)) + store.send(.exportMapAsImage(map: map)) } } diff --git a/Map/Views/MapRender.swift b/Map/Views/MapRender.swift index b35b724..7623b61 100644 --- a/Map/Views/MapRender.swift +++ b/Map/Views/MapRender.swift @@ -5,6 +5,7 @@ // Created by Ruben Beltran del Rio on 2/1/21. // +import Combine import CoreData import CoreGraphics import SwiftUI @@ -13,7 +14,7 @@ struct MapRenderView: View { @Environment(\.colorScheme) var colorScheme - @ObservedObject var map: Map + var content: String let evolution: Stage let mapSize = CGSize(width: 1300.0, height: 1000.0) @@ -23,7 +24,7 @@ struct MapRenderView: View { let padding = CGFloat(30.0) var parsedMap: ParsedMap { - return map.parse() + return Map.parse(content: content) } var body: some View { @@ -33,7 +34,7 @@ struct MapRenderView: View { path.addRect( CGRect( x: -padding, y: -padding, width: mapSize.width + padding * 2, - height: mapSize.height + padding * 2)) + height: mapSize.height + padding * 4)) }.fill(MapColor.colorForScheme(colorScheme).background) MapStages(mapSize: mapSize, lineWidth: lineWidth, stages: parsedMap.stages) @@ -48,14 +49,14 @@ struct MapRenderView: View { mapSize: mapSize, lineWidth: lineWidth, vertexSize: vertexSize, edges: parsedMap.edges) }.frame( width: mapSize.width, - height: mapSize.height, alignment: .topLeading + height: mapSize.height + 2 * padding, alignment: .topLeading ).padding(padding) } } struct MapRenderView_Previews: PreviewProvider { static var previews: some View { - MapDetailView(map: Map()).environment( + MapRenderView(content: "", evolution: Stage.stages(.general)).environment( \.managedObjectContext, PersistenceController.preview.container.viewContext) } } diff --git a/Map/Views/MapTextEditor.swift b/Map/Views/MapTextEditor.swift index 7251c59..e030d64 100644 --- a/Map/Views/MapTextEditor.swift +++ b/Map/Views/MapTextEditor.swift @@ -4,9 +4,19 @@ import SwiftUI class MapTextEditorController: NSViewController { @Binding var text: String + var colorScheme: ColorScheme - init(text: Binding) { + private let vertexRegex = MapParsingPatterns.vertex + private let edgeRegex = MapParsingPatterns.edge + private let blockerRegex = MapParsingPatterns.blocker + private let opportunityRegex = MapParsingPatterns.opportunity + private let stageRegex = MapParsingPatterns.stage + + private let debouncer: Debouncer = Debouncer(seconds: 0.2) + + init(text: Binding, colorScheme: ColorScheme) { self._text = text + self.colorScheme = colorScheme super.init(nibName: nil, bundle: nil) } @@ -21,6 +31,7 @@ class MapTextEditorController: NSViewController { scrollView.translatesAutoresizingMaskIntoConstraints = false textView.delegate = self + textView.textStorage?.delegate = self textView.string = self.text textView.isEditable = true textView.font = .monospacedSystemFont(ofSize: 16.0, weight: .regular) @@ -30,6 +41,10 @@ class MapTextEditorController: NSViewController { override func viewDidAppear() { self.view.window?.makeFirstResponder(self.view) } + + func updateColorScheme(_ colorScheme: ColorScheme) { + self.colorScheme = colorScheme + } } extension MapTextEditorController: NSTextViewDelegate { @@ -53,19 +68,80 @@ extension MapTextEditorController: NSTextViewDelegate { } } +extension MapTextEditorController: NSTextStorageDelegate { + override func textStorageDidProcessEditing(_ obj: Notification) { + if let textStorage = obj.object as? NSTextStorage { + debouncer.debounce { + DispatchQueue.main.async { + self.colorizeText(textStorage: textStorage) + } + } + } + } + + private func colorizeText(textStorage: NSTextStorage) { + let range = NSMakeRange(0, textStorage.length) + var matches = vertexRegex.matches(in: textStorage.string, options: [], range: range) + let colors = MapColor.colorForScheme(colorScheme) + + for match in matches { + textStorage.addAttributes([.foregroundColor: colors.syntax.vertex], range: match.range(at: 1)) + textStorage.addAttributes([.foregroundColor: colors.syntax.number], range: match.range(at: 2)) + textStorage.addAttributes([.foregroundColor: colors.syntax.number], range: match.range(at: 3)) + textStorage.addAttributes([.foregroundColor: colors.syntax.option], range: match.range(at: 4)) + } + + matches = edgeRegex.matches(in: textStorage.string, options: [], range: range) + + for match in matches { + textStorage.addAttributes([.foregroundColor: colors.syntax.vertex], range: match.range(at: 1)) + let arrowRange = match.range(at: 2) + textStorage.addAttributes( + [.foregroundColor: colors.syntax.symbol], + range: NSMakeRange(arrowRange.lowerBound - 1, arrowRange.length + 1)) + textStorage.addAttributes([.foregroundColor: colors.syntax.vertex], range: match.range(at: 3)) + } + + matches = opportunityRegex.matches(in: textStorage.string, options: [], range: range) + + for match in matches { + textStorage.addAttributes([.foregroundColor: colors.syntax.option], range: match.range(at: 1)) + textStorage.addAttributes([.foregroundColor: colors.syntax.vertex], range: match.range(at: 2)) + textStorage.addAttributes([.foregroundColor: colors.syntax.symbol], range: match.range(at: 3)) + textStorage.addAttributes([.foregroundColor: colors.syntax.number], range: match.range(at: 4)) + } + + matches = blockerRegex.matches(in: textStorage.string, options: [], range: range) + + for match in matches { + textStorage.addAttributes([.foregroundColor: colors.syntax.option], range: match.range(at: 1)) + textStorage.addAttributes([.foregroundColor: colors.syntax.vertex], range: match.range(at: 2)) + } + + matches = stageRegex.matches(in: textStorage.string, options: [], range: range) + + for match in matches { + textStorage.addAttributes([.foregroundColor: colors.syntax.option], range: match.range(at: 1)) + textStorage.addAttributes([.foregroundColor: colors.syntax.number], range: match.range(at: 2)) + } + } +} + struct MapTextEditor: NSViewControllerRepresentable { @Binding var text: String + let colorScheme: ColorScheme func makeNSViewController( context: NSViewControllerRepresentableContext ) -> MapTextEditorController { - return MapTextEditorController(text: $text) + return MapTextEditorController(text: $text, colorScheme: colorScheme) } func updateNSViewController( _ nsViewController: MapTextEditorController, context: NSViewControllerRepresentableContext ) { + nsViewController.updateColorScheme(context.environment.colorScheme) } }