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