Create a document based editor from scratch as a swift package

I thought I had most of the information for this ready from either Derik Ramirez's great blog or the great article Creating macOS apps without a storyboard or .xib file with Swift 5 from Ryan Theodore The. But I ran into some real hard to figure out pices that where all answered by the great guys from objc.io — I am a happy subscriber 😀

Create a Hello World App

mkdir ~/Desktop/Scratched cd ~/Desktop/Scratched swift package init --type executable xed .

Create an AppDelegate

import Cocoa import os.log class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { os_log(.debug, "%@ started", ProcessInfo.processInfo.processName as CVarArg) } }

Remeber to set the target platform in Package.swift for this to work.

let package = Package( name: "Scratched", platforms: [ .macOS("11") ],...

Update the main.swift file

import Cocoa let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate app.run()

Now you can press cmd-r and you should see the log message.

Make it document based

Create a model class

Let's reuse the same class I used here

import Foundation class Content: NSObject { @objc dynamic var contentString: String init(contentString: String) { self.contentString = contentString } } extension Content { func read(from data: Data) { contentString = String(bytes: data, encoding: .utf8) ?? "" } func data() -> Data { contentString.data(using: .utf8) ?? Data() } }

Before we create the Document type let's create a ViewController.

Create a ViewController

import Cocoa import os.log final class ViewController: NSViewController { var textView = NSTextView() override func loadView() { let scrollView = NSScrollView() scrollView.hasVerticalScroller = true textView.isRichText = false textView.allowsUndo = true textView.autoresizingMask = [.width] scrollView.documentView = textView self.view = scrollView } override func viewDidLoad() { super.viewDidLoad() if let content = representedObject as? Content { textView.bind(.value, to: content, keyPath: \.contentString, options: [NSBindingOption.continuouslyUpdatesValue: true]) } } } extension NSObject { func bind<Root, Value>(_ binding: NSBindingName, to observable: Root, keyPath: KeyPath<Root, Value>, options: [NSBindingOption: Any]? = nil) { guard let kvcKeyPath = keyPath._kvcKeyPathString else { os_log("KeyPath does not contain @objc exposed values") return } bind(binding, to: observable, withKeyPath: kvcKeyPath, options: options) } }

Create the document class

import Cocoa class Document: NSDocument { @objc dynamic var content = Content(contentString: "") private lazy var viewController = ViewController() override func makeWindowControllers() { viewController.representedObject = content let window = NSWindow(contentViewController: viewController) window.setContentSize(NSSize(width: 640, height: 480)) let wc = NSWindowController(window: window) addWindowController(wc) wc.contentViewController = viewController window.setFrameAutosaveName("window_frame") window.makeKeyAndOrderFront(nil) } }

Create the document controller class

This is important to tell the system about our Document class. I learned this from objc.io.

import Cocoa class DocumentController: NSDocumentController { override var documentClassNames: [String] { ["Document"] } override var defaultType: String? { "Document" } override func documentClass(forType typeName: String) -> AnyClass? { Document.self } }

Now comes the fun part!

To actually hook this up we need to add the following to the AppDelegate:

func applicationWillFinishLaunching(_ notification: Notification) { _ = DocumentController() }

Please take note that this is not ...DidFinish, but ...WillFinish! As Florian from objc.io pointed out: The first instance of a NSDocumentController in your app becomes the document controller of your app!

And one more thing:

Normally you would have an entry like this in your Info.plist

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>NSPrincipalClass</key> <string>NSApplication</string> </dict> </plist>

This tells the system that your app is a regular app. In code we add this to the main.swift:

app.setActivationPolicy(.regular)

add it before the call to run().

You should be able to start the app and type into the NSTextView, now.

Let's create the menu

I initially found this in Ryan's article:

import Cocoa class Menu: NSMenu { private lazy var appName = ProcessInfo.processInfo.processName override init(title: String) { super.init(title: title) // App Menu let appMenu = NSMenuItem() appMenu.submenu = NSMenu() appMenu.submenu?.items = [ NSMenuItem(title: "About \(appName)", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: ""), NSMenuItem.separator(), NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") ] // File Menu let fileMenu = NSMenuItem() fileMenu.submenu = NSMenu(title: "File") fileMenu.submenu?.items = [ NSMenuItem(title: "New", action: #selector(NSDocumentController.newDocument(_:)), keyEquivalent: "n"), NSMenuItem(title: "Open", action: #selector(NSDocumentController.openDocument(_:)), keyEquivalent: "o"), NSMenuItem.separator(), NSMenuItem(title: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w"), NSMenuItem(title: "Save", action: #selector(NSDocument.save(_:)), keyEquivalent: "s") ] // Edit Menu let editMenu = NSMenuItem() editMenu.submenu = NSMenu(title: "Edit") editMenu.submenu?.items = [ NSMenuItem(title: "Undo", action: Selector(("undo:")), keyEquivalent: "z"), NSMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") ] items = [appMenu, fileMenu, editMenu] } required init(coder: NSCoder) { super.init(coder: coder) } }

to hook it up edit main.swift:

let menu = Menu() app.menu = menu

Enable open and save

add this to Document.swift:

override func data(ofType typeName: String) throws -> Data { viewController.textView.breakUndoCoalescing() return content.data() } override func read(from data: Data, ofType typeName: String) throws { content.read(from: data) }

and these to tell the system that we handle normal text-files:

override class var readableTypes: [String] { ["public.text"] } override class func isNativeType(_ type: String) -> Bool { true }

Magic sauce!

At this point you cannot select standard text files to open 😳 The Document class clearly says that is is able to read "public.text". But we need to make the complete class visible to the Objc runtime.

@objc(Document) class Documnt: NSDocument {...}

Thanks to the guys at objc.io we now have a working text editor.

Makefile

Derik Ramirez provided me with a simple Makefile:

SUPPORTFILES=./SupportFiles PLATFORM=x86_64-apple-macosx BUILD_DIRECTORY = ./.build/${PLATFORM}/debug APP_DIRECTORY=./Scratched.app CFBUNDLEEXECUTABLE=Scratched install: build copySupportFiles build: swift build copySupportFiles: mkdir -p ${APP_DIRECTORY}/Contents/MacOS/ && \ cp ${SUPPORTFILES}/Info.plist ${APP_DIRECTORY}/Contents && \ cp ${BUILD_DIRECTORY}/${CFBUNDLEEXECUTABLE} ${APP_DIRECTORY}/Contents/MacOS/ run: | install open ${APP_DIRECTORY} clean: rm -rf .build rm -rf ${APP_DIRECTORY} .PHONY: run build copySupportFiles clean

For this to work you need to create

mkdir SupportFiles touch SupportFiles/Info.plist

And add the following content:

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict /> </plist>

Now you can run the app via

make run