index all rss twitter github linkedin email

Álvaro Ramírez

23 May 2020 Preview SwiftUI layouts using Emacs org blocks

ob-swiftui.gif

Chris Eidhof twitted a handy snippet that enables quickly bootstrapping throwaway SwiftUI code. It can be easily integrated into other tools for rapid experimentation.

Being a SwiftUI noob, I could use some SwiftUI integration with my editor of choice. With some elisp glue and a small patch, Chris's snippet can be used to generate SwiftUI inline previews using Emacs org babel. This is particularly handy for playing around with SwiftUI layouts.

We can piggyback ride off zweifisch's ob-swift by advicing org-babel-execute:swift to inject the org source block into the bootstrapping snippet. We also add a hook to org-babel-after-execute-hook to automatically refresh the inline preview.

If you're a use-package user, the following snippet should make things fairly self-contained (if you have melpa set up already).

Note: I like pressing C-c C-c to execute source blocks, so it's bound in the following snippet. I've also enabled org-display-inline-images when loading org files.

(use-package org
  :hook ((org-mode . org-display-inline-images))
  :config

  (use-package ob
    :bind (:map org-mode-map
                ("C-c C-c" . org-ctrl-c-ctrl-c))
    :config

    (use-package ob-swift
      :ensure t
      :config
      (org-babel-do-load-languages 'org-babel-load-languages
                                   (append org-babel-load-languages
                                           '((swift     . t))))

      (defun ar/org-refresh-inline-images ()
        (when org-inline-image-overlays
          (org-redisplay-inline-images)))

      ;; Automatically refresh inline images.
      (add-hook 'org-babel-after-execute-hook 'ar/org-refresh-inline-images)

      (defun adviced:org-babel-execute:swift (f &rest args)
        "Advice `adviced:org-babel-execute:swift' enabling swiftui header param."
        (let* ((body (nth 0 args))
               (params (nth 1 args))
               (swiftui (cdr (assoc :swiftui params)))
               (output))
          (when swiftui
            (assert (or (string-equal swiftui "preview")
                        (string-equal swiftui "interactive"))
                    nil ":swiftui must be either preview or interactive")
            (setq body (format
                        "
import Cocoa
import SwiftUI
import Foundation

let screenshotURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString + \".png\")
let preview = %s

NSApplication.shared.run {
  %s
}

extension NSApplication {
  public func run<V: View>(@ViewBuilder view: () -> V) {
    let appDelegate = AppDelegate(view())
    NSApp.setActivationPolicy(.regular)
    mainMenu = customMenu
    delegate = appDelegate
    run()
  }
}

extension NSApplication {
  var customMenu: NSMenu {
    let appMenu = NSMenuItem()
    appMenu.submenu = NSMenu()

    let quitItem = NSMenuItem(
      title: \"Quit \(ProcessInfo.processInfo.processName)\",
      action: #selector(NSApplication.terminate(_:)), keyEquivalent: \"q\")
    quitItem.keyEquivalentModifierMask = []
    appMenu.submenu?.addItem(quitItem)

    let mainMenu = NSMenu(title: \"Main Menu\")
    mainMenu.addItem(appMenu)
    return mainMenu
  }
}

class AppDelegate<V: View>: NSObject, NSApplicationDelegate, NSWindowDelegate {
  var window = NSWindow(
    contentRect: NSRect(x: 0, y: 0, width: 414 * 0.2, height: 896 * 0.2),
    styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
    backing: .buffered, defer: false)

  var contentView: V

  init(_ contentView: V) {
    self.contentView = contentView
  }

  func applicationDidFinishLaunching(_ notification: Notification) {
    window.delegate = self
    window.center()
    window.contentView = NSHostingView(rootView: contentView)
    window.makeKeyAndOrderFront(nil)

    if preview {
      screenshot(view: window.contentView!, saveTo: screenshotURL)
      // Write path (without newline) so org babel can parse it.
      print(screenshotURL.path, terminator: \"\")
      NSApplication.shared.terminate(self)
      return
    }

    window.setFrameAutosaveName(\"Main Window\")
    NSApp.activate(ignoringOtherApps: true)
  }
}

func screenshot(view: NSView, saveTo fileURL: URL) {
  let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds)!
  view.cacheDisplay(in: view.bounds, to: rep)
  let pngData = rep.representation(using: .png, properties: [:])
  try! pngData?.write(to: fileURL)
}
"
                        (if (string-equal swiftui "preview")
                            "true"
                          "false")
                        body))
            (setq args (list body params)))
          (setq output (apply f args))
          (when org-inline-image-overlays
            (org-redisplay-inline-images))
          output))

      (advice-add #'org-babel-execute:swift
                  :around
                  #'adviced:org-babel-execute:swift))))

Snippet also at github gist and included in my emacs config.

Once the snippet is evaluated, we're ready to use in an org babel block. We introduced the :swiftui header param to switch between inline static preview and interactive mode.

To try out an inline preview, create a new org file (eg. swiftui.org) and a source block like:

#+begin_src swift :results file :swiftui preview
  VStack(spacing: 10) {
      HStack(spacing: 10) {
        Rectangle().fill(Color.yellow)
        Rectangle().fill(Color.green)
      }
      Rectangle().fill(Color.blue)
      HStack(spacing: 10) {
        Rectangle().fill(Color.green)
        Rectangle().fill(Color.yellow)
      }
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
#+end_src
#+results:

vstack.jpg

Place the cursor anywhere inside the source block (#+begin_src/#+end_src) and press C-c C-c (or M-x org-ctrl-c-ctrl-c).

To run interactively, change the :swiftui param to interactive and press C-c C-c (or M-x org-ctrl-c-ctrl-c). When running interactively, press "q" (without ⌘) to quit the Swift app.

comments on twitter.

Update

  • Tweaked the snippet to make it more self-contained and made the steps more reproducible. Need to work out how to package things to make them more accessible. May be best to contribute as a patch to ob-swift and we can avoid the icky advice-add.
  • Thanks to Chris Eidhof for PNG support (instead of TIFF). Also TIL Swift's print has got a terminator param.