diff options
| author | Ruben Beltran del Rio <git@r.bdr.sh> | 2024-09-16 20:32:25 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <git@r.bdr.sh> | 2024-09-16 20:32:25 +0200 |
| commit | 144915635bdfc90445321189914929a911fe77d4 (patch) | |
| tree | 20f77a976c19f9c8bd138cd3dce06a742acb5113 /Map/Presentation | |
| parent | f93215a7b37e065a0a52a852bb2c35d2a3d77b0b (diff) | |
Add search
Diffstat (limited to 'Map/Presentation')
| -rw-r--r-- | Map/Presentation/Base Components/MapTextEditor.swift | 75 | ||||
| -rw-r--r-- | Map/Presentation/Base Components/SearchBar.swift | 74 | ||||
| -rw-r--r-- | Map/Presentation/Commands/MapCommands.swift | 62 | ||||
| -rw-r--r-- | Map/Presentation/Commands/UpdateCommands.swift | 43 | ||||
| -rw-r--r-- | Map/Presentation/MapEditor.swift | 97 | ||||
| -rw-r--r-- | Map/Presentation/Theme/NSColor+theme.swift | 3 |
6 files changed, 289 insertions, 65 deletions
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<String.Index>] { + 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<MapDocument>, onChange: @escaping () -> Void) { + init( + document: Binding<MapDocument>, highlightRanges: [Range<String.Index>], 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<String.Index>] + var selectedRange: Int var onChange: () -> Void = {} func makeNSViewController( context: NSViewControllerRepresentableContext<MapTextEditor> ) -> MapTextEditorController { - return MapTextEditorController(document: $document, onChange: onChange) + return MapTextEditorController( + document: $document, highlightRanges: highlightRanges, selectedRange: selectedRange, + onChange: onChange) } func updateNSViewController( _ nsViewController: MapTextEditorController, context: NSViewControllerRepresentableContext<MapTextEditor> - ) {} + ) { + 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<String.Index>] { + if !isSearching || searchTerm.isEmpty { + return [] + } + let options: NSString.CompareOptions = [.caseInsensitive, .diacriticInsensitive] + var searchRange = document.text.startIndex..<document.text.endIndex + var ranges: [Range<String.Index>] = [] + + while let range = document.text.range(of: searchTerm, options: options, range: searchRange) { + ranges.append(range) + searchRange = range.upperBound..<document.text.endIndex + } + + return ranges + + } var body: some View { VStack(spacing: 0) { + if isSearching { + SearchBar( + term: $searchTerm, + onNext: { + withAnimation { + if results.count > 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 { |