// 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 import UniformTypeIdentifiers struct MapCommands: Commands { @AppStorage("viewStyle") var viewStyle: ViewStyle = ViewStyle(rawValue: UserPreferences.Defaults.viewStyle) ?? .horizontal @AppStorage("zoom") var zoom = UserPreferences.Defaults.zoom @AppStorage("mapTemplates") private var templatesData: Data = UserPreferences.Defaults .mapTemplates @FocusedBinding(\.document) var document: MapDocument? @FocusedValue(\.fileURL) var url: URL? @FocusedBinding(\.selectedEvolution) var selectedEvolution: StageType? @FocusedBinding(\.isSearching) var isSearching private var templates: [Template] { if let decoded = try? JSONDecoder().decode([Template].self, from: templatesData) { return decoded } return [] } var body: some Commands { // File CommandGroup(after: CommandGroupPlacement.newItem) { Menu(String(localized: "commands.file.new_from_template")) { if templates.isEmpty { Text("commands.file.new_from_template.empty") .disabled(true) } else { ForEach(templates) { template in Button(template.name) { createNewDocument(from: template) } } } } } CommandGroup(after: CommandGroupPlacement.saveItem) { Divider() Button("commands.file.export") { if let selectedEvolution, let document { if let image = document.exportAsImage(withEvolution: selectedEvolution) { // Capture image data to avoid sendability issues with NSImage guard let tiffData = image.tiffRepresentation else { return } let filename = url?.deletingPathExtension().lastPathComponent ?? "Untitled" // Create format selection accessory view controller @MainActor class ExportAccessoryViewController: NSViewController { var formatPopup: NSPopUpButton! let savePanel: NSSavePanel let formats: [ExportFormats] let baseFilename: String init( savePanel: NSSavePanel, baseFilename: String ) { self.savePanel = savePanel self.formats = ExportFormats.allCases self.baseFilename = baseFilename super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { let view = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 30)) let label = NSTextField( labelWithString: String(localized: "commands.file.export.format")) label.frame = NSRect(x: 10, y: 8, width: 45, height: 17) formatPopup = NSPopUpButton( frame: NSRect(x: 60, y: 5, width: 120, height: 20), pullsDown: false) formatPopup.autoenablesItems = false for format in ExportFormats.allCases { formatPopup.addItem(withTitle: format.title) } for item in formatPopup.itemArray { item.isEnabled = true } // Restore last selected format let lastSelectedFormat = UserDefaults.standard.string(forKey: "defaultExportFormat") ?? "png" let format = ExportFormats(rawValue: lastSelectedFormat) ?? .png savePanel.allowedContentTypes = [format.utType] formatPopup.selectItem(withTitle: format.title) formatPopup.target = self formatPopup.action = #selector(formatChanged(_:)) view.addSubview(label) view.addSubview(formatPopup) self.view = view } @objc func formatChanged(_ sender: NSPopUpButton) { let selectedIndex = sender.indexOfSelectedItem let selectedFormat = formats[selectedIndex] // Update allowed content types savePanel.allowedContentTypes = [selectedFormat.utType] // Save the selection for next time UserDefaults.standard.set(selectedFormat.rawValue, forKey: "defaultExportFormat") } } let formats = ExportFormats.allCases let savePanel = NSSavePanel() let lastSelectedFormat = UserDefaults.standard.string(forKey: "defaultExportFormat") ?? "png" let format = ExportFormats(rawValue: lastSelectedFormat) ?? .png savePanel.allowedContentTypes = [format.utType] savePanel.canCreateDirectories = true savePanel.isExtensionHidden = false savePanel.title = String(localized: "commands.file.export.panel.title \(filename)") savePanel.message = String(localized: "commands.file.export.panel.content") savePanel.nameFieldStringValue = "\(filename)" let accessoryController = ExportAccessoryViewController( savePanel: savePanel, baseFilename: filename) savePanel.accessoryView = accessoryController.view savePanel.begin { result in if result == .OK { let bitmapImage = NSBitmapImageRep(data: tiffData) Task { @MainActor in guard let url = savePanel.url else { return } let selectedFormat = formats[accessoryController.formatPopup.indexOfSelectedItem] // Generate data based on selected format let imageData = bitmapImage?.representation( using: selectedFormat.fileType, properties: [:]) do { try imageData?.write(to: url) } catch { return } } } } } } }.keyboardShortcut( "e", modifiers: EventModifiers([.command]) ).disabled(document == nil) } // Edit CommandGroup(after: CommandGroupPlacement.pasteboard) { Divider() Button("commands.edit.find") { isSearching = isSearching != nil ? !isSearching! : true }.keyboardShortcut( "f", modifiers: EventModifiers([.command]) ).disabled(document == nil) } // View CommandGroup(after: CommandGroupPlacement.toolbar) { if viewStyle == .horizontal { Button("commands.view.layout.vertical") { viewStyle = .vertical }.keyboardShortcut( "l", modifiers: EventModifiers([.command]) ).disabled(document == nil) } else { Button("commands.view.layout.horizontal") { viewStyle = .horizontal }.keyboardShortcut( "l", modifiers: EventModifiers([.command]) ).disabled(document == nil) } Divider() Button("commands.view.zoom_in") { zoom = min(Constants.kMaxZoom, zoom + 0.1) }.keyboardShortcut( "+", modifiers: EventModifiers([.command]) ).disabled(document == nil) Button("commands.view.zoom_out") { zoom = max(Constants.kMinZoom, zoom - 0.1) }.keyboardShortcut( "-", modifiers: EventModifiers([.command]) ).disabled(document == nil) Divider() } CommandGroup(replacing: CommandGroupPlacement.help) { Button("commands.help.help") { NSWorkspace.shared.open(URL(string: "https://map.tranquil.systems")!) } } } @MainActor private func createNewDocument(from template: Template) { // Store the template content in UserDefaults temporarily UserDefaults.standard.set(template.content, forKey: "pendingTemplateContent") // Create a new document using the standard mechanism NSApplication.shared.sendAction( #selector(NSDocumentController.newDocument(_:)), to: nil, from: nil) // Clear the pending template content after a short delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { UserDefaults.standard.removeObject(forKey: "pendingTemplateContent") } } }