From: Ruben Beltran del Rio Date: Mon, 16 Sep 2024 18:32:25 +0000 (+0200) Subject: Add search X-Git-Url: https://git.r.bdr.sh/rbdr/map/commitdiff_plain/144915635bdfc90445321189914929a911fe77d4 Add search --- diff --git a/Map.xcodeproj/project.pbxproj b/Map.xcodeproj/project.pbxproj index 7dc8883..5601b8e 100644 --- a/Map.xcodeproj/project.pbxproj +++ b/Map.xcodeproj/project.pbxproj @@ -48,6 +48,8 @@ B54587252C961E9E0067B788 /* MapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54587242C961E9E0067B788 /* MapTests.swift */; }; B545872F2C961E9E0067B788 /* MapUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B545872E2C961E9E0067B788 /* MapUITests.swift */; }; B54587312C961E9E0067B788 /* MapUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54587302C961E9E0067B788 /* MapUITestsLaunchTests.swift */; }; + B5D42DC22C984E870075473D /* FocusedValues+document.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D42DC12C984E7F0075473D /* FocusedValues+document.swift */; }; + B5D42DC42C9851ED0075473D /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D42DC32C9851ED0075473D /* SearchBar.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -111,6 +113,8 @@ B545872A2C961E9E0067B788 /* Map2UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Map2UITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B545872E2C961E9E0067B788 /* MapUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapUITests.swift; sourceTree = ""; }; B54587302C961E9E0067B788 /* MapUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapUITestsLaunchTests.swift; sourceTree = ""; }; + B5D42DC12C984E7F0075473D /* FocusedValues+document.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FocusedValues+document.swift"; sourceTree = ""; }; + B5D42DC32C9851ED0075473D /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -144,6 +148,7 @@ B5012E3C2C96222E00AC4D68 /* Data */ = { isa = PBXGroup; children = ( + B5D42DC12C984E7F0075473D /* FocusedValues+document.swift */, B5012E412C96235B00AC4D68 /* Stage.swift */, B54587112C961E9C0067B788 /* MapDocument.swift */, ); @@ -169,6 +174,7 @@ B5012E6A2C96255A00AC4D68 /* MapRender */, B5012E462C96243500AC4D68 /* MapTextEditor.swift */, B5012E3E2C96232300AC4D68 /* EvolutionPicker.swift */, + B5D42DC32C9851ED0075473D /* SearchBar.swift */, ); path = "Base Components"; sourceTree = ""; @@ -465,6 +471,7 @@ B54587122C961E9C0067B788 /* MapDocument.swift in Sources */, B54587102C961E9C0067B788 /* MapApp.swift in Sources */, B5012E8C2C98244000AC4D68 /* ViewStyle.swift in Sources */, + B5D42DC42C9851ED0075473D /* SearchBar.swift in Sources */, B5012E8E2C9828D000AC4D68 /* Constants.swift in Sources */, B5012E7C2C972B6C00AC4D68 /* GroupParserStrategy.swift in Sources */, B5012E6B2C96255A00AC4D68 /* MapAxes.swift in Sources */, @@ -486,6 +493,7 @@ B5012E5B2C96249400AC4D68 /* OpportunityParserStrategy.swift in Sources */, B5012E5C2C96249400AC4D68 /* EdgeParserStrategy.swift in Sources */, B5012E5D2C96249400AC4D68 /* StageParserStrategy.swift in Sources */, + B5D42DC22C984E870075473D /* FocusedValues+document.swift in Sources */, B5012E812C97318600AC4D68 /* MapGroups.swift in Sources */, B5012E8A2C98235500AC4D68 /* MapCommands.swift in Sources */, B5012E5E2C96249400AC4D68 /* Debouncer.swift in Sources */, diff --git a/Map/Data/FocusedValues+document.swift b/Map/Data/FocusedValues+document.swift new file mode 100644 index 0000000..a28404f --- /dev/null +++ b/Map/Data/FocusedValues+document.swift @@ -0,0 +1,52 @@ +/* + 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 SwiftUI + +struct DocumentFocusedValueKey: FocusedValueKey { + typealias Value = Binding +} + +struct IsSearchingValueKey: FocusedValueKey { + typealias Value = Binding +} + +struct SelectedEvolutionValueKey: FocusedValueKey { + typealias Value = Binding +} + +struct FileURLValueKey: FocusedValueKey { + typealias Value = URL +} + +extension FocusedValues { + var document: DocumentFocusedValueKey.Value? { + get { self[DocumentFocusedValueKey.self] } + set { self[DocumentFocusedValueKey.self] = newValue } + } + var isSearching: IsSearchingValueKey.Value? { + get { self[IsSearchingValueKey.self] } + set { self[IsSearchingValueKey.self] = newValue } + } + var selectedEvolution: SelectedEvolutionValueKey.Value? { + get { self[SelectedEvolutionValueKey.self] } + set { self[SelectedEvolutionValueKey.self] = newValue } + } + var fileURL: FileURLValueKey.Value? { + get { self[FileURLValueKey.self] } + set { self[FileURLValueKey.self] = newValue } + } +} diff --git a/Map/Logic/MapParser/Strategies/GroupParserStrategy.swift b/Map/Logic/MapParser/Strategies/GroupParserStrategy.swift index d1acec2..d5ab60c 100644 --- a/Map/Logic/MapParser/Strategies/GroupParserStrategy.swift +++ b/Map/Logic/MapParser/Strategies/GroupParserStrategy.swift @@ -33,9 +33,10 @@ struct GroupParserStrategy: MapParserStrategy { var groupVertices: [Vertex] = [] let vertexIdString = String(line[Range(match.range(at: 2), in: line)!]) let vertexIds = vertexIdString.split(separator: ",", omittingEmptySubsequences: true).map( - String.init).map({ vertexId in - vertexId.trimmingCharacters(in: .whitespacesAndNewlines) - }) + String.init + ).map({ vertexId in + vertexId.trimmingCharacters(in: .whitespacesAndNewlines) + }) for vertexId in vertexIds { if let vertex = vertices[vertexId] { diff --git a/Map/MapApp.swift b/Map/MapApp.swift index fa60ab0..4578bd8 100644 --- a/Map/MapApp.swift +++ b/Map/MapApp.swift @@ -1,3 +1,4 @@ +import Sparkle /* Copyright (C) 2024 Rubén Beltrán del Río @@ -15,16 +16,18 @@ along with this program. If not, see https://map.tranquil.systems. */ import SwiftUI -import Sparkle @main struct MapApp: App { - - private let updaterController: SPUStandardUpdaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) - + + private let updaterController: SPUStandardUpdaterController = SPUStandardUpdaterController( + startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + var body: some Scene { DocumentGroup(newDocument: MapDocument()) { file in MapEditor(document: file.$document, url: file.fileURL) + .focusedSceneValue(\.document, file.$document) + .focusedSceneValue(\.fileURL, file.fileURL) }.commands { MapCommands() UpdateCommands(updaterController: updaterController) diff --git a/Map/Presentation/Base Components/MapTextEditor.swift b/Map/Presentation/Base Components/MapTextEditor.swift index 31da790..a7dbe52 100644 --- a/Map/Presentation/Base Components/MapTextEditor.swift +++ b/Map/Presentation/Base Components/MapTextEditor.swift @@ -20,6 +20,19 @@ import SwiftUI class MapTextEditorController: NSViewController { @Binding var document: MapDocument + var highlightRanges: [Range] { + didSet { + updateHighlights() + } + } + + var selectedRange: Int { + didSet { + updateHighlights() + focusOnResult() + } + } + let onChange: () -> Void private let vertexRegex = MapParsingPatterns.vertex @@ -32,9 +45,14 @@ class MapTextEditorController: NSViewController { private let changeDebouncer: Debouncer = Debouncer(seconds: 1) - init(document: Binding, onChange: @escaping () -> Void) { + init( + document: Binding, highlightRanges: [Range], selectedRange: Int, + onChange: @escaping () -> Void + ) { self._document = document self.onChange = onChange + self.highlightRanges = highlightRanges + self.selectedRange = selectedRange super.init(nibName: nil, bundle: nil) } @@ -60,6 +78,50 @@ class MapTextEditorController: NSViewController { override func viewDidAppear() { self.view.window?.makeFirstResponder(self.view) + updateHighlights() + } + + private var textView: NSTextView? { + return (view as? NSScrollView)?.documentView as? NSTextView + } + + private func updateHighlights() { + if let textView { + if let textStorage = textView.textStorage { + textStorage.removeAttribute( + .backgroundColor, range: NSRange(location: 0, length: textStorage.length)) + + for range in highlightRanges { + let nsRange = NSRange(range, in: textStorage.string) + + textStorage.addAttribute(.backgroundColor, value: NSColor.syntax.match, range: nsRange) + } + + textView.needsDisplay = true + + } + } + } + + private func focusOnResult() { + if let textView { + if let textStorage = textView.textStorage { + if selectedRange < highlightRanges.count { + let range = highlightRanges[selectedRange] + let nsRange = NSRange(range, in: textStorage.string) + textView.scrollRangeToVisible(nsRange) + textView.selectedRange = nsRange + } + } + } + } + + private func setSelectionColor() { + guard let textView = self.textView else { return } + + var selectedTextAttributes = textView.selectedTextAttributes + selectedTextAttributes[.backgroundColor] = NSColor.yellow.withAlphaComponent(0.3) + textView.selectedTextAttributes = selectedTextAttributes } } @@ -182,16 +244,23 @@ extension MapTextEditorController: NSTextStorageDelegate { struct MapTextEditor: NSViewControllerRepresentable { @Binding var document: MapDocument + var highlightRanges: [Range] + var selectedRange: Int var onChange: () -> Void = {} func makeNSViewController( context: NSViewControllerRepresentableContext ) -> MapTextEditorController { - return MapTextEditorController(document: $document, onChange: onChange) + return MapTextEditorController( + document: $document, highlightRanges: highlightRanges, selectedRange: selectedRange, + onChange: onChange) } func updateNSViewController( _ nsViewController: MapTextEditorController, context: NSViewControllerRepresentableContext - ) {} + ) { + nsViewController.highlightRanges = highlightRanges + nsViewController.selectedRange = selectedRange + } } diff --git a/Map/Presentation/Base Components/SearchBar.swift b/Map/Presentation/Base Components/SearchBar.swift new file mode 100644 index 0000000..7e921b0 --- /dev/null +++ b/Map/Presentation/Base Components/SearchBar.swift @@ -0,0 +1,74 @@ +/* + 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 SwiftUI + +struct SearchBar: View { + + @Binding var term: String + @FocusState var isSearchFocused: Bool + var onNext: () -> Void = {} + var onPrevious: () -> Void = {} + var onSubmit: () -> Void = {} + var onDismiss: () -> Void = {} + + var body: some View { + HStack(spacing: 2.0) { + ZStack { + TextField("Search", text: $term) + .textFieldStyle(.roundedBorder) + .padding(.trailing, 16.0) + .focused($isSearchFocused) + .onAppear { + isSearchFocused = true + } + .onKeyPress { press in + if press.key == .return { + if press.modifiers.contains(.shift) { + onPrevious() + return .handled + } + onNext() + return .handled + } + return .ignored + } + } + Spacer() + Button(action: onPrevious) { + Image(systemName: "chevron.left") + .font(.theme.smallControl) + }.keyboardShortcut( + "g", modifiers: EventModifiers([.command, .shift]) + ).help("Find Previous (⇧⌘G)") + Button(action: onNext) { + Image(systemName: "chevron.right") + .font(.theme.smallControl) + }.keyboardShortcut( + "g", modifiers: EventModifiers([.command]) + ).help("Find Next (⌘G)") + Button(action: onDismiss) { + Text("Done") + .font(.theme.smallControl) + }.keyboardShortcut(.escape, modifiers: EventModifiers()) + .help("Done (⎋)") + }.padding(4.0) + } +} + +#Preview { + SearchBar(term: .constant("Hello")) +} diff --git a/Map/Presentation/Commands/MapCommands.swift b/Map/Presentation/Commands/MapCommands.swift index b0fbf9c..83c4b27 100644 --- a/Map/Presentation/Commands/MapCommands.swift +++ b/Map/Presentation/Commands/MapCommands.swift @@ -20,9 +20,63 @@ struct MapCommands: Commands { @AppStorage("viewStyle") var viewStyle: ViewStyle = .horizontal @AppStorage("zoom") var zoom = 1.0 + @FocusedBinding(\.document) var document: MapDocument? + @FocusedValue(\.fileURL) var url: URL? + @FocusedBinding(\.selectedEvolution) var selectedEvolution: StageType? + @FocusedBinding(\.isSearching) var isSearching var body: some Commands { + // File + + CommandGroup(after: CommandGroupPlacement.saveItem) { + Divider() + Button("Export...") { + if let selectedEvolution, let document { + if let image = document.exportAsImage(withEvolution: selectedEvolution) { + + let filename = url?.deletingPathExtension().lastPathComponent ?? "Untitled" + + let savePanel = NSSavePanel() + savePanel.allowedContentTypes = [.png] + savePanel.canCreateDirectories = true + savePanel.isExtensionHidden = false + savePanel.title = "Save \(filename) as image" + savePanel.message = "Choose a location to save the image" + savePanel.nameFieldStringValue = "\(filename).png" + savePanel.begin { result in + if result == .OK, let url = savePanel.url { + if let tiffRepresentation = image.tiffRepresentation { + let bitmapImage = NSBitmapImageRep(data: tiffRepresentation) + let pngData = bitmapImage?.representation(using: .png, properties: [:]) + do { + try pngData?.write(to: url) + } catch { + return + } + } + } + } + } + } + }.keyboardShortcut( + "e", modifiers: EventModifiers([.command]) + ).disabled(document == nil) + } + + // Edit + + CommandGroup(after: CommandGroupPlacement.pasteboard) { + Divider() + Button("Find...") { + withAnimation { + isSearching = isSearching != nil ? !isSearching! : true + } + }.keyboardShortcut( + "f", modifiers: EventModifiers([.command]) + ).disabled(document == nil) + } + // View CommandGroup(after: CommandGroupPlacement.toolbar) { @@ -31,25 +85,25 @@ struct MapCommands: Commands { viewStyle = .vertical }.keyboardShortcut( "l", modifiers: EventModifiers([.command]) - ) + ).disabled(document == nil) } else { Button("Use Horizontal Layout") { viewStyle = .horizontal }.keyboardShortcut( "l", modifiers: EventModifiers([.command]) - ) + ).disabled(document == nil) } Divider() Button("Zoom In") { zoom = min(Constants.kMaxZoom, zoom + 0.1) }.keyboardShortcut( "+", modifiers: EventModifiers([.command]) - ) + ).disabled(document == nil) Button("Zoom Out") { zoom = max(Constants.kMinZoom, zoom - 0.1) }.keyboardShortcut( "-", modifiers: EventModifiers([.command]) - ) + ).disabled(document == nil) Divider() } diff --git a/Map/Presentation/Commands/UpdateCommands.swift b/Map/Presentation/Commands/UpdateCommands.swift index 51d2b0a..3a019cc 100644 --- a/Map/Presentation/Commands/UpdateCommands.swift +++ b/Map/Presentation/Commands/UpdateCommands.swift @@ -1,3 +1,4 @@ +import Sparkle /* Copyright (C) 2024 Rubén Beltrán del Río @@ -15,11 +16,10 @@ along with this program. If not, see https://map.tranquil.systems. */ import SwiftUI -import Sparkle struct UpdateCommands: Commands { let updaterController: SPUStandardUpdaterController - + var body: some Commands { CommandGroup(after: .appInfo) { CheckForUpdatesView(updater: updaterController.updater) @@ -27,29 +27,28 @@ struct UpdateCommands: Commands { } } - struct CheckForUpdatesView: View { - @ObservedObject private var checkForUpdatesViewModel: CheckForUpdatesViewModel - private let updater: SPUUpdater - - init(updater: SPUUpdater) { - self.updater = updater - - // Create our view model for our CheckForUpdatesView - self.checkForUpdatesViewModel = CheckForUpdatesViewModel(updater: updater) - } - - var body: some View { - Button("Check for Updates…", action: updater.checkForUpdates) - .disabled(!checkForUpdatesViewModel.canCheckForUpdates) - } + @ObservedObject private var checkForUpdatesViewModel: CheckForUpdatesViewModel + private let updater: SPUUpdater + + init(updater: SPUUpdater) { + self.updater = updater + + // Create our view model for our CheckForUpdatesView + self.checkForUpdatesViewModel = CheckForUpdatesViewModel(updater: updater) + } + + var body: some View { + Button("Check for Updates…", action: updater.checkForUpdates) + .disabled(!checkForUpdatesViewModel.canCheckForUpdates) + } } final class CheckForUpdatesViewModel: ObservableObject { - @Published var canCheckForUpdates = false + @Published var canCheckForUpdates = false - init(updater: SPUUpdater) { - updater.publisher(for: \.canCheckForUpdates) - .assign(to: &$canCheckForUpdates) - } + init(updater: SPUUpdater) { + updater.publisher(for: \.canCheckForUpdates) + .assign(to: &$canCheckForUpdates) + } } diff --git a/Map/Presentation/MapEditor.swift b/Map/Presentation/MapEditor.swift index 8c5ab22..d7c93dd 100644 --- a/Map/Presentation/MapEditor.swift +++ b/Map/Presentation/MapEditor.swift @@ -20,18 +20,75 @@ struct MapEditor: View { @Binding var document: MapDocument var url: URL? @State var selectedEvolution: StageType = .behavior + @State var isSearching: Bool = false @AppStorage("viewStyle") var viewStyle: ViewStyle = .horizontal let zoomRange = Constants.kMinZoom...Constants.kMaxZoom @AppStorage("zoom") var zoom = 1.0 @State var lastZoom = 1.0 + @State var searchTerm = "" + @State var selectedTerm = 0 + + var results: [Range] { + if !isSearching || searchTerm.isEmpty { + return [] + } + 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 + } + } + } + }, + onSubmit: { + + }, + onDismiss: { + withAnimation { + isSearching = false + } + } + ) + .onChange( + of: searchTerm, + { + selectedTerm = 0 + }) + Divider() + } adaptiveStack { ZStack(alignment: .topLeading) { - MapTextEditor(document: $document) + MapTextEditor(document: $document, highlightRanges: results, selectedRange: selectedTerm) .background(Color.ui.background) .foregroundColor(Color.ui.foreground) .frame(minHeight: 96.0) @@ -81,14 +138,10 @@ struct MapEditor: View { }.padding(4.0) }.toolbar { HStack { - Button(action: saveImage) { - Image(systemName: "photo") - } - .help("Export Image (⌘E)") - .padding(.vertical, 4.0).padding(.leading, 4.0).padding(.trailing, 8.0) + EvolutionPicker(selectedEvolution: $selectedEvolution) } - EvolutionPicker(selectedEvolution: $selectedEvolution) - } + }.focusedSceneValue(\.isSearching, $isSearching) + .focusedSceneValue(\.selectedEvolution, $selectedEvolution) } @ViewBuilder @@ -114,34 +167,6 @@ struct MapEditor: View { private func onDragVertex(vertex: Vertex, x: CGFloat, y: CGFloat) { } - - private func saveImage() { - if let image = document.exportAsImage(withEvolution: selectedEvolution) { - - let filename = url?.deletingPathExtension().lastPathComponent ?? "Untitled" - - let savePanel = NSSavePanel() - savePanel.allowedContentTypes = [.png] - savePanel.canCreateDirectories = true - savePanel.isExtensionHidden = false - savePanel.title = "Save \(filename) as image" - savePanel.message = "Choose a location to save the image" - savePanel.nameFieldStringValue = "\(filename).png" - savePanel.begin { result in - if result == .OK, let url = savePanel.url { - if let tiffRepresentation = image.tiffRepresentation { - let bitmapImage = NSBitmapImageRep(data: tiffRepresentation) - let pngData = bitmapImage?.representation(using: .png, properties: [:]) - do { - try pngData?.write(to: url) - } catch { - return - } - } - } - } - } - } } #Preview { diff --git a/Map/Presentation/Theme/NSColor+theme.swift b/Map/Presentation/Theme/NSColor+theme.swift index 2b9b8f7..f1a046d 100644 --- a/Map/Presentation/Theme/NSColor+theme.swift +++ b/Map/Presentation/Theme/NSColor+theme.swift @@ -22,6 +22,9 @@ extension NSColor { static let number = NSColor(named: "Number") ?? .textColor static let option = NSColor(named: "Option") ?? .textColor static let symbol = NSColor(named: "Symbol") ?? .textColor + static let match = (NSColor(named: "Light Neutral Gray") ?? .textColor).withAlphaComponent(0.3) + static let highlightMatch = (NSColor(named: "Naples Yellow") ?? .textColor).withAlphaComponent( + 0.3) } struct ui {