aboutsummaryrefslogtreecommitdiff
path: root/Map/Presentation
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2024-09-16 20:32:25 +0200
committerRuben Beltran del Rio <git@r.bdr.sh>2024-09-16 20:32:25 +0200
commit144915635bdfc90445321189914929a911fe77d4 (patch)
tree20f77a976c19f9c8bd138cd3dce06a742acb5113 /Map/Presentation
parentf93215a7b37e065a0a52a852bb2c35d2a3d77b0b (diff)
Add search
Diffstat (limited to 'Map/Presentation')
-rw-r--r--Map/Presentation/Base Components/MapTextEditor.swift75
-rw-r--r--Map/Presentation/Base Components/SearchBar.swift74
-rw-r--r--Map/Presentation/Commands/MapCommands.swift62
-rw-r--r--Map/Presentation/Commands/UpdateCommands.swift43
-rw-r--r--Map/Presentation/MapEditor.swift97
-rw-r--r--Map/Presentation/Theme/NSColor+theme.swift3
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 {