How to provide file icon thumbnails in macOS

Update My PreviewMarkdown app, which provides Markdown file previews and icon thumbnails in Catalins, is now available from the Mac App Store.

Providing content-based icon thumbnails in macOS Catalina follows the same pattern as generating file previews: QuickLook runs code from an app extension and calls a function within that code to draw the image that will be placed on the icon.

thumbs
Markdown file previews in Catalina, courtesy of PreviewMarkdown

To add an extension to an app, just add a new target to the app’s Xcode project. Xcode has templates for both QuickLook Preview and Thumbnail extensions, and selecting either of these provides you with the core code resources you need. In this post, I’ll focus on thumbnailing; this post covers previews.

The template contains a file called ThumbnailProvider.swift which includes a class called ThumbnailProvider, which is a sub-class of QLThumbnailProvider and includes a single function, provideThumbnail(). This is the function called by QuickLook when, for example, Finder asks for an icon image to present.

provideThumbnail() has two parameters. The first, for, receives a QLFileThumbnailRequest instance which provides information about the file to be thumbnailed. This instance can be accessed by your code as the variable request. The second parameter receives a block of code which your own code calls as a function, called handler(), when it has prepared the thumbnail (or it was unable to do so).

handler() expects to be provided with two arguments. The first is a QLThumbnailReply instance, the second nil or an Error if you couldn’t generate the thumbnail for some reason.

Typically, then, your code will load in the file located at the URL stored in the request’s fileURL property. You then render the content as an image, which your drawing code, supplied via the QLThumbnailReply instance, draws into the current graphics context.

This is easier to show with an example than to describe, so here is the business part of own of my thumbnailers. You can see the full code here:

do {
    // Read in the markdown from the specified file
    let markdownString: String = try String(contentsOf: intent.url, encoding: String.Encoding.utf8)     
    
    // Set the thumbnail frame     
    var thumbnailFrame: CGRect = .zero     
    thumbnailFrame.size = request.maximumSize     
    thumbnailFrame.size.width = 0.75 * thumbnailFrame.size.height     

    // Set the drawing frame and a base font size
    let drawFrame: CGRect = CGRect.init(x: 0.0, y: 0.0, width: 768, height: 1024.00)
    let fontSize: CGFloat = 14.0

    // Instantiate an NSTextView to display the NSAttributedString render of the markdown
    let tv: NSTextView = NSTextView.init(frame: drawFrame)
    tv.backgroundColor = NSColor.white
    if let tvs: NSTextStorage = tv.textStorage {
        let sm: SwiftyMarkdown = SwiftyMarkdown.init(string: "")
        self.setBaseValues(sm, fontSize)
        tvs.setAttributedString(sm.attributedString(markdownString))
    }
     
    let imageRep: NSBitmapImageRep? = tv.bitmapImageRepForCachingDisplay(in: drawFrame)
        if imageRep != nil {
            tv.cacheDisplay(in: drawFrame, to: imageRep!)
        }

    let reply: QLThumbnailReply = QLThumbnailReply.init(contextSize: thumbnailFrame.size) { () -> Bool in
        // This is the drawing block. It returns true (thumbnail drawn into current context)
        // or false (thumbnail not drawn)
        if imageRep != nil {
            let _ = imageRep!.draw(in: thumbnailFrame)
            return true
        }
        
        // We didn't draw anything
        return false
    }
    
    // Hand control back to QuickLook, supplying the QLThumbnailReply instance and no error
    handler(reply, nil)
 } catch {
    handler(nil, nil)
}

The do… catch structure is included if there’s a problem reading a markdown file in the second line. The code then specifies a frame based on dimensions included in the QLFileThumbnailRequest instance’s properties. Icon files have an aspect ratio that’s roughly 3:4, which is why we set the thumbnail width to 0.75 times its height. We set the (larger) frame that we’ll create a view with to the same aspect ratio for easy scaling.

The code instantiates an NSTextView object and then sets that instance’s content to be an NSAttributedString assembled from the loaded markdown, in this case using code derived from the iOS-oriented SwiftyMarkdown library. We then generate a bitmap graphic (as an NSBitmapImageRep) from the NSTextView.

Next we create the QLThumbnailReply that we’ll return to QuickLook via the function referenced by handler. QLThumbnailReply has a selection of three initializers you can choose from: one that assumes you’ll be drawing the current graphics context, one that provides you with a context to draw in, and one that doesn’t use contexts but rather expects you to provide the URL of a graphics file on disk. My code uses the former: it draws the NSBitmapImageRep into the thumbnail-sized frame we calculated earlier, then returns true to indicate the thumbnail was drawn.

Finally, we call handler(), passing in the QLThumbnailReply instance and nil (to indicate there was no error getting the file data).

So what happens at runtime?

  1. QuickLook instantiates a ThumbnailProvider object and calls its provideThumbnail() function.
  2. provideThumbnail() loads and renders a bitmap image.
  3. provideThumbnail() creates a QLThumbnailReply instance and passes in a closure containing drawing code.
  4. provideThumbnail() hands control back to QuickLook and hands it the QLThumbnailReply instance.
  5. QuickLook uses the QLThumbnailReply instance to run your drawing code.

A closure is combination of function and state data, so when QuickLook runs the drawing code, the drawing code has access to the all data you generated in provideThumbnail(). This is important because you don’t know when your drawing code will be called. There’s a lot of clever memory management going on behind the scenes to ensure that, for example, the NSBitmapImageRep is kept available for the drawing code to (eventually) use.

Again this is all highly asynchronous, which makes debugging hard. Don’t expect to do this through Xcode; NSLog() is your friend here.

As we saw with the file preview app extension, you also need to set the Thumbnailer’s info.plist to indicate what file types your extension can work with. These types are specified as URIs. The plist already has an NSExtension entry with an NSExtensionAttributes dictionary. One of the dictionary entries is QLSupportedContentTypes, which has an array value: double-click this value (“0 items”) to begin adding strings for each of your app’s UTIs.

If you’re running Catalina, you can try this out by downloading and installing my app PreviewMarkdown, which you can find here. The source code is available over at GitHub.