diff options
| -rw-r--r-- | Documentation/wmap-spec.ebnf | 47 | ||||
| -rw-r--r-- | Map/Business/WmapParser/Entity.swift | 389 | ||||
| -rw-r--r-- | Map/Business/WmapParser/Lexer.swift | 344 | ||||
| -rw-r--r-- | Map/Business/WmapParser/Parser.swift | 488 | ||||
| -rw-r--r-- | Map/Business/WmapParser/SourcePosition.swift | 35 | ||||
| -rw-r--r-- | Map/Business/WmapParser/Syntax.swift | 32 | ||||
| -rw-r--r-- | Map/Business/WmapParser/Token.swift | 22 | ||||
| -rw-r--r-- | Map/Business/WmapParser/TokenType.swift | 36 | ||||
| -rw-r--r-- | Map/Business/WmapSyntaxHighlighter/SyntaxHighlighter.swift | 83 | ||||
| -rw-r--r-- | Map/Data/MapDocument.swift | 6 | ||||
| -rw-r--r-- | Map/Presentation/Base Components/MapTextEditor.swift | 167 | ||||
| -rw-r--r-- | Map/Presentation/MapEditor.swift | 4 | ||||
| -rw-r--r-- | Map/Presentation/Preferences/EditorPreferencesView.swift | 10 | ||||
| -rw-r--r-- | Map/Presentation/Theme/NSColor+theme.swift | 2 |
14 files changed, 1560 insertions, 105 deletions
diff --git a/Documentation/wmap-spec.ebnf b/Documentation/wmap-spec.ebnf new file mode 100644 index 0000000..d3c5ad7 --- /dev/null +++ b/Documentation/wmap-spec.ebnf @@ -0,0 +1,47 @@ +(* wmap Language Specification *) + +(* Top-level structure *) +program = { line } ; +line = ( entity newline ) | ( ignored_line newline ) | newline ; + +entity = vertex | edge | note | stage | group | inertia | evolution ; +ignored_line = ? any sequence of tokens that doesn't match entity grammar ? ; + +(* Entities *) +vertex = vertex_label position [ shape ] ; +edge = vertex_label edge_type vertex_label ; +note = case_insensitive("[Note]") position text ; +stage = "[" stage_number "]" real_number ; +group = case_insensitive("[Group]") vertex_label { "," vertex_label } ; +inertia = case_insensitive("[Inertia]") vertex_label ; +evolution = case_insensitive("[Evolution]") vertex_label sign real_number ; + +(* Component definitions *) +position = "(" real_number "," real_number ")" ; +shape = "[" shape_label "]" ; +edge_type = "--" | "->" ; +sign = "+" | "-" ; + +(* Terminal symbols *) +vertex_label = preserved_case( { character - ( "-" | "+" | "," | "[" | "]" | "(" | ")" | newline ) } ) ; +text = preserved_case( { character - newline } ) ; +real_number = [ digit ] { digit } [ "." { digit } ] | "." digit { digit } ; +stage_number = case_insensitive( "i" | "ii" | "iii" | "iv" ) ; +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 ? ; + +(* Lexical rules *) +case_insensitive(x) = ? case-insensitive match of x ? ; +preserved_case(x) = ? case-insensitive match of x, preserving original case for display ? ; + +(* Notes *) +(* - 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 *) +(* - Each line contains exactly one entity *) +(* - Invalid lines should be ignored. *) diff --git a/Map/Business/WmapParser/Entity.swift b/Map/Business/WmapParser/Entity.swift new file mode 100644 index 0000000..40dd7ca --- /dev/null +++ b/Map/Business/WmapParser/Entity.swift @@ -0,0 +1,389 @@ +struct Wmap { + protocol Entity { + var position: SourcePosition { get } + var range: SourceRange { get } + var syntaxElements: [SyntaxElement] { get } + } + + struct Vertex: Entity { + let label: String + let position: SourcePosition + let coordinates: (Double, Double) + let shape: String? + let range: SourceRange + + // Syntax element ranges (populated during parsing) + let labelRange: SourceRange + let leftParenRange: SourceRange + let xCoordRange: SourceRange + let commaRange: SourceRange + let yCoordRange: SourceRange + let rightParenRange: SourceRange + let shapeRange: SourceRange? + + var syntaxElements: [SyntaxElement] { + var elements: [SyntaxElement] = [] + + // Vertex label + elements.append( + SyntaxElement( + type: .vertexLabel, + range: labelRange, + value: label + )) + + // Left parenthesis + elements.append( + SyntaxElement( + type: .punctuation, + range: leftParenRange, + value: "(" + )) + + // X coordinate + elements.append( + SyntaxElement( + type: .number, + range: xCoordRange, + value: String(coordinates.0) + )) + + // Comma + elements.append( + SyntaxElement( + type: .punctuation, + range: commaRange, + value: "," + )) + + // Y coordinate + elements.append( + SyntaxElement( + type: .number, + range: yCoordRange, + value: String(coordinates.1) + )) + + // Right parenthesis + elements.append( + SyntaxElement( + type: .punctuation, + range: rightParenRange, + value: ")" + )) + + // Shape if present + if let shape = shape, let shapeRange = shapeRange { + elements.append( + SyntaxElement( + type: .shape, + range: shapeRange, + value: "[\(shape)]" + )) + } + + return elements + } + } + + struct Edge: Entity { + let from: String + let to: String + let isDirected: Bool + let position: SourcePosition + let range: SourceRange + + // Syntax element ranges + let fromLabelRange: SourceRange + let edgeTypeRange: SourceRange + let toLabelRange: SourceRange + + var syntaxElements: [SyntaxElement] { + var elements: [SyntaxElement] = [] + + // From vertex label + elements.append( + SyntaxElement( + type: .vertexLabel, + range: fromLabelRange, + value: from + )) + + // Edge symbol + elements.append( + SyntaxElement( + type: .symbol, + range: edgeTypeRange, + value: isDirected ? "->" : "--" + )) + + // To vertex label + elements.append( + SyntaxElement( + type: .vertexLabel, + range: toLabelRange, + value: to + )) + + return elements + } + } + + struct Note: Entity { + let coordinates: (Double, Double) + let text: String + let position: SourcePosition + let range: SourceRange + + // Syntax element ranges + let keywordRange: SourceRange + let leftParenRange: SourceRange + let xCoordRange: SourceRange + let commaRange: SourceRange + let yCoordRange: SourceRange + let rightParenRange: SourceRange + let textRange: SourceRange + + var syntaxElements: [SyntaxElement] { + var elements: [SyntaxElement] = [] + + // [Note] keyword + elements.append( + SyntaxElement( + type: .keyword, + range: keywordRange, + value: "[Note]" + )) + + // Left parenthesis + elements.append( + SyntaxElement( + type: .punctuation, + range: leftParenRange, + value: "(" + )) + + // X coordinate + elements.append( + SyntaxElement( + type: .number, + range: xCoordRange, + value: String(coordinates.0) + )) + + // Comma + elements.append( + SyntaxElement( + type: .punctuation, + range: commaRange, + value: "," + )) + + // Y coordinate + elements.append( + SyntaxElement( + type: .number, + range: yCoordRange, + value: String(coordinates.1) + )) + + // Right parenthesis + elements.append( + SyntaxElement( + type: .punctuation, + range: rightParenRange, + value: ")" + )) + + // Text content + elements.append( + SyntaxElement( + type: .text, + range: textRange, + value: text + )) + + return elements + } + } + + struct Stage: Entity { + let number: String // i, ii, iii, iv + let value: Double + let position: SourcePosition + let range: SourceRange + + // Syntax element ranges + let leftBracketRange: SourceRange + let stageNumberRange: SourceRange + let rightBracketRange: SourceRange + let valueRange: SourceRange + + var syntaxElements: [SyntaxElement] { + var elements: [SyntaxElement] = [] + + // Left bracket + elements.append( + SyntaxElement( + type: .punctuation, + range: leftBracketRange, + value: "[" + )) + + // Stage number + elements.append( + SyntaxElement( + type: .stageNumber, + range: stageNumberRange, + value: number + )) + + // Right bracket + elements.append( + SyntaxElement( + type: .punctuation, + range: rightBracketRange, + value: "]" + )) + + // Value + elements.append( + SyntaxElement( + type: .number, + range: valueRange, + value: String(value) + )) + + return elements + } + } + + struct Group: Entity { + let vertices: [String] + let position: SourcePosition + let range: SourceRange + + // Syntax element ranges + let keywordRange: SourceRange + let vertexRanges: [SourceRange] + let commaRanges: [SourceRange] + + var syntaxElements: [SyntaxElement] { + var elements: [SyntaxElement] = [] + + // [Group] keyword + elements.append( + SyntaxElement( + type: .keyword, + range: keywordRange, + value: "[Group]" + )) + + // Vertex labels and commas + for (index, vertex) in vertices.enumerated() { + elements.append( + SyntaxElement( + type: .vertexLabel, + range: vertexRanges[index], + value: vertex + )) + + // Add comma if not the last vertex + if index < vertices.count - 1 { + elements.append( + SyntaxElement( + type: .punctuation, + range: commaRanges[index], + value: "," + )) + } + } + + return elements + } + } + + struct Inertia: Entity { + let vertex: String + let position: SourcePosition + let range: SourceRange + + // Syntax element ranges + let keywordRange: SourceRange + let vertexRange: SourceRange + + var syntaxElements: [SyntaxElement] { + var elements: [SyntaxElement] = [] + + // [Inertia] keyword + elements.append( + SyntaxElement( + type: .keyword, + range: keywordRange, + value: "[Inertia]" + )) + + // Vertex label + elements.append( + SyntaxElement( + type: .vertexLabel, + range: vertexRange, + value: vertex + )) + + return elements + } + } + + struct Evolution: Entity { + let vertex: String + let isPositive: Bool + let value: Double + let position: SourcePosition + let range: SourceRange + + // Syntax element ranges + let keywordRange: SourceRange + let vertexRange: SourceRange + let signRange: SourceRange + let valueRange: SourceRange + + var syntaxElements: [SyntaxElement] { + var elements: [SyntaxElement] = [] + + // [Evolution] keyword + elements.append( + SyntaxElement( + type: .keyword, + range: keywordRange, + value: "[Evolution]" + )) + + // Vertex label + elements.append( + SyntaxElement( + type: .vertexLabel, + range: vertexRange, + value: vertex + )) + + // Sign + elements.append( + SyntaxElement( + type: .symbol, + range: signRange, + value: isPositive ? "+" : "-" + )) + + // Value + elements.append( + SyntaxElement( + type: .number, + range: valueRange, + value: String(value) + )) + + return elements + } + } +} diff --git a/Map/Business/WmapParser/Lexer.swift b/Map/Business/WmapParser/Lexer.swift new file mode 100644 index 0000000..dc789bb --- /dev/null +++ b/Map/Business/WmapParser/Lexer.swift @@ -0,0 +1,344 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. +extension Wmap { + class Lexer { + let input: String + private var current: String.Index + private var line = 1 + private var column = 1 + + init(_ input: String) { + self.input = input + self.current = input.startIndex + } + + func nextToken() -> Token { + skipWhitespace() + + guard current < input.endIndex else { + return Token( + type: .eof, + value: "", + position: currentPosition(), + stringRange: current..<current + ) + } + + let char = input[current].lowercased().first! + let pos = currentPosition() + 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 "(": + advance() + return Token( + type: .leftParen, + value: "(", + position: pos, + stringRange: tokenStart..<current + ) + + case ")": + advance() + return Token( + type: .rightParen, + value: ")", + position: pos, + stringRange: tokenStart..<current + ) + + case "[": + return scanBracketedToken() + + case ",": + advance() + return Token( + type: .comma, + value: ",", + position: pos, + stringRange: tokenStart..<current + ) + + case "+": + advance() + return Token( + type: .plus, + value: "+", + position: pos, + stringRange: tokenStart..<current + ) + + case "-": + if peek() == "-" { + advance() + advance() + return Token( + type: .edgeUndirected, + value: "--", + position: pos, + stringRange: tokenStart..<current + ) + } else if peek() == ">" { + advance() + advance() + return Token( + type: .edgeDirected, + value: "->", + position: pos, + stringRange: tokenStart..<current + ) + } else { + advance() + return Token( + type: .minus, + value: "-", + position: pos, + stringRange: tokenStart..<current + ) + } + + case "0"..."9", ".": + return scanNumber() + + case "i": + return scanStageOrLabel() + + default: + // If we encounter an unexpected character, try to scan it as a vertex label + // This provides more graceful degradation than failing immediately + return scanVertexLabel() + } + } + + func scanText() -> Token { + let pos = currentPosition() + let tokenStart = current + + while current < input.endIndex && input[current] != "\n" { + advance() + } + + let originalValue = String(input[tokenStart..<current]) + return Token( + type: .text, + value: originalValue, + position: pos, + stringRange: tokenStart..<current + ) + } + + private func scanBracketedToken() -> Token { + let pos = currentPosition() + let tokenStart = current + advance() // consume '[' + + let contentStart = current + while current < input.endIndex && input[current] != "]" && input[current] != "\n" { + advance() + } + + // If we hit a newline 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 Token( + type: .error, + value: String(input[tokenStart..<current]), + position: pos, + stringRange: tokenStart..<current + ) + } + + let originalContent = String(input[contentStart..<current]) + let lowercaseContent = originalContent.lowercased() + advance() // consume ']' + + let tokenEnd = current + + switch lowercaseContent { + case "note": + return Token( + type: .noteKeyword, + value: "[Note]", + position: pos, + stringRange: tokenStart..<tokenEnd + ) + case "group": + return Token( + type: .groupKeyword, + value: "[Group]", + position: pos, + stringRange: tokenStart..<tokenEnd + ) + case "inertia": + return Token( + type: .inertiaKeyword, + value: "[Inertia]", + position: pos, + stringRange: tokenStart..<tokenEnd + ) + case "evolution": + return Token( + type: .evolutionKeyword, + value: "[Evolution]", + position: pos, + stringRange: tokenStart..<tokenEnd + ) + case "x", "square", "triangle", "circle": + return Token( + type: .shapeLabel, + value: originalContent, // Use original case, not lowercase + position: pos, + stringRange: tokenStart..<tokenEnd + ) + default: + if lowercaseContent.allSatisfy({ $0 == "i" }) && lowercaseContent.count <= 4 { + return Token( + type: .stageNumber, + value: lowercaseContent, + position: pos, + stringRange: tokenStart..<tokenEnd + ) + } + // Return error token for invalid bracketed content + return Token( + type: .error, + value: String(input[tokenStart..<tokenEnd]), + position: pos, + stringRange: tokenStart..<tokenEnd + ) + } + } + + private func scanNumber() -> Token { + let pos = currentPosition() + let tokenStart = current + + // Handle leading decimal point + if input[current] == "." { + advance() + } + + while current < input.endIndex && input[current].isNumber { + advance() + } + + // Handle decimal point if we haven't seen one + if current < input.endIndex && input[current] == "." && input[tokenStart] != "." { + advance() + while current < input.endIndex && input[current].isNumber { + advance() + } + } + + let value = String(input[tokenStart..<current]) + return Token( + type: .realNumber, + value: value, + position: pos, + stringRange: tokenStart..<current + ) + } + + private func scanStageOrLabel() -> Token { + let pos = currentPosition() + let tokenStart = current + + // Check if it's a stage number (i, ii, iii, iv) + var tempIndex = current + var iCount = 0 + + while tempIndex < input.endIndex && input[tempIndex].lowercased() == "i" && iCount < 4 { + tempIndex = input.index(after: tempIndex) + iCount += 1 + } + + // If we have 1-4 i's and next char is whitespace/delimiter, it's a stage + if iCount > 0 && iCount <= 4 && (tempIndex >= input.endIndex || isDelimiter(input[tempIndex])) + { + current = tempIndex + return Token( + type: .stageNumber, + value: String(repeating: "i", count: iCount), + position: pos, + stringRange: tokenStart..<current + ) + } + + // Otherwise, scan as vertex label + return scanVertexLabel() + } + + private func scanVertexLabel() -> Token { + let pos = currentPosition() + let tokenStart = current + + while current < input.endIndex && !isVertexLabelDelimiter(input[current]) { + advance() + } + + let originalValue = String(input[tokenStart..<current]).trimmingCharacters(in: .whitespaces) + return Token( + type: .vertexLabel, + value: originalValue, + position: pos, + stringRange: tokenStart..<current + ) + } + + private func isVertexLabelDelimiter(_ char: Character) -> Bool { + return char == "-" || char == "+" || char == "," || char == "[" || char == "]" || char == "(" + || char == ")" || char == "\n" + } + + private func isDelimiter(_ char: Character) -> Bool { + return char.isWhitespace || char == "\n" || char == "(" || char == ")" || char == "[" + || char == "]" || char == "," + } + + private func skipWhitespace() { + while current < input.endIndex && input[current].isWhitespace && input[current] != "\n" { + advance() + } + } + + private func advance() { + if current < input.endIndex { + current = input.index(after: current) + column += 1 + } + } + + private func peek() -> Character? { + let next = input.index(after: current) + return next < input.endIndex ? input[next] : nil + } + + private func currentPosition() -> SourcePosition { + return SourcePosition(line: line, column: column, stringIndex: current) + } + } +} diff --git a/Map/Business/WmapParser/Parser.swift b/Map/Business/WmapParser/Parser.swift new file mode 100644 index 0000000..f04998c --- /dev/null +++ b/Map/Business/WmapParser/Parser.swift @@ -0,0 +1,488 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. +import Foundation + +extension Wmap { + class Parser { + private let lexer: Lexer + private var currentToken: Token + private var vertexLabelMap: [String: String] = [:] + + init(lexer: Lexer) { + self.lexer = lexer + self.currentToken = lexer.nextToken() + } + + func parse() -> [Entity] { + var entities: [Entity] = [] + + while currentToken.type != .eof { + if currentToken.type == .newline { + advance() + continue + } + + do { + let entity = try parseEntity() + entities.append(entity) + + if currentToken.type == .newline { + advance() + } else if currentToken.type != .eof { + throw ParseError.expectedNewline(actual: currentToken) + } + } catch { + skipToNextLine() + } + } + + return entities + } + + private func parseEntity() throws -> Entity { + switch currentToken.type { + case .vertexLabel: + return try parseVertexOrEdge() + case .noteKeyword: + return try parseNote() + case .stageNumber: + return try parseStage() + case .groupKeyword: + return try parseGroup() + case .inertiaKeyword: + return try parseInertia() + case .evolutionKeyword: + return try parseEvolution() + case .error: + // Error tokens should cause the line to be ignored + throw ParseError.invalidLine + default: + throw ParseError.invalidLine + } + } + + private func parseVertexOrEdge() throws -> Entity { + let startPos = currentToken.position + let firstLabel = currentToken.value + let firstLabelRange = makeSourceRange(from: currentToken) + + vertexLabelMap[firstLabel.lowercased()] = firstLabel + advance() + + if currentToken.type == .edgeUndirected || currentToken.type == .edgeDirected { + let isDirected = currentToken.type == .edgeDirected + let edgeTypeRange = makeSourceRange(from: currentToken) + advance() + + guard currentToken.type == .vertexLabel else { + throw ParseError.expectedVertexLabel(actual: currentToken) + } + + let secondLabel = currentToken.value + let secondLabelRange = makeSourceRange(from: currentToken) + let endPos = currentToken.position + + 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 + ) + } else { + // Parse vertex + let (coordinates, coordRanges) = try parsePositionWithRanges() + let (shape, shapeRange) = try parseOptionalShapeWithRange() + + 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 + ) + } + } + + private func parseNote() throws -> Note { + let startPos = currentToken.position + let keywordRange = makeSourceRange(from: currentToken) + advance() + + 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 + + return Note( + coordinates: coordinates, + text: text, + position: startPos, + range: SourceRange( + start: startPos, end: endPos, + stringRange: keywordRange.stringRange.lowerBound..<textRange.stringRange.upperBound), + keywordRange: keywordRange, + leftParenRange: coordRanges.leftParen, + xCoordRange: coordRanges.xCoord, + commaRange: coordRanges.comma, + yCoordRange: coordRanges.yCoord, + rightParenRange: coordRanges.rightParen, + textRange: textRange + ) + } + + private func parseStage() throws -> Stage { + let startPos = currentToken.position + let stageToken = currentToken + let stageValue = stageToken.value + + // For bracketed stage numbers like [ii], we need to parse the components + // The lexer gives us the full token, but we need to break it down + let tokenRange = stageToken.stringRange + + // Find the bracket positions within the token + let leftBracketRange = SourceRange( + start: stageToken.position, + end: SourcePosition( + line: stageToken.position.line, + column: stageToken.position.column + 1, + stringIndex: lexer.input.index(after: tokenRange.lowerBound) + ), + stringRange: tokenRange.lowerBound..<lexer.input.index(after: tokenRange.lowerBound) + ) + + let stageNumberStart = lexer.input.index(after: tokenRange.lowerBound) + let stageNumberEnd = lexer.input.index(tokenRange.upperBound, offsetBy: -1) + let stageNumberRange = SourceRange( + start: SourcePosition( + line: stageToken.position.line, + column: stageToken.position.column + 1, + stringIndex: stageNumberStart + ), + end: SourcePosition( + line: stageToken.position.line, + column: stageToken.position.column + 1 + stageValue.count, + stringIndex: stageNumberEnd + ), + stringRange: stageNumberStart..<stageNumberEnd + ) + + let rightBracketRange = SourceRange( + start: SourcePosition( + line: stageToken.position.line, + column: stageToken.position.column + 1 + stageValue.count, + stringIndex: stageNumberEnd + ), + end: SourcePosition( + line: stageToken.position.line, + column: stageToken.position.column + 2 + stageValue.count, + stringIndex: tokenRange.upperBound + ), + stringRange: stageNumberEnd..<tokenRange.upperBound + ) + + advance() + + guard currentToken.type == .realNumber else { + throw ParseError.expectedNumber(actual: currentToken) + } + + guard let value = Double(currentToken.value) else { + throw ParseError.invalidNumber(currentToken.value) + } + + let valueRange = makeSourceRange(from: currentToken) + let endPos = currentToken.position + advance() + + return Stage( + number: stageValue, + value: value, + position: startPos, + range: SourceRange( + start: startPos, + end: endPos, + stringRange: leftBracketRange.stringRange.lowerBound..<valueRange.stringRange.upperBound + ), + leftBracketRange: leftBracketRange, + stageNumberRange: stageNumberRange, + rightBracketRange: rightBracketRange, + valueRange: valueRange + ) + } + + private func parseGroup() throws -> Group { + let startPos = currentToken.position + let keywordRange = makeSourceRange(from: currentToken) + advance() + + var vertices: [String] = [] + var vertexRanges: [SourceRange] = [] + var commaRanges: [SourceRange] = [] + + guard currentToken.type == .vertexLabel else { + throw ParseError.expectedVertexLabel(actual: currentToken) + } + + vertices.append(currentToken.value) + vertexRanges.append(makeSourceRange(from: currentToken)) + advance() + + while currentToken.type == .comma { + commaRanges.append(makeSourceRange(from: currentToken)) + advance() + + guard currentToken.type == .vertexLabel else { + throw ParseError.expectedVertexLabel(actual: currentToken) + } + + vertices.append(currentToken.value) + vertexRanges.append(makeSourceRange(from: currentToken)) + advance() + } + + let endPos = vertexRanges.last?.end ?? startPos + + return Group( + vertices: vertices, + position: startPos, + range: SourceRange( + start: startPos, end: endPos, + stringRange: keywordRange.stringRange + .lowerBound..<vertexRanges.last!.stringRange.upperBound), + keywordRange: keywordRange, + vertexRanges: vertexRanges, + commaRanges: commaRanges + ) + } + + private func parseInertia() throws -> Inertia { + let startPos = currentToken.position + let keywordRange = makeSourceRange(from: currentToken) + advance() + + guard currentToken.type == .vertexLabel else { + throw ParseError.expectedVertexLabel(actual: currentToken) + } + + let vertex = currentToken.value + let vertexRange = makeSourceRange(from: currentToken) + let endPos = currentToken.position + advance() + + return Inertia( + vertex: vertex, + position: startPos, + range: SourceRange( + start: startPos, end: endPos, + stringRange: keywordRange.stringRange.lowerBound..<vertexRange.stringRange.upperBound), + keywordRange: keywordRange, + vertexRange: vertexRange + ) + } + + private func parseEvolution() throws -> Evolution { + let startPos = currentToken.position + let keywordRange = makeSourceRange(from: currentToken) + advance() + + guard currentToken.type == .vertexLabel else { + throw ParseError.expectedVertexLabel(actual: currentToken) + } + + let vertex = currentToken.value + let vertexRange = makeSourceRange(from: currentToken) + advance() + + let isPositive: Bool + let signRange: SourceRange + if currentToken.type == .plus { + isPositive = true + signRange = makeSourceRange(from: currentToken) + advance() + } else if currentToken.type == .minus { + isPositive = false + signRange = makeSourceRange(from: currentToken) + advance() + } else { + throw ParseError.expectedSign(actual: currentToken) + } + + guard currentToken.type == .realNumber else { + throw ParseError.expectedNumber(actual: currentToken) + } + + guard let value = Double(currentToken.value) else { + throw ParseError.invalidNumber(currentToken.value) + } + + let valueRange = makeSourceRange(from: currentToken) + let endPos = currentToken.position + advance() + + return Evolution( + vertex: vertex, + isPositive: isPositive, + value: value, + position: startPos, + range: SourceRange( + start: startPos, end: endPos, + stringRange: keywordRange.stringRange.lowerBound..<valueRange.stringRange.upperBound), + keywordRange: keywordRange, + vertexRange: vertexRange, + signRange: signRange, + valueRange: valueRange + ) + } + + // Helper methods + private func parsePositionWithRanges() throws -> ( + (Double, Double), + ( + leftParen: SourceRange, xCoord: SourceRange, comma: SourceRange, yCoord: SourceRange, + rightParen: SourceRange + ) + ) { + guard currentToken.type == .leftParen else { + throw ParseError.expectedLeftParen(actual: currentToken) + } + let leftParenRange = makeSourceRange(from: currentToken) + advance() + + guard currentToken.type == .realNumber else { + throw ParseError.expectedNumber(actual: currentToken) + } + guard let x = Double(currentToken.value) else { + throw ParseError.invalidNumber(currentToken.value) + } + let xCoordRange = makeSourceRange(from: currentToken) + advance() + + guard currentToken.type == .comma else { + throw ParseError.expectedComma(actual: currentToken) + } + let commaRange = makeSourceRange(from: currentToken) + advance() + + guard currentToken.type == .realNumber else { + throw ParseError.expectedNumber(actual: currentToken) + } + guard let y = Double(currentToken.value) else { + throw ParseError.invalidNumber(currentToken.value) + } + let yCoordRange = makeSourceRange(from: currentToken) + advance() + + guard currentToken.type == .rightParen else { + throw ParseError.expectedRightParen(actual: currentToken) + } + let rightParenRange = makeSourceRange(from: currentToken) + advance() + + return ((x, y), (leftParenRange, xCoordRange, commaRange, yCoordRange, rightParenRange)) + } + + private func parseOptionalShapeWithRange() throws -> (String?, SourceRange?) { + guard currentToken.type == .shapeLabel else { + return (nil, nil) + } + + let shape = currentToken.value + let shapeRange = makeSourceRange(from: currentToken) + advance() + return (shape, shapeRange) + } + + private func makeSourceRange(from token: Token) -> SourceRange { + return SourceRange( + start: token.position, + end: SourcePosition( + line: token.position.line, + column: token.position.column + token.value.count, + stringIndex: token.stringRange.upperBound + ), + stringRange: token.stringRange + ) + } + + private func advance() { + currentToken = lexer.nextToken() + } + + private func skipToNextLine() { + while currentToken.type != .newline && currentToken.type != .eof { + advance() + } + if currentToken.type == .newline { + advance() + } + } + } + + enum ParseError: Error, LocalizedError { + case unexpectedToken(expected: String, actual: Token) + case expectedVertexLabel(actual: Token) + case expectedNumber(actual: Token) + case expectedText(actual: Token) + case expectedSign(actual: Token) + case expectedComma(actual: Token) + case expectedLeftParen(actual: Token) + case expectedRightParen(actual: Token) + case expectedNewline(actual: Token) + case invalidNumber(String) + case invalidLine + + var errorDescription: String? { + switch self { + case .unexpectedToken(let expected, let actual): + return + "Expected \(expected), got \(actual.type) '\(actual.value)' at line \(actual.position.line)" + case .expectedVertexLabel(let actual): + return "Expected vertex label, got \(actual.type) at line \(actual.position.line)" + case .invalidNumber(let value): + return "Invalid number format: \(value)" + // ... other cases + default: + return "Parse error" + } + } + } +} diff --git a/Map/Business/WmapParser/SourcePosition.swift b/Map/Business/WmapParser/SourcePosition.swift new file mode 100644 index 0000000..e83c07a --- /dev/null +++ b/Map/Business/WmapParser/SourcePosition.swift @@ -0,0 +1,35 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. +extension Wmap { + struct SourcePosition: Equatable { + let line: Int + let column: Int + let stringIndex: String.Index + + static func == (lhs: SourcePosition, rhs: SourcePosition) -> Bool { + return lhs.line == rhs.line && lhs.column == rhs.column && lhs.stringIndex == rhs.stringIndex + } + } + + struct SourceRange: Equatable { + let start: SourcePosition + let end: SourcePosition + let stringRange: Range<String.Index> + + static func == (lhs: SourceRange, rhs: SourceRange) -> Bool { + return lhs.start == rhs.start && lhs.end == rhs.end && lhs.stringRange == rhs.stringRange + } + } +} diff --git a/Map/Business/WmapParser/Syntax.swift b/Map/Business/WmapParser/Syntax.swift new file mode 100644 index 0000000..551ebcd --- /dev/null +++ b/Map/Business/WmapParser/Syntax.swift @@ -0,0 +1,32 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. +extension Wmap { + struct SyntaxElement { + let type: SyntaxType + let range: SourceRange + let value: String + } + + enum SyntaxType { + case keyword // [Note], [Group], etc. + case vertexLabel // vertex names + case number // coordinates, values + case symbol // ->, --, +, - + case shape // [square], [circle] + case text // note text + case punctuation // (, ), [, ], , + case stageNumber // i, ii, iii, iv + } +} diff --git a/Map/Business/WmapParser/Token.swift b/Map/Business/WmapParser/Token.swift new file mode 100644 index 0000000..86bab60 --- /dev/null +++ b/Map/Business/WmapParser/Token.swift @@ -0,0 +1,22 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. +extension Wmap { + struct Token { + let type: TokenType + let value: String + let position: SourcePosition + let stringRange: Range<String.Index> + } +} diff --git a/Map/Business/WmapParser/TokenType.swift b/Map/Business/WmapParser/TokenType.swift new file mode 100644 index 0000000..2e316bd --- /dev/null +++ b/Map/Business/WmapParser/TokenType.swift @@ -0,0 +1,36 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. +extension Wmap { + enum TokenType: CaseIterable { + case vertexLabel + case edgeUndirected // -- + case edgeDirected // -> + case leftParen, rightParen + case leftBracket, rightBracket + case comma + case plus, minus + case realNumber + case stageNumber // i, ii, iii, iv + case shapeLabel // x, square, triangle, circle + case noteKeyword // [Note] + case groupKeyword // [Group] + case inertiaKeyword // [Inertia] + case evolutionKeyword // [Evolution] + case text // free text until newline + case newline + case eof + case error // invalid token that should cause line to be ignored + } +} diff --git a/Map/Business/WmapSyntaxHighlighter/SyntaxHighlighter.swift b/Map/Business/WmapSyntaxHighlighter/SyntaxHighlighter.swift new file mode 100644 index 0000000..e0aaee7 --- /dev/null +++ b/Map/Business/WmapSyntaxHighlighter/SyntaxHighlighter.swift @@ -0,0 +1,83 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. +import AppKit + +extension Wmap { + class SyntaxHighlighter { + private var cachedSyntaxElements: [SyntaxElement] = [] + private var lineToElementsMap: [Int: [SyntaxElement]] = [:] + + func updateSyntaxElements(from parsedMap: [Entity]) { + cachedSyntaxElements = parsedMap.flatMap { $0.syntaxElements } + + // Build line-to-elements mapping for fast lookup + lineToElementsMap.removeAll() + for element in cachedSyntaxElements { + let line = element.range.start.line + lineToElementsMap[line, default: []].append(element) + } + } + + func applySyntaxHighlighting(textStorage: NSTextStorage, range: NSRange) { + // Get the line range that encompasses the edited range + let string = textStorage.string as NSString + let lineRange = string.lineRange(for: range) + + // Apply highlighting to elements that intersect with the line range + for element in cachedSyntaxElements { + // Safely convert string range to NSRange with bounds checking + guard element.range.stringRange.lowerBound <= element.range.stringRange.upperBound, + element.range.stringRange.upperBound <= textStorage.string.endIndex + else { + continue + } + + let nsRange = NSRange(element.range.stringRange, in: textStorage.string) + + // Only process elements that intersect with our update area + guard nsRange.location != NSNotFound, + nsRange.location + nsRange.length <= textStorage.length, + NSIntersectionRange(nsRange, lineRange).length > 0 + else { + continue + } + + let color = colorForSyntaxType(element.type) + textStorage.addAttribute(.foregroundColor, value: color, range: nsRange) + } + } + + private func colorForSyntaxType(_ type: SyntaxType) -> NSColor { + switch type { + case .keyword: + return NSColor.Theme.Syntax.option + case .vertexLabel: + return NSColor.Theme.Syntax.vertex + case .number: + return NSColor.Theme.Syntax.number + case .symbol: + return NSColor.Theme.Syntax.symbol + case .shape: + return NSColor.Theme.Syntax.option + case .text: + return NSColor.Theme.Syntax.text + case .punctuation: + return NSColor.Theme.Syntax.punctuation + case .stageNumber: + return NSColor.Theme.Syntax.option + } + } + } +} diff --git a/Map/Data/MapDocument.swift b/Map/Data/MapDocument.swift index 4d1f78b..95fcfde 100644 --- a/Map/Data/MapDocument.swift +++ b/Map/Data/MapDocument.swift @@ -46,6 +46,12 @@ struct MapDocument: FileDocument { static var readableContentTypes: [UTType] { [.wmap] } + var parsed: [Wmap.Entity] { + let lexer = Wmap.Lexer(self.text) + let parser = Wmap.Parser(lexer: lexer) + return parser.parse() + } + init(configuration: ReadConfiguration) throws { guard let data = configuration.file.regularFileContents, let string = String(data: data, encoding: .utf8) diff --git a/Map/Presentation/Base Components/MapTextEditor.swift b/Map/Presentation/Base Components/MapTextEditor.swift index a2070e4..c4ac561 100644 --- a/Map/Presentation/Base Components/MapTextEditor.swift +++ b/Map/Presentation/Base Components/MapTextEditor.swift @@ -19,6 +19,13 @@ class MapTextEditorController: NSViewController { @Binding var text: String + var parsedMap: [Wmap.Entity] = [] { + didSet { + syntaxHighlighter.updateSyntaxElements(from: parsedMap) + // Don't re-highlight entire document here - let the text change handler do it + } + } + var highlightRanges: [Range<String.Index>] { didSet { updateHighlights() @@ -34,25 +41,26 @@ class MapTextEditorController: NSViewController { let onChange: () -> Void - private let vertexRegex = MapParsingPatterns.vertex - private let edgeRegex = MapParsingPatterns.edge - private let inertiaRegex = MapParsingPatterns.inertia - private let opportunityRegex = MapParsingPatterns.opportunity - private let noteRegex = MapParsingPatterns.note - private let stageRegex = MapParsingPatterns.stage - private let groupRegex = MapParsingPatterns.group - + private let syntaxHighlighter = Wmap.SyntaxHighlighter() private let changeDebouncer: Debouncer = Debouncer(seconds: 1) init( - text: Binding<String>, highlightRanges: [Range<String.Index>], selectedRange: Int, + text: Binding<String>, + parsedMap: [Wmap.Entity] = [], + highlightRanges: [Range<String.Index>], + selectedRange: Int, onChange: @escaping () -> Void ) { self._text = text + self.parsedMap = parsedMap self.onChange = onChange self.highlightRanges = highlightRanges self.selectedRange = selectedRange super.init(nibName: nil, bundle: nil) + + Task { + syntaxHighlighter.updateSyntaxElements(from: parsedMap) + } } required init?(coder: NSCoder) { @@ -73,6 +81,13 @@ class MapTextEditorController: NSViewController { textView.isEditable = true textView.font = NSFont.Theme.Editor.regular textView.textContainerInset = NSSize(width: 0, height: Dimensions.Spacing.coziest) + + // Disable word wrapping to prevent flashing on soft-wrapped lines + textView.textContainer?.widthTracksTextView = false + textView.textContainer?.containerSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.isHorizontallyResizable = true + textView.isVerticallyResizable = true self.view = scrollView } @@ -154,123 +169,63 @@ extension MapTextEditorController: NSTextStorageDelegate { override func textStorageDidProcessEditing(_ obj: Notification) { if let textStorage = obj.object as? NSTextStorage { - // Only colorize the edited range instead of the entire document + // Only process if this is a text content change, not an attribute change + guard textStorage.editedMask.contains(.editedCharacters) else { + return + } + let editedRange = textStorage.editedRange if editedRange.location != NSNotFound { - self.colorizeEditedText(textStorage: textStorage, editedRange: editedRange) + // Use a small delay to batch rapid changes and avoid flashing + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + guard let self = self, + let currentTextView = self.currentTextView, + let currentTextStorage = currentTextView.textStorage + else { return } + + self.colorizeEditedText(textStorage: currentTextStorage, editedRange: editedRange) + } } } } - nonisolated private func colorizeEditedText(textStorage: NSTextStorage, editedRange: NSRange) { + private func colorizeEditedText(textStorage: NSTextStorage, editedRange: NSRange) { // Expand range to include the entire line(s) that were edited let string = textStorage.string as NSString let lineRange = string.lineRange(for: editedRange) - // Clear existing attributes in the range + // Clear existing syntax highlighting attributes in the range, but preserve background colors textStorage.removeAttribute(.foregroundColor, range: lineRange) - // Apply syntax highlighting only to the affected lines - self.applySyntaxHighlighting(textStorage: textStorage, range: lineRange) + // Apply syntax highlighting + syntaxHighlighter.applySyntaxHighlighting(textStorage: textStorage, range: lineRange) } - nonisolated private func colorizeText(textStorage: NSTextStorage) { + private func colorizeText(textStorage: NSTextStorage) { let range = NSMakeRange(0, textStorage.length) - self.applySyntaxHighlighting(textStorage: textStorage, range: range) - } - - nonisolated private func applySyntaxHighlighting(textStorage: NSTextStorage, range: NSRange) { - var matches = vertexRegex.matches(in: textStorage.string, options: [], range: range) - - for match in matches { - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.vertex], range: match.range(at: 1)) - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.number], range: match.range(at: 2)) - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.number], range: match.range(at: 3)) - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.option], range: match.range(at: 4)) - } - - matches = edgeRegex.matches(in: textStorage.string, options: [], range: range) - - for match in matches { - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.vertex], range: match.range(at: 1)) - let arrowRange = match.range(at: 2) - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.symbol], - range: NSMakeRange(arrowRange.lowerBound - 1, arrowRange.length + 1)) - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.vertex], range: match.range(at: 3)) - } - matches = opportunityRegex.matches(in: textStorage.string, options: [], range: range) + // Clear all existing attributes + textStorage.removeAttribute(.foregroundColor, range: range) - for match in matches { - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.option], range: match.range(at: 1)) - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.vertex], range: match.range(at: 2)) - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.symbol], range: match.range(at: 3)) - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.number], range: match.range(at: 4)) - } - - matches = inertiaRegex.matches(in: textStorage.string, options: [], range: range) - - for match in matches { - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.option], range: match.range(at: 1)) - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.vertex], range: match.range(at: 2)) - } - - matches = noteRegex.matches(in: textStorage.string, options: [], range: range) - - for match in matches { - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.option], range: match.range(at: 1)) - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.number], range: match.range(at: 2)) - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.number], range: match.range(at: 3)) - } - - matches = stageRegex.matches(in: textStorage.string, options: [], range: range) - - for match in matches { - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.option], range: match.range(at: 1)) - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.number], range: match.range(at: 2)) - } - - matches = groupRegex.matches(in: textStorage.string, options: [], range: range) - - for match in matches { - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.option], range: match.range(at: 1)) - textStorage.addAttributes( - [.foregroundColor: NSColor.Theme.Syntax.vertex], range: match.range(at: 2)) - } + syntaxHighlighter.applySyntaxHighlighting(textStorage: textStorage, range: range) } } struct MapTextEditor: NSViewControllerRepresentable { @Binding var text: String + var parsedMap: [Wmap.Entity] = [] var highlightRanges: [Range<String.Index>] var selectedRange: Int var onChange: () -> Void = {} init( - text: Binding<String>, highlightRanges: [Range<String.Index>] = [], selectedRange: Int = 0, + text: Binding<String>, parsedMap: [Wmap.Entity] = [], + highlightRanges: [Range<String.Index>] = [], selectedRange: Int = 0, onChange: @escaping () -> Void = {} ) { self._text = text + self.parsedMap = parsedMap self.highlightRanges = highlightRanges self.selectedRange = selectedRange self.onChange = onChange @@ -280,7 +235,8 @@ struct MapTextEditor: NSViewControllerRepresentable { context: NSViewControllerRepresentableContext<MapTextEditor> ) -> MapTextEditorController { return MapTextEditorController( - text: $text, highlightRanges: highlightRanges, selectedRange: selectedRange, + text: $text, parsedMap: parsedMap, highlightRanges: highlightRanges, + selectedRange: selectedRange, onChange: onChange) } @@ -288,16 +244,33 @@ struct MapTextEditor: NSViewControllerRepresentable { _ nsViewController: MapTextEditorController, context: NSViewControllerRepresentableContext<MapTextEditor> ) { - nsViewController.highlightRanges = highlightRanges + // Only update if something actually changed to prevent excessive updates + var hasChanges = false + + // Always update parsed map since the content may have changed even if count is same + nsViewController.parsedMap = parsedMap + + if nsViewController.highlightRanges != highlightRanges { + nsViewController.highlightRanges = highlightRanges + hasChanges = true + } + if nsViewController.selectedRange != selectedRange { nsViewController.selectedRange = selectedRange + hasChanges = true } // Update text view content if text has changed if let textView = nsViewController.currentTextView { if textView.string != text { textView.string = text + hasChanges = true } } + + // Only log if we made changes (for debugging) + if hasChanges { + print("MapTextEditor: Updated view controller") + } } } diff --git a/Map/Presentation/MapEditor.swift b/Map/Presentation/MapEditor.swift index 11545e2..167ef77 100644 --- a/Map/Presentation/MapEditor.swift +++ b/Map/Presentation/MapEditor.swift @@ -19,6 +19,9 @@ struct MapEditor: View { var url: URL? @State var selectedEvolution: StageType = .behavior @State var isSearching: Bool = false + var parsedMap: [Wmap.Entity] { + document.parsed + } private let changeDebouncer: Debouncer = Debouncer(seconds: 0.05) @@ -103,6 +106,7 @@ struct MapEditor: View { get: { document.text }, set: { document.text = $0 } ), + parsedMap: parsedMap, highlightRanges: results, selectedRange: selectedTerm ) diff --git a/Map/Presentation/Preferences/EditorPreferencesView.swift b/Map/Presentation/Preferences/EditorPreferencesView.swift index 91b332f..23359b2 100644 --- a/Map/Presentation/Preferences/EditorPreferencesView.swift +++ b/Map/Presentation/Preferences/EditorPreferencesView.swift @@ -98,25 +98,19 @@ struct EditorPreferencesView: View { .frame(maxWidth: .infinity, alignment: .leading) } - /* - - Disabled because we haven't written the smart editor yet. - Call it aspirational preferences. - Divider() - + HStack(alignment: .firstTextBaseline, spacing: Dimensions.Spacing.regular) { Text("preferences.editor.smart_editor.title") .font(.Theme.Body.emphasized) .frame(maxWidth: 250, alignment: .trailing) - + VStack(alignment: .leading, spacing: Dimensions.Spacing.cozy) { Toggle( String(localized: "preferences.editor.smart_editor.enabled"), isOn: $useSmartEditor) } .frame(maxWidth: .infinity, alignment: .leading) } - */ Spacer() }.padding(.vertical, Dimensions.Spacing.loose) diff --git a/Map/Presentation/Theme/NSColor+theme.swift b/Map/Presentation/Theme/NSColor+theme.swift index 874764d..e10f51e 100644 --- a/Map/Presentation/Theme/NSColor+theme.swift +++ b/Map/Presentation/Theme/NSColor+theme.swift @@ -21,6 +21,8 @@ extension NSColor { static let number = NSColor(named: "Number") ?? .textColor static let option = NSColor(named: "Option") ?? .textColor static let symbol = NSColor(named: "Symbol") ?? .textColor + static let text = NSColor(named: "Foreground") ?? .textColor + static let punctuation = NSColor(named: "Foreground") ?? .textColor static let match = (NSColor(named: "Light Neutral Gray") ?? .textColor).withAlphaComponent( 0.3) static let highlightMatch = (NSColor(named: "Naples Yellow") ?? .textColor) |