]> git.r.bdr.sh - rbdr/map/blob - Map/Presentation/MapEditor.swift
d7c93dda3ce597fce10ec123b32a3befd71d03f5
[rbdr/map] / Map / Presentation / MapEditor.swift
1 /*
2 Copyright (C) 2024 Rubén Beltrán del Río
3
4 This program is free software: you can redistribute it and/or modify
5 it under the terms of the GNU General Public License as published by
6 the Free Software Foundation, either version 3 of the License, or
7 (at your option) any later version.
8
9 This program is distributed in the hope that it will be useful,
10 but WITHOUT ANY WARRANTY; without even the implied warranty of
11 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 GNU General Public License for more details.
13
14 You should have received a copy of the GNU General Public License
15 along with this program. If not, see https://map.tranquil.systems.
16 */
17 import SwiftUI
18
19 struct MapEditor: View {
20 @Binding var document: MapDocument
21 var url: URL?
22 @State var selectedEvolution: StageType = .behavior
23 @State var isSearching: Bool = false
24
25 @AppStorage("viewStyle") var viewStyle: ViewStyle = .horizontal
26
27 let zoomRange = Constants.kMinZoom...Constants.kMaxZoom
28 @AppStorage("zoom") var zoom = 1.0
29 @State var lastZoom = 1.0
30 @State var searchTerm = ""
31 @State var selectedTerm = 0
32
33 var results: [Range<String.Index>] {
34 if !isSearching || searchTerm.isEmpty {
35 return []
36 }
37 let options: NSString.CompareOptions = [.caseInsensitive, .diacriticInsensitive]
38 var searchRange = document.text.startIndex..<document.text.endIndex
39 var ranges: [Range<String.Index>] = []
40
41 while let range = document.text.range(of: searchTerm, options: options, range: searchRange) {
42 ranges.append(range)
43 searchRange = range.upperBound..<document.text.endIndex
44 }
45
46 return ranges
47
48 }
49
50 var body: some View {
51 VStack(spacing: 0) {
52 if isSearching {
53 SearchBar(
54 term: $searchTerm,
55 onNext: {
56 withAnimation {
57 if results.count > 0 {
58 selectedTerm = (selectedTerm + 1) % results.count
59 }
60 }
61 },
62 onPrevious: {
63 withAnimation {
64 if results.count > 0 {
65 if selectedTerm == 0 {
66 selectedTerm = results.count - 1
67 } else {
68 selectedTerm = (selectedTerm - 1) % results.count
69 }
70 }
71 }
72 },
73 onSubmit: {
74
75 },
76 onDismiss: {
77 withAnimation {
78 isSearching = false
79 }
80 }
81 )
82 .onChange(
83 of: searchTerm,
84 {
85 selectedTerm = 0
86 })
87 Divider()
88 }
89 adaptiveStack {
90 ZStack(alignment: .topLeading) {
91 MapTextEditor(document: $document, highlightRanges: results, selectedRange: selectedTerm)
92 .background(Color.ui.background)
93 .foregroundColor(Color.ui.foreground)
94 .frame(minHeight: 96.0)
95 }.padding(.top, 8.0).padding(.leading, 8.0).background(Color.ui.background).cornerRadius(
96 5.0)
97 GeometryReader { geometry in
98 ScrollView([.horizontal, .vertical]) {
99 MapRenderView(
100 document: $document, evolution: $selectedEvolution, onDragVertex: onDragVertex
101 ).scaleEffect(zoom, anchor: .center).frame(
102 width: (Dimensions.mapSize.width + 2 * Dimensions.mapPadding) * zoom,
103 height: (Dimensions.mapSize.height + 2 * Dimensions.mapPadding) * zoom)
104 }.background(Color.ui.background)
105 .gesture(
106 MagnificationGesture()
107 .onChanged { value in
108 let delta = value / lastZoom
109 lastZoom = value
110 zoom = min(max(zoom * delta, zoomRange.lowerBound), zoomRange.upperBound)
111 }
112 .onEnded { _ in
113 lastZoom = 1.0
114 }
115 )
116 }
117 }
118 Divider()
119 HStack {
120 Spacer()
121 Slider(
122 value: $zoom, in: zoomRange, step: 0.1,
123 label: {
124 Text(formatZoom(zoom))
125 .font(.theme.smallControl)
126 },
127 minimumValueLabel: {
128 Image(systemName: "minus.magnifyingglass")
129 .font(.theme.smallControl)
130 .help("Zoom Out (⌘-)")
131 },
132 maximumValueLabel: {
133 Image(systemName: "plus.magnifyingglass")
134 .font(.theme.smallControl)
135 .help("Zoom In (⌘+)")
136 }
137 ).frame(width: 200).padding(.trailing, 10.0)
138 }.padding(4.0)
139 }.toolbar {
140 HStack {
141 EvolutionPicker(selectedEvolution: $selectedEvolution)
142 }
143 }.focusedSceneValue(\.isSearching, $isSearching)
144 .focusedSceneValue(\.selectedEvolution, $selectedEvolution)
145 }
146
147 @ViewBuilder
148 func adaptiveStack<Content: View>(@ViewBuilder content: () -> Content) -> some View {
149 if viewStyle == .horizontal {
150 VSplitView {
151 content()
152 }
153 } else {
154 HSplitView {
155 content()
156 }
157 }
158 }
159
160 private func formatZoom(_ number: CGFloat) -> String {
161 let formatter = NumberFormatter()
162 formatter.numberStyle = .decimal
163 formatter.maximumFractionDigits = 1
164 formatter.minimumFractionDigits = 1
165 return (formatter.string(from: NSNumber(value: number)) ?? "") + "x"
166 }
167
168 private func onDragVertex(vertex: Vertex, x: CGFloat, y: CGFloat) {
169 }
170 }
171
172 #Preview {
173 MapEditor(document: .constant(MapDocument()), url: URL(filePath: "test.png")!)
174 }