// 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 SharedWithYou import SwiftUI import WmapParser struct MapEditor: View { @Binding var document: MapDocument @FocusedValue(\.fileURL) var fileURL: URL? @State var selectedEvolution: StageType = .behavior @State var isSearching: Bool = false @State var isPresentingSharingSheet: Bool = false var parsedMap: WmapParser.Map { document.parsed } private let changeDebouncer: Debouncer = Debouncer(seconds: 0.05) @AppStorage("viewStyle") var viewStyle: ViewStyle = ViewStyle(rawValue: UserPreferences.Defaults.viewStyle) ?? .horizontal @AppStorage("useCustomEditorFont") var useCustomEditorFont: Bool = UserPreferences.Defaults .useCustomEditorFont @AppStorage("customEditorFontName") var customEditorFontName: String = UserPreferences.Defaults .customEditorFontName @AppStorage("editorFontSize") var editorFontSize: Double = UserPreferences.Defaults.editorFontSize @AppStorage("softWrapLines") var softWrapLines: Bool = UserPreferences.Defaults .softWrapLines @AppStorage("useSmartEditor") var useSmartEditor: Bool = UserPreferences.Defaults .useSmartEditor @AppStorage("highlightMissingComponents") var highlightMissingComponents: Bool = UserPreferences .Defaults .highlightMissingComponents let zoomRange = Constants.kMinZoom...Constants.kMaxZoom @AppStorage("zoom") var zoom = UserPreferences.Defaults.zoom @State var lastZoom = 1.0 @State var searchTerm = "" @State var selectedTerm = 0 @State var results: [Range] = [] @State private var vertexLineMap: [String: (lineIndex: Int, shapeText: String)] = [:] private func updateRanges() { if !isSearching || searchTerm.isEmpty { results = [] } let options: NSString.CompareOptions = [.caseInsensitive, .diacriticInsensitive] var searchRange = document.text.startIndex..] = [] while let range = document.text.range(of: searchTerm, options: options, range: searchRange) { ranges.append(range) searchRange = range.upperBound.. 0 { selectedTerm = (selectedTerm + 1) % results.count } } }, onPrevious: { withAnimation { if results.count > 0 { if selectedTerm == 0 { selectedTerm = results.count - 1 } else { selectedTerm = (selectedTerm - 1) % results.count } } } }, onDismiss: { isSearching = false } ) .onChange( of: searchTerm, { changeDebouncer.debounce { updateRanges() selectedTerm = 0 } }) Divider() } adaptiveStack { ZStack(alignment: .topLeading) { MapTextEditor( text: Binding( get: { document.text }, set: { document.text = $0 } ), parsedMap: parsedMap, highlightRanges: results, selectedRange: selectedTerm ) .id( "\(useCustomEditorFont)-\(customEditorFontName)-\(editorFontSize)-\(softWrapLines)-\(useSmartEditor)-\(highlightMissingComponents)" ) .background(Color.Theme.UI.background) .foregroundColor(Color.Theme.UI.foreground) .frame(minHeight: 96.0) } .background(Color.Theme.UI.background) .cornerRadius(5.0) GeometryReader { _ in ScrollView([.horizontal, .vertical], showsIndicators: false) { MapRenderView( parsedMap: parsedMap, evolution: $selectedEvolution, onDragVertex: onDragVertex ) .scaleEffect(zoom, anchor: .center).frame( width: (Dimensions.Map.size.width + 2 * Dimensions.Map.padding) * zoom, height: (Dimensions.Map.size.height + 2 * Dimensions.Map.padding) * zoom ) .onAppear { buildVertexLineMap() } .onChange(of: document.text) { _, _ in buildVertexLineMap() } .padding(.bottom, Dimensions.MapEditor.additionalPadding) } .background(Color.Theme.UI.background) .gesture( MagnificationGesture() .onChanged { value in let delta = value / lastZoom lastZoom = value zoom = min(max(zoom * delta, zoomRange.lowerBound), zoomRange.upperBound) } .onEnded { _ in lastZoom = 1.0 } ) } } if #unavailable(macOS 26) { Divider() HStack { Spacer() Slider( value: $zoom, in: zoomRange, step: 0.1, label: { Text(formatZoom(zoom)) .font(.Theme.SmallControl.regular) }, minimumValueLabel: { Image(systemName: "minus.magnifyingglass") .font(.Theme.SmallControl.regular) .help("map_editor.zoom_out") }, maximumValueLabel: { Image(systemName: "plus.magnifyingglass") .font(.Theme.SmallControl.regular) .help("map_editor.zoom_in") } ).frame(width: 200).padding(.trailing, 10.0) }.padding(4.0) } } .safeAreaInset(edge: .bottom, alignment: .trailing) { if #available(macOS 26, *) { HStack { Slider( value: $zoom, in: zoomRange, step: 0.1, label: { Text(formatZoom(zoom)) .font(.Theme.SmallControl.regular) }, minimumValueLabel: { Image(systemName: "minus.magnifyingglass") .font(.Theme.SmallControl.regular) .help("map_editor.zoom_out") }, maximumValueLabel: { Image(systemName: "plus.magnifyingglass") .font(.Theme.SmallControl.regular) .help("map_editor.zoom_in") } ).frame(width: 200) .padding(Dimensions.Spacing.regular) .glassEffect(.regular) GlassEvolutionPicker(selectedEvolution: $selectedEvolution) } .padding(Dimensions.Spacing.regular) } } .toolbar { if let fileURL { if fileIsShared { ToolbarItem(placement: .primaryAction) { CollaborationButton(fileURL: fileURL) } } ToolbarItem(placement: .primaryAction) { ShareLink(item: fileURL) } } if #unavailable(macOS 26) { ToolbarItem(placement: .primaryAction) { EvolutionPicker(selectedEvolution: $selectedEvolution) } } } .focusedSceneValue(\.isSearching, $isSearching) .focusedSceneValue(\.selectedEvolution, $selectedEvolution) } private var fileIsShared: Bool { do { if let fileURL { let resourceValues = try fileURL.resourceValues(forKeys: [.ubiquitousItemIsSharedKey]) return resourceValues.ubiquitousItemIsShared ?? false } return false } catch { return false } } @ViewBuilder func adaptiveStack(@ViewBuilder content: () -> Content) -> some View { if viewStyle == .horizontal { VSplitView { content() } } else { HSplitView { content() } } } private func formatZoom(_ number: CGFloat) -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 1 formatter.minimumFractionDigits = 1 return (formatter.string(from: NSNumber(value: number)) ?? "") + "x" } private func buildVertexLineMap() { let lines = document.text.components(separatedBy: .newlines) let vertexPattern = "^([^\\(\\[\\]]*?)[\\s]*\\([\\s]*([0-9]+.?[0-9]*)[\\s]*,[\\s]*([0-9]+.?[0-9]*)[\\s]*\\)[\\s]*(?:\\[(.*?)\\])?[\\s]*$" vertexLineMap.removeAll() for (index, line) in lines.enumerated() { if let regex = try? NSRegularExpression(pattern: vertexPattern, options: [.caseInsensitive]), let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { let nameRange = Range(match.range(at: 1), in: line)! let name = String(line[nameRange]).trimmingCharacters(in: .whitespaces) let shapeRange = match.range(at: 4) let shapeText = shapeRange.location != NSNotFound ? " [\(String(line[Range(shapeRange, in: line)!]))]" : "" vertexLineMap[name] = (lineIndex: index, shapeText: shapeText) } } } private func onDragVertex(vertex: Vertex, x: CGFloat, y: CGFloat) { guard let lineInfo = vertexLineMap[vertex.label] else { return } let lines = document.text.components(separatedBy: .newlines) var updatedLines = lines // Directly update the line without regex updatedLines[lineInfo.lineIndex] = "\(vertex.label) (\(String(format: "%.2f", x)), \(String(format: "%.2f", y)))\(lineInfo.shapeText)" // Update document text document.text = updatedLines.joined(separator: "\n") } } #Preview { MapEditor(document: .constant(MapDocument(text: nil))) }