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 */
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 = "<group>"; };
B54587302C961E9E0067B788 /* MapUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapUITestsLaunchTests.swift; sourceTree = "<group>"; };
+ B5D42DC12C984E7F0075473D /* FocusedValues+document.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FocusedValues+document.swift"; sourceTree = "<group>"; };
+ B5D42DC32C9851ED0075473D /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
B5012E3C2C96222E00AC4D68 /* Data */ = {
isa = PBXGroup;
children = (
+ B5D42DC12C984E7F0075473D /* FocusedValues+document.swift */,
B5012E412C96235B00AC4D68 /* Stage.swift */,
B54587112C961E9C0067B788 /* MapDocument.swift */,
);
B5012E6A2C96255A00AC4D68 /* MapRender */,
B5012E462C96243500AC4D68 /* MapTextEditor.swift */,
B5012E3E2C96232300AC4D68 /* EvolutionPicker.swift */,
+ B5D42DC32C9851ED0075473D /* SearchBar.swift */,
);
path = "Base Components";
sourceTree = "<group>";
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 */,
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 */,
--- /dev/null
+/*
+ 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<MapDocument>
+}
+
+struct IsSearchingValueKey: FocusedValueKey {
+ typealias Value = Binding<Bool>
+}
+
+struct SelectedEvolutionValueKey: FocusedValueKey {
+ typealias Value = Binding<StageType>
+}
+
+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 }
+ }
+}
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] {
+import Sparkle
/*
Copyright (C) 2024 Rubén Beltrán del Río
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)
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
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)
}
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
}
}
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
+ }
}
--- /dev/null
+/*
+ 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"))
+}
@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) {
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()
}
+import Sparkle
/*
Copyright (C) 2024 Rubén Beltrán del Río
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)
}
}
-
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)
+ }
}
@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)
}.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
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 {
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 {