aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Documentation/wmap-spec.ebnf47
-rw-r--r--Map/Business/WmapParser/Entity.swift389
-rw-r--r--Map/Business/WmapParser/Lexer.swift344
-rw-r--r--Map/Business/WmapParser/Parser.swift488
-rw-r--r--Map/Business/WmapParser/SourcePosition.swift35
-rw-r--r--Map/Business/WmapParser/Syntax.swift32
-rw-r--r--Map/Business/WmapParser/Token.swift22
-rw-r--r--Map/Business/WmapParser/TokenType.swift36
-rw-r--r--Map/Business/WmapSyntaxHighlighter/SyntaxHighlighter.swift83
-rw-r--r--Map/Data/MapDocument.swift6
-rw-r--r--Map/Presentation/Base Components/MapTextEditor.swift167
-rw-r--r--Map/Presentation/MapEditor.swift4
-rw-r--r--Map/Presentation/Preferences/EditorPreferencesView.swift10
-rw-r--r--Map/Presentation/Theme/NSColor+theme.swift2
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)