diff --git a/ControlRoom.xcodeproj/project.pbxproj b/ControlRoom.xcodeproj/project.pbxproj index 1210f62..fafc09c 100644 --- a/ControlRoom.xcodeproj/project.pbxproj +++ b/ControlRoom.xcodeproj/project.pbxproj @@ -48,7 +48,7 @@ 551F8CED23F489A50006D1BD /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CEC23F489A50006D1BD /* SidebarView.swift */; }; 551F8CEF23F48B030006D1BD /* SplitLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CEE23F48B030006D1BD /* SplitLayoutView.swift */; }; 551F8CF123F498C30006D1BD /* SimulatorsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CF023F498C30006D1BD /* SimulatorsController.swift */; }; - 551F8CF523F4AF7B0006D1BD /* FilterField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CF423F4AF7B0006D1BD /* FilterField.swift */; }; + 551F8CF523F4AF7B0006D1BD /* SearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CF423F4AF7B0006D1BD /* SearchField.swift */; }; 551F8CF723F4BEAC0006D1BD /* TypeIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CF623F4BEAC0006D1BD /* TypeIdentifier.swift */; }; 551F8CF923F4C45A0006D1BD /* SimulatorSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CF823F4C45A0006D1BD /* SimulatorSidebarView.swift */; }; 551F8CFE23F5C9EF0006D1BD /* SimCtl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551F8CFD23F5C9EF0006D1BD /* SimCtl.swift */; }; @@ -77,6 +77,9 @@ ACDF076823F7E91A00597B3B /* ApplicationType.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACDF076723F7E91A00597B3B /* ApplicationType.swift */; }; B07F584923F99A1700256D5D /* SimCtl+SubCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = B07F584823F99A1700256D5D /* SimCtl+SubCommands.swift */; }; B07F585123F9F83800256D5D /* SimCtl+SubCommandsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B07F585023F9F83800256D5D /* SimCtl+SubCommandsTests.swift */; }; + C9C203E42B1788270081E1EF /* LocalSearchController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C203E32B1788270081E1EF /* LocalSearchController.swift */; }; + C9C203E62B1788C90081E1EF /* LocalSearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C203E52B1788C90081E1EF /* LocalSearchResult.swift */; }; + C9C203ED2B17FEA80081E1EF /* LocalSearchRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C203EC2B17FEA80081E1EF /* LocalSearchRowView.swift */; }; E04B47322B07B66F00DAE338 /* LocationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E04B47312B07B66F00DAE338 /* LocationsController.swift */; }; E04B47342B07B72C00DAE338 /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = E04B47332B07B72C00DAE338 /* Location.swift */; }; /* End PBXBuildFile section */ @@ -135,7 +138,7 @@ 551F8CEC23F489A50006D1BD /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; 551F8CEE23F48B030006D1BD /* SplitLayoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitLayoutView.swift; sourceTree = ""; }; 551F8CF023F498C30006D1BD /* SimulatorsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorsController.swift; sourceTree = ""; }; - 551F8CF423F4AF7B0006D1BD /* FilterField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterField.swift; sourceTree = ""; }; + 551F8CF423F4AF7B0006D1BD /* SearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchField.swift; sourceTree = ""; }; 551F8CF623F4BEAC0006D1BD /* TypeIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypeIdentifier.swift; sourceTree = ""; }; 551F8CF823F4C45A0006D1BD /* SimulatorSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorSidebarView.swift; sourceTree = ""; }; 551F8CFD23F5C9EF0006D1BD /* SimCtl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimCtl.swift; sourceTree = ""; }; @@ -167,6 +170,9 @@ B07F584E23F9F83800256D5D /* ControlRoomTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ControlRoomTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B07F585023F9F83800256D5D /* SimCtl+SubCommandsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SimCtl+SubCommandsTests.swift"; sourceTree = ""; }; B07F585223F9F83800256D5D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C9C203E32B1788270081E1EF /* LocalSearchController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSearchController.swift; sourceTree = ""; }; + C9C203E52B1788C90081E1EF /* LocalSearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSearchResult.swift; sourceTree = ""; }; + C9C203EC2B17FEA80081E1EF /* LocalSearchRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSearchRowView.swift; sourceTree = ""; }; E04B47312B07B66F00DAE338 /* LocationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsController.swift; sourceTree = ""; }; E04B47332B07B72C00DAE338 /* Location.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Location.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -238,12 +244,12 @@ 511BA5AB23F434B500E3E660 /* ControlScreens */ = { isa = PBXGroup; children = ( - 51AB56E12A13D3D1002B5A67 /* SystemView */, 51B0241B25C3103B0042394E /* AppView */, - 70BE435923F54B7200FD6282 /* LocationView.swift */, + 51AB56E82A141055002B5A67 /* ColorsView.swift */, + C9C203EB2B17FE900081E1EF /* LocationVIew */, 519732492A0863B400B1F68C /* OverridesView.swift */, 511BA59F23F4197200E3E660 /* StatusBarView.swift */, - 51AB56E82A141055002B5A67 /* ColorsView.swift */, + 51AB56E12A13D3D1002B5A67 /* SystemView */, ); path = ControlScreens; sourceTree = ""; @@ -251,7 +257,7 @@ 511BA5CF23F455D500E3E660 /* NSViewWrappers */ = { isa = PBXGroup; children = ( - 551F8CF423F4AF7B0006D1BD /* FilterField.swift */, + 551F8CF423F4AF7B0006D1BD /* SearchField.swift */, ); path = NSViewWrappers; sourceTree = ""; @@ -281,6 +287,7 @@ 51AB56EC2A143B90002B5A67 /* Double-Rounding.swift */, 41C44ACC2616FCB50016B1E4 /* FFMPEGConverter.swift */, 5534158523FE1AC4005C0A41 /* Flow.swift */, + C9C203E52B1788C90081E1EF /* LocalSearchResult.swift */, E04B47332B07B72C00DAE338 /* Location.swift */, 51AB56E62A140D53002B5A67 /* NSColor-Conversions.swift */, 51AB56EE2A143BC0002B5A67 /* PickedColor.swift */, @@ -385,13 +392,14 @@ 555A146323F762AD00313BC5 /* Controllers */ = { isa = PBXGroup; children = ( - 51AB56DA2A129637002B5A67 /* ChromeRendering */, ACDF076523F7D1C300597B3B /* Application.swift */, ACDF076723F7E91A00597B3B /* ApplicationType.swift */, 51DCEFB32A0BC6B600561C9B /* CaptureController.swift */, + 51AB56DA2A129637002B5A67 /* ChromeRendering */, 51AB56EA2A141C57002B5A67 /* ColorHistoryController.swift */, 51AB56DF2A13D189002B5A67 /* DeepLinksController.swift */, 5179289925C37D2A000F6F3A /* KeyboardShortcuts.swift */, + C9C203E32B1788270081E1EF /* LocalSearchController.swift */, E04B47312B07B66F00DAE338 /* LocationsController.swift */, 5523A7E023F99D7200F25EEC /* Preferences.swift */, ACCD798D240AE82C0004ECE5 /* PushNotification.swift */, @@ -441,6 +449,15 @@ path = Controllers; sourceTree = ""; }; + C9C203EB2B17FE900081E1EF /* LocationVIew */ = { + isa = PBXGroup; + children = ( + 70BE435923F54B7200FD6282 /* LocationView.swift */, + C9C203EC2B17FEA80081E1EF /* LocalSearchRowView.swift */, + ); + path = LocationVIew; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -598,6 +615,7 @@ 5534158623FE1AC4005C0A41 /* Flow.swift in Sources */, 51B0241D25C3104C0042394E /* AppIcon.swift in Sources */, 511BA5A823F42EE400E3E660 /* AppView.swift in Sources */, + C9C203E42B1788270081E1EF /* LocalSearchController.swift in Sources */, 5523A7E123F99D7200F25EEC /* Preferences.swift in Sources */, 51AB56E02A13D189002B5A67 /* DeepLinksController.swift in Sources */, 551F8CED23F489A50006D1BD /* SidebarView.swift in Sources */, @@ -607,6 +625,7 @@ 54C2092B27DBDE2200E47016 /* DocumentPickerConfig.swift in Sources */, ACCD798E240AE82C0004ECE5 /* PushNotification.swift in Sources */, 517928C225C47392000F6F3A /* AVAssetToGIF.swift in Sources */, + C9C203E62B1788C90081E1EF /* LocalSearchResult.swift in Sources */, 55AF68B723F9D2E200C5D87A /* UIState.swift in Sources */, 511BA59223F4031F00E3E660 /* ControlView.swift in Sources */, 5534158223FE0539005C0A41 /* Contributors.swift in Sources */, @@ -634,6 +653,7 @@ 51AB56DE2A13D14A002B5A67 /* DeepLink.swift in Sources */, 51AB56E52A13D3F6002B5A67 /* DeepLinkEditorView.swift in Sources */, 51AB56DC2A129647002B5A67 /* ChromeRendererTypes.swift in Sources */, + C9C203ED2B17FEA80081E1EF /* LocalSearchRowView.swift in Sources */, 551F8CF723F4BEAC0006D1BD /* TypeIdentifier.swift in Sources */, 551F8CF123F498C30006D1BD /* SimulatorsController.swift in Sources */, ACCD798C240ADEE20004ECE5 /* NotificationEditorView.swift in Sources */, @@ -656,7 +676,7 @@ 55AF68B523F9CFD600C5D87A /* SettingsView.swift in Sources */, 51AB56EF2A143BC0002B5A67 /* PickedColor.swift in Sources */, B07F584923F99A1700256D5D /* SimCtl+SubCommands.swift in Sources */, - 551F8CF523F4AF7B0006D1BD /* FilterField.swift in Sources */, + 551F8CF523F4AF7B0006D1BD /* SearchField.swift in Sources */, AC472CCF240D46D7007FF521 /* KeyedEncodingContainer-NotEmpty.swift in Sources */, 51AB56E72A140D53002B5A67 /* NSColor-Conversions.swift in Sources */, 5FA79DA92A5752A9006F5477 /* XcodeHelper.swift in Sources */, diff --git a/ControlRoom/Controllers/LocalSearchController.swift b/ControlRoom/Controllers/LocalSearchController.swift new file mode 100644 index 0000000..d00dac5 --- /dev/null +++ b/ControlRoom/Controllers/LocalSearchController.swift @@ -0,0 +1,130 @@ +// +// LocalSearchController.swift +// ControlRoom +// +// Created by John McEvoy on 29/11/2023. +// Copyright © 2023 Paul Hudson. All rights reserved. +// + +import Foundation +import MapKit + +@MainActor +class LocalSearchController: NSObject, ObservableObject { + /// Prevents duplicate queries from being made + private var lastQuery: String = "" + + /// Completion handler is called by the `MKLocalSearchCompleter` success callback + private var callback: (([LocalSearchResult]) -> Void)? + + /// the MKLocalSearchCompleter used to make local search requests + private lazy var localSearchCompleter: MKLocalSearchCompleter = { + let completer = MKLocalSearchCompleter() + completer.resultTypes = [.address, .pointOfInterest] + completer.delegate = self + return completer + }() + + /** + Finds places and POIs using a query string and a geographical point to focus on. + + - Parameter for: The partial (autocomplete) query to search for. + - Parameter around: Provides a hint for `MKLocalSearchCompleter` to search around a geographical point. + - Parameter completion: Called if valid search results are found. + + - Returns: If a location is found immediately (a coordinate was pasted in, for example), returns a `Location`. + */ + func search( + for query: String, + around location: Location, + completion: @escaping ([LocalSearchResult]) -> Void + ) -> Location? { + guard query.isNotEmpty, query != lastQuery else { return nil } + callback = completion + lastQuery = query + + if let location = parseCoordinates(query) { + return location + } + + localSearchCompleter.queryFragment = query + localSearchCompleter.region = MKCoordinateRegion( + center: location.center, + latitudinalMeters: CLLocationDistance(20000), + longitudinalMeters: CLLocationDistance(20000) + ) + + return nil + } + + /** + Converts an incomplete `LocalSearchResult` to a `Location` with coordinates and map bounds. + + - Parameter result: The `LocalSearchResult` to convert. + - Parameter completion: Called if a valid `Location` is created. + */ + func select(_ result: LocalSearchResult, completion: @escaping (Location) -> Void) { + guard let completer = result.completer else { return } + + Task { + do { + let request = MKLocalSearch.Request(completion: completer) + let response = try await MKLocalSearch(request: request).start() + guard let mapItem = response.mapItems.first else { return } + let location = Location( + id: result.id, + name: result.title, + latitude: mapItem.placemark.coordinate.latitude, + longitude: mapItem.placemark.coordinate.longitude, + latitudeDelta: response.boundingRegion.span.latitudeDelta, + longitudeDelta: response.boundingRegion.span.longitudeDelta) + completion(location) + } catch { + print("\(error)") + } + } + } + + /// Uses a regex to detect if a string is a lat/long coordinate (e.g. `'37.33467, -122.00898'`) + private func parseCoordinates(_ coordinateString: String) -> Location? { + do { + let regexSearch = try Regex("^-?(?:[1-8]?\\d(?:\\.\\d+)?|90(?:\\.0+)?),\\s*-?(?:180(?:\\.0+)?|1[0-7]\\d(?:\\.\\d+)?|\\d{1,2}(?:\\.\\d+)?)$") + + guard coordinateString.ranges(of: regexSearch).isNotEmpty else { + return nil + } + + let components = coordinateString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + + guard let latitude = Double(components[0]), let longitude = Double(components[1]) else { + return nil + } + + return Location( + id: UUID(), + name: "Map coordinate", + latitude: latitude, + longitude: longitude) + + } catch { + return nil + } + } +} + +/// Adds `MKLocalSearchCompleterDelegate` conformance so the controller can use the delegate's callback methods +extension LocalSearchController: MKLocalSearchCompleterDelegate { + /// Called if `MKLocalSearchCompleter` return valid results from a query string + func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { + guard let callback else { return } + let results = completer.results.map { + LocalSearchResult( result: $0 ) + } + callback(results) + } + + /// Called if `MKLocalSearchCompleter` encounters an error + func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { + print(error) + } +} diff --git a/ControlRoom/Helpers/LocalSearchResult.swift b/ControlRoom/Helpers/LocalSearchResult.swift new file mode 100644 index 0000000..8a9046b --- /dev/null +++ b/ControlRoom/Helpers/LocalSearchResult.swift @@ -0,0 +1,38 @@ +// +// LocalSearchResult.swift +// ControlRoom +// +// Created by John McEvoy on 29/11/2023. +// Copyright © 2023 Paul Hudson. All rights reserved. +// + +import Foundation +import MapKit + +/// A local search result item +struct LocalSearchResult: Identifiable { + var id: UUID + var title: String + var subtitle: String? + var completer: MKLocalSearchCompletion? + + init(result: MKLocalSearchCompletion) { + id = UUID() + self.title = result.title + self.subtitle = result.subtitle.clean() + self.completer = result + } +} + +/// if a string is empty or whitespace, convert it to `nil` +extension String { + func clean() -> String? { + let cleanString = self.trimmingCharacters(in: .whitespacesAndNewlines) + + if cleanString.isEmpty { + return nil + } + + return cleanString + } +} diff --git a/ControlRoom/Helpers/Location.swift b/ControlRoom/Helpers/Location.swift index 04957fd..af787f9 100644 --- a/ControlRoom/Helpers/Location.swift +++ b/ControlRoom/Helpers/Location.swift @@ -7,6 +7,8 @@ // import Foundation +import CoreLocation +import MapKit /// The user's saved location. struct Location: Identifiable, Codable { @@ -14,4 +16,27 @@ struct Location: Identifiable, Codable { var name: String var latitude: Double var longitude: Double + var latitudeDelta: Double = 15 + var longitudeDelta: Double = 15 + + var center: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } + + var region: MKCoordinateRegion { + get { + return MKCoordinateRegion( + center: center, + span: MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta)) + } set { + latitude = newValue.center.latitude + longitude = newValue.center.longitude + latitudeDelta = newValue.span.latitudeDelta + longitudeDelta = newValue.span.longitudeDelta + } + } + + func toString() -> String { + String(format: "%.5f, %.5f", latitude, longitude) + } } diff --git a/ControlRoom/Main Window/SidebarView.swift b/ControlRoom/Main Window/SidebarView.swift index 37f5a09..3a821e9 100644 --- a/ControlRoom/Main Window/SidebarView.swift +++ b/ControlRoom/Main Window/SidebarView.swift @@ -65,7 +65,7 @@ struct SidebarView: View { .buttonStyle(.borderless) .padding(.leading, 3) - FilterField("Filter", text: $filterText.onChange(controller.filterSimulators)) + SearchField("Filter", text: $filterText.onChange(controller.filterSimulators), onClear: {}) } .padding(2) .sheet(isPresented: $shouldShowDeleteAlert) { diff --git a/ControlRoom/NSViewWrappers/FilterField.swift b/ControlRoom/NSViewWrappers/SearchField.swift similarity index 73% rename from ControlRoom/NSViewWrappers/FilterField.swift rename to ControlRoom/NSViewWrappers/SearchField.swift index d37dfda..6c78c55 100644 --- a/ControlRoom/NSViewWrappers/FilterField.swift +++ b/ControlRoom/NSViewWrappers/SearchField.swift @@ -9,20 +9,22 @@ import SwiftUI /// A wrapper around NSSearchField so we get a macOS-native search box -struct FilterField: NSViewRepresentable { +struct SearchField: NSViewRepresentable { /// The text entered by the user. @Binding var text: String + var onClear: () -> Void /// Placeholder text for the text field. let prompt: String - init(_ prompt: String, text: Binding) { + init(_ prompt: String, text: Binding, onClear: @escaping () -> Void) { + self.onClear = onClear self.prompt = prompt _text = text } func makeCoordinator() -> Coordinator { - Coordinator(binding: $text) + Coordinator(binding: $text, onClear: onClear) } func makeNSView(context: Context) -> NSSearchField { @@ -40,15 +42,21 @@ struct FilterField: NSViewRepresentable { class Coordinator: NSObject, NSSearchFieldDelegate { let binding: Binding + let onClear: () -> Void - init(binding: Binding) { + init(binding: Binding, onClear: @escaping () -> Void) { self.binding = binding + self.onClear = onClear super.init() } func controlTextDidChange(_ obj: Notification) { guard let field = obj.object as? NSTextField else { return } binding.wrappedValue = field.stringValue + + if field.stringValue.isEmpty { + onClear() + } } } } diff --git a/ControlRoom/Simulator UI/ControlScreens/LocationVIew/LocalSearchRowView.swift b/ControlRoom/Simulator UI/ControlScreens/LocationVIew/LocalSearchRowView.swift new file mode 100644 index 0000000..47d6519 --- /dev/null +++ b/ControlRoom/Simulator UI/ControlScreens/LocationVIew/LocalSearchRowView.swift @@ -0,0 +1,54 @@ +// +// LocalSearchRowView.swift +// ControlRoom +// +// Created by John McEvoy on 29/11/2023. +// Copyright © 2023 Paul Hudson. All rights reserved. +// + +import SwiftUI +import CoreLocation + +struct LocalSearchRowView: View { + @Binding var lastHoverId: UUID? + @State private var isHovered = false + let result: LocalSearchResult + let onTap: () -> Void + + var body: some View { + Button { + onTap() + } label: { + HStack { + + Image(systemName: "mappin.circle.fill") + .symbolRenderingMode(.multicolor) + .font(.system(size: 24)) + + VStack(alignment: .leading, spacing: 2) { + Text(result.title) + .font(.body) + .foregroundColor(.primary) + .lineLimit(1) + + if let subtitle = result.subtitle { + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + Spacer() + } + } + .buttonStyle(.borderless) + .frame(minHeight: 36) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(isHovered ? .blue : .clear) + .cornerRadius(8) + .onChange(of: lastHoverId) { + isHovered = $0 == result.id + } + } +} diff --git a/ControlRoom/Simulator UI/ControlScreens/LocationView.swift b/ControlRoom/Simulator UI/ControlScreens/LocationVIew/LocationView.swift similarity index 50% rename from ControlRoom/Simulator UI/ControlScreens/LocationView.swift rename to ControlRoom/Simulator UI/ControlScreens/LocationVIew/LocationView.swift index 1de9c45..0e3db8a 100644 --- a/ControlRoom/Simulator UI/ControlScreens/LocationView.swift +++ b/ControlRoom/Simulator UI/ControlScreens/LocationVIew/LocationView.swift @@ -19,6 +19,8 @@ struct LocationView: View { /// Saved locations controller. @StateObject private var locationsController = LocationsController() + /// Local search controller. + @StateObject private var localSearchController = LocalSearchController() /// Current table selection binding. @State private var previouslyPickedLocation: Location.ID? @@ -27,12 +29,21 @@ struct LocationView: View { /// Indicates whether save location alert is currently presented. @State private var isShowingNewLocationAlert = false + /// Placeholder that appears in the local search bar + @State private var placeholder = "Search" + /// The query that is typed into the search bar + @State private var query = "" + /// Results returned from the local search + @State private var results: [LocalSearchResult] = [] + /// Controls presentation of the results dropdown + @State private var presentResults = false + /// Keeps track of which search item is being currently hovered over + @State private var lastHoverId: UUID? + @State private var latitudeText = "\(DEFAULT_LAT)" @State private var longitudeText = "\(DEFAULT_LNG)" /// The location that is being simulated - @State private var currentLocation = MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: DEFAULT_LAT, longitude: DEFAULT_LNG), - span: MKCoordinateSpan(latitudeDelta: 15, longitudeDelta: 15)) + @State private var currentLocation = Location(id: UUID(), name: "", latitude: DEFAULT_LAT, longitude: DEFAULT_LNG) @State private var pinnedLocation: CLLocationCoordinate2D? /// A randomly generated location offset from the currentLocation. @@ -59,37 +70,59 @@ struct LocationView: View { var body: some View { Form { VStack { - Text("Move the map wherever you want, then click Activate to update the simulator to match your centered coordinate.") - - HStack(spacing: 10.0) { - TextField("Latitude", text: $latitudeText) - .textFieldStyle(.roundedBorder) - - TextField("Longitude", text: $longitudeText) - .textFieldStyle(.roundedBorder) - } - - Button("Update coordinates") { - if let latitude = Double(latitudeText), - let longitude = Double(longitudeText) { - self.currentLocation = MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), - span: MKCoordinateSpan(latitudeDelta: 15, longitudeDelta: 15)) - } - } + Text("Move the map, paste in coordinates or search for a location, then click Activate to update the simulator to match your centered coordinate.") GeometryReader { proxy in HStack { - ZStack { - Map(coordinateRegion: $currentLocation, annotationItems: annotations) { location in - MapMarker(coordinate: CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude), tint: .red) + ZStack(alignment: .topLeading) { + VStack { + SearchField(placeholder, text: $query, onClear: { onSearchClear() }) + .onReceive(query.publisher) { _ in + performLocalSearch() + } + ZStack { + Map(coordinateRegion: $currentLocation.region, annotationItems: annotations) { location in + MapMarker(coordinate: CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude), tint: .red) + } + .cornerRadius(5) + + Circle() + .stroke(Color.blue, lineWidth: 4) + .frame(width: 20) + } } - .cornerRadius(5) + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + ForEach(results) { result in + LocalSearchRowView(lastHoverId: $lastHoverId, result: result, onTap: { + selectResult(result) + }) + .onHover { isHovered in + if isHovered { + lastHoverId = result.id + } else if lastHoverId == result.id { + lastHoverId = nil + } + } + } - Circle() - .stroke(Color.blue, lineWidth: 4) - .frame(width: 20) + if results.isEmpty { + Text("No suggestions found") + .frame(maxWidth: .infinity) + .foregroundColor(.secondary) + .padding(.vertical, 8) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + } + .frame(maxWidth: .infinity) + .background(.background) + .padding(.top, 24) + .cornerRadius(12) + .opacity(presentResults ? 1 : 0) } + .keyboardShortcut(.defaultAction) Table(of: Location.self, selection: $previouslyPickedLocation.onChange(updatePickedLocation)) { @@ -113,6 +146,7 @@ struct LocationView: View { HStack { Text("Coordinates: \(locationText)") .textSelection(.enabled) + Button("Copy", action: copyCoordinatesToClipboard) Spacer() Toggle("Jitter location", isOn: $isJittering) .toggleStyle(.checkbox) @@ -150,6 +184,50 @@ struct LocationView: View { SimCtl.execute(.location(deviceId: simulator.udid, latitude: coordinate.latitude, longitude: coordinate.longitude)) } + /// Takes the entered query and performs a local search. If the query is a pasted-in coordinate, + /// it will return a `Location` immediately. + private func performLocalSearch() { + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + presentResults = false + NSApp.keyWindow?.makeFirstResponder(nil) + return + } + + let immediateResult = localSearchController.search(for: query, around: currentLocation) { newResults in + results = Array(newResults.prefix(5)) + presentResults = true + } + + if let immediateResult { + currentLocation = immediateResult + presentResults = false + NSApp.keyWindow?.makeFirstResponder(nil) + } + } + + /// If the close button on the search bar is closed, close the dropdown list + private func onSearchClear() { + query = "" + presentResults = false + NSApp.keyWindow?.makeFirstResponder(nil) + } + + /// If a result is chosen, request the coordinate and present the location on the map + private func selectResult(_ result: LocalSearchResult) { + localSearchController.select(result) { location in + currentLocation = location + presentResults = false + NSApp.keyWindow?.makeFirstResponder(nil) + } + } + + /// Copies `currentLocation` to the clipboard + private func copyCoordinatesToClipboard() { + let pasteboard = NSPasteboard.general + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString(currentLocation.toString(), forType: .string) + } + /// Randomly generates a new location slightly offset from the currentLocation private func jitterLocation() { let lat = currentLocation.center.latitude + (Double.random(in: -0.0001...0.0001)) @@ -163,17 +241,12 @@ struct LocationView: View { let latitude = currentLocation.center.latitude let longitude = currentLocation.center.longitude locationsController.create(name: newLocationName, latitude: latitude, longitude: longitude) - newLocationName = "" } /// Updates current location on the map when saved location is selected from the table. private func updatePickedLocation() { guard let location = locationsController.item(with: previouslyPickedLocation) else { return } - - currentLocation = MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude), - span: MKCoordinateSpan(latitudeDelta: 15, longitudeDelta: 15) - ) + currentLocation = location } }