Presenting UI with Swift

During the Swift Interoperability tutorial, we created a simple plugin that calls a Swift function. However, that function did no more than showing a modal alert. In a real plugin, you might want to show a window with a custom layout and call Marta APIs when the user interacts with the widgets.

In this tutorial, we will create a dialog that asks the user’s name. Firstly, we will implement it as a sheet, and then as a non-modal window. We will reuse the project we set up during the Swift Interoperability tutorial.

Lua part

First of all, let’s start with the Lua part. Open init.lua and add the new action:

action {
    id = "greet",
    name = "Greet User",
    apply = function(context)
        local closure = function(name)
            if name then 
                martax.alert("Hello, " .. name .. "!") 
            end
        end

        interop.ask_name(context.window.nsWindow, closure)
    end
}

Here we call ask_name() defined in our Swift library, passing a reference to the Cocoa NSWindow and a callback. The Swift part is supposed to show the UI and call the callback as the user inputs a string.

Working with callbacks

Currently, the Swift library exposes one function (helloWorld()). Let’s define one more:

private let askName: lua_CFunction = { L in
    return 0
}

🐧 Do not forget to register the function in luaopen_libswiftinterop().

At this stage, we can compile the library and test the new action. There will not be any visible feedback, though, as we do not invoke the callback function.

In our action, we need to save the callback somehow and call it later when the user inputs their name. However, as the callback comes from Lua, we cannot get a C pointer to it. Instead, we can save it in the registry.

In the following example, we save the function and call it after two seconds:

private let askName: lua_CFunction = { L in
    guard lua_type(L, 2) == LUA_TFUNCTION else {
        luaL_argerror(L, 2, "Second argument should be a function")
        return 0
    }

    let closureRef = luaL_ref(L, LUA_REGISTRYINDEX_COMPAT)

    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        lua_rawgeti(L, LUA_REGISTRYINDEX_COMPAT, Int64(closureRef))
        luaL_unref(L, LUA_REGISTRYINDEX_COMPAT, closureRef)
        lua_pushstring(L, "Mary")
        lua_callk(L, 1, 0, 0, nil)
    }
}

⚠️ Use Lua API only from the main application thread (Thread.isMainThread).

⚠️ Make sure you clean up values in the registry by calling luaL_unref() to avoid memory leaks.

If you write the plugin in C or Objective-C, you can use the LUA_REGISTRYINDEX macro. However, it is unavailable in Swift, so we need to declare an accessor constant.

Add the constant to swiftinterop-Bridging-Header.h:

const int LUA_REGISTRYINDEX_COMPAT = LUA_REGISTRYINDEX;

Now you should be able to compile the library. Note that the alert appears a couple of seconds after you run the action.

Let’s modify our function so it calls the showNameDialog() function instead. We use Unmanaged to convert a raw reference to the NSWindow instance.

private func showNameDialog(ownerWindow: NSWindow, completion: @escaping (String?) -> Void) {
    // Not implemented yet
}

private let askName: lua_CFunction = { L in
    guard lua_type(L, 1) == LUA_TLIGHTUSERDATA, let nsWindowPtr = lua_touserdata(L, 1) else {
        luaL_argerror(L, 1, "First argument should be a NSWindow reference")
        return 0
    }

    let ownerWindow = Unmanaged<NSWindow>.fromOpaque(nsWindowPtr).takeUnretainedValue()

    guard lua_type(L, 2) == LUA_TFUNCTION else {
        luaL_argerror(L, 2, "Second argument should be a function")
        return 0
    }

    let closureRef = luaL_ref(L, LUA_REGISTRYINDEX_COMPAT)

    showNameDialog(ownerWindow: ownerWindow) { name in
        lua_rawgeti(L, LUA_REGISTRYINDEX_COMPAT, Int64(closureRef))
        luaL_unref(L, LUA_REGISTRYINDEX_COMPAT, closureRef)
        if let name = name {
            lua_pushstring(L, name)
        } else {
            lua_pushnil(L)
        }
        lua_callk(L, 1, 0, 0, nil)
    }

    return 0
}

In the following sections, we will implement showNameDialog() in two different ways.

Sheet implementation

The first implementation shows a sheet. While the sheet is on the screen, it takes control, and you cannot interact with its owner. Marta uses sheets in many actions such as Copy or New Folder.

First of all, we need to create a UI for our window. It will have a text field and two buttons – “OK” and “Cancel”.

Plugin UI

Let’s incapsulate our UI in a view controller. The implementation is deliberately simple, yet it shows the idea.

class UserNameViewController: NSViewController {
    private weak var textField: NSTextField?

    var completion: ((String?) -> Void)?

    override func loadView() {
        view = NSView()
        view.translatesAutoresizingMaskIntoConstraints = false

        let textField = NSTextField()
        textField.translatesAutoresizingMaskIntoConstraints = false
        textField.placeholderString = "Name"
        self.textField = textField

        let okButton = NSButton(title: "OK", target: self, action: #selector(onOkButtonPressed))
        okButton.keyEquivalent = "\r"
        okButton.translatesAutoresizingMaskIntoConstraints = false

        let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(onCancelButtonPressed))
        cancelButton.keyEquivalent = "\u{1b}"
        cancelButton.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(textField)
        view.addSubview(okButton)
        view.addSubview(cancelButton)

        let views = ["textField": textField, "okButton": okButton, "cancelButton": cancelButton]
        func addConstraints(_ format: String) {
            view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: format, metrics: nil, views: views))
        }

        addConstraints("H:[textField(>=200)]")
        addConstraints("H:|-10-[textField]-10-|")
        addConstraints("V:|-10-[textField]")
        addConstraints("H:[okButton]-10-[cancelButton]-10-|")
        addConstraints("V:[textField]-10-[okButton]-10-|")
        addConstraints("V:[textField]-10-[cancelButton]-10-|")
    }

    @objc
    private func onOkButtonPressed() {
        let completion = self.completion, result = textField?.stringValue
        DispatchQueue.main.async { completion?(result) }

        closeWindow()
    }

    @objc
    private func onCancelButtonPressed() {
        closeWindow()
    }

    private func closeWindow() {
        guard let sheetWindow = view.window else { return }
        sheetWindow.sheetParent?.endSheet(sheetWindow)
    }
}

Our view controller holds a callback that we call if the user presses the “OK” button. On pressing “Cancel”, we just close the window.

Implementation for showNameDialog() is rather trivial:

private func showNameDialog(ownerWindow: NSWindow, completion: @escaping (String?) -> Void) {
    let viewController = UserNameViewController()
    viewController.title = "What's your name?"
    viewController.completion = completion

    let sheetWindow = NSWindow(contentViewController: viewController)
    ownerWindow.beginSheet(sheetWindow)
}

Here we initialize the view controller, then make a window using it, and finally show the window as a sheet.

After you added code from listings above, you can build the library and test it.

Sheet implementation

Stand-alone window implementation

Use stand-alone windows to give the user simultaneous access to the file list and your UI. For instance, Marta uses stand-alone windows for actions such as Preferences and Tutorial.

As macOS does not set the window position for us, we need to tune it by ourselves:

private func showNameDialog(ownerWindow: NSWindow, completion: @escaping (String?) -> Void) {
    let viewController = UserNameViewController()
    viewController.title = "What's your name?"
    viewController.completion = completion

    let window = NSWindow(contentViewController: viewController)
    window.styleMask = .titled

    let ownerWindowCenter = NSPoint(
        x: ownerWindow.frame.midX - window.frame.width / 2,
        y: ownerWindow.frame.midY - window.frame.height / 2
    )

    window.setFrameOrigin(ownerWindowCenter)

    let windowController = NSWindowController(window: window)
    keepWindow(windowController: windowController)
    windowController.showWindow(nil)
}

Also, now we do not have an owner which keeps an instance to our window. Unless we store it in some place, the reference counter will destroy our window immediately:

private var keptWindows: Set<NSWindowController> = []

private func keepWindow(windowController: NSWindowController) {
    guard let window = windowController.window else { preconditionFailure("Window must be set") }
    keptWindows.insert(windowController)

    NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification, object: window, queue: .main) { notification in
        if let window = notification.object as? NSWindow, let windowController = window.windowController {
            keptWindows.remove(windowController)
        }
    }
}

Finally, we need to modify the implementation of UserNameViewController.closeWindow():

private func closeWindow() {
    view.window?.close()
}

Modify showNameDialog() and closeWindow(), and add keepWindow() to plugin.swift. Now you can recompile the library and test the action once more.

Stand-alone window implementation