diff options
| -rw-r--r-- | Documentation/wmap-spec.ebnf | 17 | ||||
| -rw-r--r-- | Map/Business/WmapParser/Entity.swift | 73 | ||||
| -rw-r--r-- | Map/Business/WmapParser/Lexer.swift | 93 | ||||
| -rw-r--r-- | Map/Business/WmapParser/Parser.swift | 87 | ||||
| -rw-r--r-- | Map/Data/MapDocument.swift | 2 | ||||
| -rw-r--r-- | Map/Presentation/Complex Components/MapRender/MapRenderView.swift | 152 | ||||
| -rw-r--r-- | Map/Presentation/MapEditor.swift | 5 |
7 files changed, 339 insertions, 90 deletions
diff --git a/Documentation/wmap-spec.ebnf b/Documentation/wmap-spec.ebnf index d3c5ad7..05eabe5 100644 --- a/Documentation/wmap-spec.ebnf +++ b/Documentation/wmap-spec.ebnf @@ -2,7 +2,7 @@ (* Top-level structure *) program = { line } ; -line = ( entity newline ) | ( ignored_line newline ) | newline ; +line = ( entity line_ending ) | ( ignored_line line_ending ) | line_ending ; entity = vertex | edge | note | stage | group | inertia | evolution ; ignored_line = ? any sequence of tokens that doesn't match entity grammar ? ; @@ -23,8 +23,8 @@ edge_type = "--" | "->" ; sign = "+" | "-" ; (* Terminal symbols *) -vertex_label = preserved_case( { character - ( "-" | "+" | "," | "[" | "]" | "(" | ")" | newline ) } ) ; -text = preserved_case( { character - newline } ) ; +vertex_label = preserved_case( { character - ( "-" | "+" | "," | "[" | "]" | "(" | ")" | line_ending ) } ) ; +text = preserved_case( { character - line_ending } ) ; real_number = [ digit ] { digit } [ "." { digit } ] | "." digit { digit } ; stage_number = case_insensitive( "i" | "ii" | "iii" | "iv" ) ; shape_label = case_insensitive( "x" | "square" | "triangle" | "circle" ) ; @@ -32,7 +32,10 @@ shape_label = case_insensitive( "x" | "square" | "triangle" | "circle" ) ; (* Lexical elements *) character = ? any Unicode character ? ; digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ; -newline = ? newline character ? ; +line_ending = lf | crlf | cr ; +lf = ? U+000A (Line Feed) ? ; +cr = ? U+000D (Carriage Return) ? ; +crlf = cr lf ; (* Lexical rules *) case_insensitive(x) = ? case-insensitive match of x ? ; @@ -42,6 +45,8 @@ preserved_case(x) = ? case-insensitive match of x, preserving original case for (* - Keywords and shape labels are case-insensitive *) (* - Vertex labels are case-insensitive for matching but preserve original case *) (* - Text content preserves original case *) -(* - Whitespace (except newlines) may appear between any tokens *) +(* - Whitespace (except line endings) may appear between any tokens *) (* - Each line contains exactly one entity *) -(* - Invalid lines should be ignored. *) +(* - Invalid lines should be ignored *) +(* - Line endings support Unix (LF), Windows (CRLF), and classic Mac (CR) formats *) +(* - CRLF is treated as a single line ending, not separate CR and LF *) diff --git a/Map/Business/WmapParser/Entity.swift b/Map/Business/WmapParser/Entity.swift index 40dd7ca..52f6e93 100644 --- a/Map/Business/WmapParser/Entity.swift +++ b/Map/Business/WmapParser/Entity.swift @@ -1,11 +1,51 @@ struct Wmap { - protocol Entity { - var position: SourcePosition { get } - var range: SourceRange { get } - var syntaxElements: [SyntaxElement] { get } + enum Entity: Equatable { + case vertex(Vertex) + case edge(Edge) + case note(Note) + case stage(Stage) + case group(Group) + case inertia(Inertia) + case evolution(Evolution) + + var position: SourcePosition { + switch self { + case .vertex(let vertex): return vertex.position + case .edge(let edge): return edge.position + case .note(let note): return note.position + case .stage(let stage): return stage.position + case .group(let group): return group.position + case .inertia(let inertia): return inertia.position + case .evolution(let evolution): return evolution.position + } + } + + var range: SourceRange { + switch self { + case .vertex(let vertex): return vertex.range + case .edge(let edge): return edge.range + case .note(let note): return note.range + case .stage(let stage): return stage.range + case .group(let group): return group.range + case .inertia(let inertia): return inertia.range + case .evolution(let evolution): return evolution.range + } + } + + var syntaxElements: [SyntaxElement] { + switch self { + case .vertex(let vertex): return vertex.syntaxElements + case .edge(let edge): return edge.syntaxElements + case .note(let note): return note.syntaxElements + case .stage(let stage): return stage.syntaxElements + case .group(let group): return group.syntaxElements + case .inertia(let inertia): return inertia.syntaxElements + case .evolution(let evolution): return evolution.syntaxElements + } + } } - struct Vertex: Entity { + struct Vertex: Equatable { let label: String let position: SourcePosition let coordinates: (Double, Double) @@ -21,6 +61,12 @@ struct Wmap { let rightParenRange: SourceRange let shapeRange: SourceRange? + static func == (lhs: Vertex, rhs: Vertex) -> Bool { + return lhs.label == rhs.label && lhs.position == rhs.position + && lhs.coordinates.0 == rhs.coordinates.0 && lhs.coordinates.1 == rhs.coordinates.1 + && lhs.shape == rhs.shape && lhs.range == rhs.range + } + var syntaxElements: [SyntaxElement] { var elements: [SyntaxElement] = [] @@ -86,7 +132,7 @@ struct Wmap { } } - struct Edge: Entity { + struct Edge: Equatable { let from: String let to: String let isDirected: Bool @@ -129,7 +175,7 @@ struct Wmap { } } - struct Note: Entity { + struct Note: Equatable { let coordinates: (Double, Double) let text: String let position: SourcePosition @@ -144,6 +190,11 @@ struct Wmap { let rightParenRange: SourceRange let textRange: SourceRange + static func == (lhs: Note, rhs: Note) -> Bool { + return lhs.coordinates.0 == rhs.coordinates.0 && lhs.coordinates.1 == rhs.coordinates.1 + && lhs.text == rhs.text && lhs.position == rhs.position && lhs.range == rhs.range + } + var syntaxElements: [SyntaxElement] { var elements: [SyntaxElement] = [] @@ -207,7 +258,7 @@ struct Wmap { } } - struct Stage: Entity { + struct Stage: Equatable { let number: String // i, ii, iii, iv let value: Double let position: SourcePosition @@ -258,7 +309,7 @@ struct Wmap { } } - struct Group: Entity { + struct Group: Equatable { let vertices: [String] let position: SourcePosition let range: SourceRange @@ -303,7 +354,7 @@ struct Wmap { } } - struct Inertia: Entity { + struct Inertia: Equatable { let vertex: String let position: SourcePosition let range: SourceRange @@ -335,7 +386,7 @@ struct Wmap { } } - struct Evolution: Entity { + struct Evolution: Equatable { let vertex: String let isPositive: Bool let value: Double diff --git a/Map/Business/WmapParser/Lexer.swift b/Map/Business/WmapParser/Lexer.swift index dc789bb..c79879f 100644 --- a/Map/Business/WmapParser/Lexer.swift +++ b/Map/Business/WmapParser/Lexer.swift @@ -18,6 +18,7 @@ extension Wmap { private var current: String.Index private var line = 1 private var column = 1 + private var expectingText = false init(_ input: String) { self.input = input @@ -41,17 +42,9 @@ extension Wmap { let tokenStart = current switch char { - case "\n": - advance() - let token = Token( - type: .newline, - value: "\n", - position: pos, - stringRange: tokenStart..<current - ) - line += 1 - column = 1 - return token + case "\n", "\r": + expectingText = false // Reset expectingText on newline + return scanLineEnding() case "(": advance() @@ -128,6 +121,12 @@ extension Wmap { return scanStageOrLabel() default: + // If we're expecting text, scan as text instead of vertex label + if expectingText { + expectingText = false // Reset after scanning text + return scanText() + } + // If we encounter an unexpected character, try to scan it as a vertex label // This provides more graceful degradation than failing immediately return scanVertexLabel() @@ -136,13 +135,17 @@ extension Wmap { func scanText() -> Token { let pos = currentPosition() + + // Skip any leading whitespace + skipWhitespace() + let tokenStart = current - while current < input.endIndex && input[current] != "\n" { + while current < input.endIndex && !isLineEnding(input[current]) { advance() } - let originalValue = String(input[tokenStart..<current]) + let originalValue = String(input[tokenStart..<current]).trimmingCharacters(in: .whitespaces) return Token( type: .text, value: originalValue, @@ -151,19 +154,69 @@ extension Wmap { ) } + private func scanLineEnding() -> Token { + let pos = currentPosition() + let tokenStart = current + + if input[current] == "\r" { + advance() + // Check for CRLF + if current < input.endIndex && input[current] == "\n" { + advance() + let token = Token( + type: .newline, + value: "\r\n", + position: pos, + stringRange: tokenStart..<current + ) + line += 1 + column = 1 + return token + } else { + // Just CR + let token = Token( + type: .newline, + value: "\r", + position: pos, + stringRange: tokenStart..<current + ) + line += 1 + column = 1 + return token + } + } else if input[current] == "\n" { + advance() + let token = Token( + type: .newline, + value: "\n", + position: pos, + stringRange: tokenStart..<current + ) + line += 1 + column = 1 + return token + } else { + fatalError("scanLineEnding called on non-line-ending character") + } + } + + private func isLineEnding(_ char: Character) -> Bool { + return char == "\n" || char == "\r" + } + private func scanBracketedToken() -> Token { let pos = currentPosition() let tokenStart = current advance() // consume '[' let contentStart = current - while current < input.endIndex && input[current] != "]" && input[current] != "\n" { + while current < input.endIndex && input[current] != "]" && !isLineEnding(input[current]) { advance() } - // If we hit a newline or EOF before finding the closing bracket, it's an error + // If we hit a line ending or EOF before finding the closing bracket, it's an error guard current < input.endIndex && input[current] == "]" else { - // Return error token for unclosed bracket (don't consume past newline) + // Return error token for unclosed bracket (don't consume past line ending) return Token( type: .error, value: String(input[tokenStart..<current]), @@ -180,6 +233,7 @@ extension Wmap { switch lowercaseContent { case "note": + expectingText = true // Set expectingText when we return a note keyword return Token( type: .noteKeyword, value: "[Note]", @@ -311,16 +365,17 @@ extension Wmap { private func isVertexLabelDelimiter(_ char: Character) -> Bool { return char == "-" || char == "+" || char == "," || char == "[" || char == "]" || char == "(" - || char == ")" || char == "\n" + || char == ")" || isLineEnding(char) } private func isDelimiter(_ char: Character) -> Bool { - return char.isWhitespace || char == "\n" || char == "(" || char == ")" || char == "[" + return char.isWhitespace || isLineEnding(char) || char == "(" || char == ")" || char == "[" || char == "]" || char == "," } private func skipWhitespace() { - while current < input.endIndex && input[current].isWhitespace && input[current] != "\n" { + while current < input.endIndex && input[current].isWhitespace && !isLineEnding(input[current]) + { advance() } } diff --git a/Map/Business/WmapParser/Parser.swift b/Map/Business/WmapParser/Parser.swift index f04998c..9418263 100644 --- a/Map/Business/WmapParser/Parser.swift +++ b/Map/Business/WmapParser/Parser.swift @@ -56,15 +56,15 @@ extension Wmap { case .vertexLabel: return try parseVertexOrEdge() case .noteKeyword: - return try parseNote() + return .note(try parseNote()) case .stageNumber: - return try parseStage() + return .stage(try parseStage()) case .groupKeyword: - return try parseGroup() + return .group(try parseGroup()) case .inertiaKeyword: - return try parseInertia() + return .inertia(try parseInertia()) case .evolutionKeyword: - return try parseEvolution() + return .evolution(try parseEvolution()) case .error: // Error tokens should cause the line to be ignored throw ParseError.invalidLine @@ -97,19 +97,20 @@ extension Wmap { vertexLabelMap[secondLabel.lowercased()] = secondLabel advance() - return Edge( - from: firstLabel, - to: secondLabel, - isDirected: isDirected, - position: startPos, - range: SourceRange( - start: startPos, end: endPos, - stringRange: firstLabelRange.stringRange - .lowerBound..<secondLabelRange.stringRange.upperBound), - fromLabelRange: firstLabelRange, - edgeTypeRange: edgeTypeRange, - toLabelRange: secondLabelRange - ) + return .edge( + Edge( + from: firstLabel, + to: secondLabel, + isDirected: isDirected, + position: startPos, + range: SourceRange( + start: startPos, end: endPos, + stringRange: firstLabelRange.stringRange + .lowerBound..<secondLabelRange.stringRange.upperBound), + fromLabelRange: firstLabelRange, + edgeTypeRange: edgeTypeRange, + toLabelRange: secondLabelRange + )) } else { // Parse vertex let (coordinates, coordRanges) = try parsePositionWithRanges() @@ -117,24 +118,25 @@ extension Wmap { let endPos = shapeRange?.end ?? coordRanges.rightParen.end - return Vertex( - label: firstLabel, - position: startPos, - coordinates: coordinates, - shape: shape, - range: SourceRange( - start: startPos, end: endPos, - stringRange: firstLabelRange.stringRange - .lowerBound..<(shapeRange?.stringRange.upperBound - ?? coordRanges.rightParen.stringRange.upperBound)), - labelRange: firstLabelRange, - leftParenRange: coordRanges.leftParen, - xCoordRange: coordRanges.xCoord, - commaRange: coordRanges.comma, - yCoordRange: coordRanges.yCoord, - rightParenRange: coordRanges.rightParen, - shapeRange: shapeRange - ) + return .vertex( + Vertex( + label: firstLabel, + position: startPos, + coordinates: coordinates, + shape: shape, + range: SourceRange( + start: startPos, end: endPos, + stringRange: firstLabelRange.stringRange + .lowerBound..<(shapeRange?.stringRange.upperBound + ?? coordRanges.rightParen.stringRange.upperBound)), + labelRange: firstLabelRange, + leftParenRange: coordRanges.leftParen, + xCoordRange: coordRanges.xCoord, + commaRange: coordRanges.comma, + yCoordRange: coordRanges.yCoord, + rightParenRange: coordRanges.rightParen, + shapeRange: shapeRange + )) } } @@ -145,12 +147,13 @@ extension Wmap { let (coordinates, coordRanges) = try parsePositionWithRanges() - // For notes, we need to scan the remaining text on the line - let textToken = lexer.scanText() - let text = textToken.value - let textRange = makeSourceRange(from: textToken) - let endPos = textToken.position - currentToken = lexer.nextToken() // Update current token after scanning text + guard currentToken.type == .text else { + throw ParseError.expectedText(actual: currentToken) + } + let text = currentToken.value + let textRange = makeSourceRange(from: currentToken) + let endPos = currentToken.position + advance() return Note( coordinates: coordinates, diff --git a/Map/Data/MapDocument.swift b/Map/Data/MapDocument.swift index 95fcfde..6cc84d7 100644 --- a/Map/Data/MapDocument.swift +++ b/Map/Data/MapDocument.swift @@ -69,7 +69,7 @@ struct MapDocument: FileDocument { @MainActor func exportAsImage(withEvolution selectedEvolution: StageType) -> NSImage? { let renderView = MapRenderView( - document: .constant(self), + entities: self.parsed, evolution: .constant(selectedEvolution)) let renderer = ImageRenderer(content: renderView) diff --git a/Map/Presentation/Complex Components/MapRender/MapRenderView.swift b/Map/Presentation/Complex Components/MapRender/MapRenderView.swift index 72a765d..08d1d61 100644 --- a/Map/Presentation/Complex Components/MapRender/MapRenderView.swift +++ b/Map/Presentation/Complex Components/MapRender/MapRenderView.swift @@ -19,8 +19,10 @@ import SwiftUI struct MapRenderView: View { - @Binding var document: MapDocument + var entities: [Wmap.Entity] @Binding var evolution: StageType + + @State private var lastEntityHash: Int = 0 @AppStorage("showMapBackground") var showMapBackground: Bool = UserPreferences.Defaults .showMapBackground @AppStorage("useCustomFont") var useCustomFont: Bool = UserPreferences.Defaults.useCustomFont @@ -44,14 +46,14 @@ struct MapRenderView: View { var onDragVertex: (Vertex, CGFloat, CGFloat) -> Void = { _, _, _ in } - private func parseMapInBackground() { + private func convertEntitiesInBackground() { parseTask?.cancel() - let currentText = document.text + let currentEntities = entities parseTask = Task { let parsed = await Task.detached(priority: .userInitiated) { - MapParser.parse(content: currentText) + convertEntitiesToParsedMap(currentEntities) }.value let positions = await Task.detached(priority: .userInitiated) { @@ -92,6 +94,138 @@ struct MapRenderView: View { } } + private nonisolated func convertEntitiesToParsedMap(_ entities: [Wmap.Entity]) -> ParsedMap { + var vertices: [Vertex] = [] + var edges: [MapEdge] = [] + var inertias: [Inertia] = [] + var opportunities: [Opportunity] = [] + var notes: [Note] = [] + var stages: [CGFloat] = [25.0, 50.0, 75.0] // default dimensions + var groups: [[Vertex]] = [] + + // Build a vertex lookup for edges, groups, etc. + var vertexMap: [String: Vertex] = [:] + var vertexIdCounter = 0 + + for entity in entities { + switch entity { + case .vertex(let vertex): + let newVertex = Vertex( + id: vertexIdCounter, + label: vertex.label, + position: CGPoint(x: vertex.coordinates.0, y: vertex.coordinates.1), + shape: convertShape(vertex.shape) + ) + vertices.append(newVertex) + vertexMap[vertex.label] = newVertex + vertexIdCounter += 1 + + case .note(let note): + notes.append( + Note( + id: notes.count, + position: CGPoint(x: note.coordinates.0, y: note.coordinates.1), + text: note.text + )) + + case .stage(let stage): + let stageIndex = convertStageNumber(stage.number) - 1 + if stageIndex >= 0 && stageIndex < 3 { + stages[stageIndex] = stage.value + } + + default: + continue + } + } + + // Second pass for entities that depend on vertices + for entity in entities { + switch entity { + case .edge(let edge): + if let fromVertex = vertexMap[edge.from], + let toVertex = vertexMap[edge.to] + { + edges.append( + MapEdge( + id: edges.count, + origin: fromVertex.position, + destination: toVertex.position, + arrowhead: edge.isDirected + )) + } + + case .inertia(let inertia): + if let vertex = vertexMap[inertia.vertex] { + inertias.append( + Inertia( + id: inertias.count, + position: vertex.position + )) + } + + case .evolution(let evolution): + if let vertex = vertexMap[evolution.vertex] { + let direction: CGFloat = evolution.isPositive ? 1.0 : -1.0 + let destination = CGPoint( + x: vertex.position.x + (direction * evolution.value), + y: vertex.position.y + ) + opportunities.append( + Opportunity( + id: opportunities.count, + origin: vertex.position, + destination: destination + )) + } + + case .group(let group): + var groupVertices: [Vertex] = [] + for vertexName in group.vertices { + if let vertex = vertexMap[vertexName] { + groupVertices.append(vertex) + } + } + if !groupVertices.isEmpty { + groups.append(groupVertices) + } + + default: + continue + } + } + + return ParsedMap( + vertices: vertices, + edges: edges, + inertias: inertias, + opportunities: opportunities, + notes: notes, + stages: stages, + groups: groups + ) + } + + private nonisolated func convertShape(_ shape: String?) -> VertexShape { + guard let shape = shape?.lowercased() else { return .circle } + switch shape { + case "square": return .square + case "triangle": return .triangle + case "x": return .x + default: return .circle + } + } + + private nonisolated func convertStageNumber(_ stageNumber: String) -> Int { + switch stageNumber.lowercased() { + case "i": return 1 + case "ii": return 2 + case "iii": return 3 + case "iv": return 4 + default: return 1 + } + } + var body: some View { ZStack(alignment: .topLeading) { @@ -137,13 +271,13 @@ struct MapRenderView: View { height: mapSize.height + 2 * padding, alignment: .topLeading ) .onAppear { - parseMapInBackground() + convertEntitiesInBackground() } - .onChange(of: document.text) { - parseMapInBackground() + .onChange(of: entities) { _, _ in + convertEntitiesInBackground() } .onChange(of: useSmartLabelPositioning) { - parseMapInBackground() + convertEntitiesInBackground() } .onDisappear { parseTask?.cancel() @@ -161,7 +295,7 @@ struct MapRenderView: View { #Preview { MapRenderView( - document: Binding.constant(MapDocument(text: "")), + entities: [], evolution: Binding.constant(StageType.general) ) } diff --git a/Map/Presentation/MapEditor.swift b/Map/Presentation/MapEditor.swift index 167ef77..0f8f24e 100644 --- a/Map/Presentation/MapEditor.swift +++ b/Map/Presentation/MapEditor.swift @@ -120,8 +120,9 @@ struct MapEditor: View { GeometryReader { geometry in ScrollView([.horizontal, .vertical], showsIndicators: false) { MapRenderView( - document: $document, evolution: $selectedEvolution, onDragVertex: onDragVertex - ).scaleEffect(zoom, anchor: .center).frame( + entities: parsedMap, evolution: $selectedEvolution, onDragVertex: onDragVertex + ) + .scaleEffect(zoom, anchor: .center).frame( width: (Dimensions.Map.size.width + 2 * Dimensions.Map.padding) * zoom, height: (Dimensions.Map.size.height + 2 * Dimensions.Map.padding) * zoom ) |