]> git.r.bdr.sh - rbdr/map/blame - Map/Presentation/MapEditor.swift
Add some debouncing
[rbdr/map] / Map / Presentation / MapEditor.swift
CommitLineData
be897af3 1// Copyright (C) 2024 Rubén Beltrán del Río
98f09799 2
be897af3
RBR
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.
98f09799 7
be897af3
RBR
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.
98f09799 12
be897af3
RBR
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.
e2c37ac1
RBR
15import SwiftUI
16
17struct MapEditor: View {
18 @Binding var document: MapDocument
19 var url: URL?
20 @State var selectedEvolution: StageType = .behavior
14491563 21 @State var isSearching: Bool = false
e2c37ac1 22
ed10ac19
RBR
23 private let changeDebouncer: Debouncer = Debouncer(seconds: 0.05)
24
e2c37ac1
RBR
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
14491563
RBR
30 @State var searchTerm = ""
31 @State var selectedTerm = 0
32
ed10ac19
RBR
33 @State var results: [Range<String.Index>] = []
34
35 private func updateRanges() {
14491563 36 if !isSearching || searchTerm.isEmpty {
ed10ac19 37 results = []
14491563
RBR
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
ed10ac19 48 results = ranges
14491563
RBR
49
50 }
e2c37ac1
RBR
51
52 var body: some View {
53 VStack(spacing: 0) {
14491563
RBR
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: {
ed10ac19 79 isSearching = false
14491563
RBR
80 }
81 )
82 .onChange(
83 of: searchTerm,
84 {
ed10ac19
RBR
85 changeDebouncer.debounce {
86 updateRanges()
87 selectedTerm = 0
88 }
14491563
RBR
89 })
90 Divider()
91 }
e2c37ac1
RBR
92 adaptiveStack {
93 ZStack(alignment: .topLeading) {
14491563 94 MapTextEditor(document: $document, highlightRanges: results, selectedRange: selectedTerm)
be897af3
RBR
95 .background(Color.UI.background)
96 .foregroundColor(Color.UI.foreground)
e2c37ac1 97 .frame(minHeight: 96.0)
be897af3 98 }.padding(.top, 8.0).padding(.leading, 8.0).background(Color.UI.background).cornerRadius(
e2c37ac1
RBR
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)
be897af3 107 }.background(Color.UI.background)
e2c37ac1
RBR
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))
be897af3 128 .font(.Theme.smallControl)
e2c37ac1
RBR
129 },
130 minimumValueLabel: {
131 Image(systemName: "minus.magnifyingglass")
be897af3 132 .font(.Theme.smallControl)
e2c37ac1
RBR
133 .help("Zoom Out (⌘-)")
134 },
135 maximumValueLabel: {
136 Image(systemName: "plus.magnifyingglass")
be897af3 137 .font(.Theme.smallControl)
e2c37ac1
RBR
138 .help("Zoom In (⌘+)")
139 }
140 ).frame(width: 200).padding(.trailing, 10.0)
141 }.padding(4.0)
142 }.toolbar {
143 HStack {
14491563 144 EvolutionPicker(selectedEvolution: $selectedEvolution)
e2c37ac1 145 }
14491563
RBR
146 }.focusedSceneValue(\.isSearching, $isSearching)
147 .focusedSceneValue(\.selectedEvolution, $selectedEvolution)
e2c37ac1
RBR
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) {
e2c37ac1 172 }
e2c37ac1
RBR
173}
174
175#Preview {
176 MapEditor(document: .constant(MapDocument()), url: URL(filePath: "test.png")!)
177}