Terminal Palettes

September 3, 2017

For some recent work, I was switching between OmniFocus and OmniPlan code often. To keep my source directories straight in my head, I tweaked the colors of the two Terminal tabs to use the apps’ branding fonts: purple text for OmniFocus; yellow text for OmniPlan. I mentioned this hack in an engineering meeting and Ken promptly dropped the nerd snipe: “Does it switch automatically?”

It didn’t then. Now it does.

With a three part solution — AppleScript, shell script, and a zsh hook — my Terminal palette now updates whenever I switch between source directories. Yours can too.

ZSH Hook

I added the following code to my .zshrc file:

if [[ "$TERM_PROGRAM" == "Apple_Terminal" ]] && [[ -z "$INSIDE_EMACS" ]]; then

    update_terminal_cwd() {
        # Identify the directory using a "file:" scheme URL, including
        # the host name to disambiguate local vs. remote paths.

        # Percent-encode the pathname.
        # http://superuser.com/questions/313650/resume-zsh-terminal-os-x-lion/328148#328148
        local URL_PATH=''
        {
            # Use LANG=C to process text byte-by-byte.
            local i ch hexch LANG=C
            for ((i = 1; i <= ${#PWD}; ++i)); do
                ch="$PWD[i]"
                if [[ "$ch" =~ [/._~A-Za-z0-9-] ]]; then
                    URL_PATH+="$ch"
                else
                    hexch=$(printf "%02X" "'$ch")
                    URL_PATH+="%$hexch"
                fi
            done
        }

        local PWD_URL="file://$HOST$URL_PATH"
        #echo "$PWD_URL"        # testing
        print -Pn "\e]0;%n@%m: %~\a" # get the tab title right: http://www.tldp.org/HOWTO/Xterm-Title-4.html#ss4.1
        printf '\e]7;%s\a' "$PWD_URL" # get path restore right
        ~/bin/terminalPalette.sh "$PWD_URL" # update colors
    }

    # Register the function so it is called whenever the working
    # directory changes.
    autoload add-zsh-hook
    add-zsh-hook chpwd update_terminal_cwd

    # Tell the terminal about the initial directory.
    update_terminal_cwd
fi

This code first declares a local function, update_terminal_cwd(), then hooks the shell directory change command to run the function. Finally it calls the function so that a new terminal window will trigger the code as well.

Inside the function, we percent-encode the current working directory. Then we set the tab title, save the working directory so it restores on the next launch of Terminal, and finally call the terminalPalette.sh script to update the color palette.

Shell Script

The terminalPalette.sh script lives in the bin subdirectory of my home directory.

The script starts with a fairly standard preamble like so:

#!/bin/zsh

func usage() {
        echo "Usage:"
        echo "${0} (<file url>|<settings name>)"
}

PROFILE=${1}

if [[ -z ${PROFILE} ]] ; then
        usage
        exit 1
fi

The script takes a single argument. That argument can either be a file URL, like

terminalPalette.sh file://localhost/Users/curt/bin

or the name of a Terminal.app profile, like

terminalPalette.sh "Man Page"

The preamble just checks for any single argument and reports the correct usage if the argument is missing.

Next, we bridge the shell argument into AppleScript so we can drive Terminal.app:

osascript - `tty` ${PROFILE} <<SCRIPT

Invoked with - as the first argument, osascript will run a script passed in on standard input, feeding the subsequent arguments to the embedded script. Here we pass the current tty — we’ll see why shortly — and the original argument. Then we begin a here doc with the <<SCRIPT incantation. Until the end of the here doc, we’re coding in AppleScript:

on run argv
    set theTTY to item 1 of argv
    set settingsNameOrPath to item 2 of argv
    if settingsNameOrPath starts with "file://" then
        set settingsName to my settingsNameForPath(settingsNameOrPath)
    else
        set settingsName to settingsNameOrPath
    end if

    if settingsName is missing value then return

    tell application "Terminal"
        set desiredSettings to first settings set whose name is settingsName
        if desiredSettings is missing value then return
        repeat with matchingTab in my tabForTTY(theTTY)
            set current settings of matchingTab to desiredSettings
        end repeat
    end tell
end run

The run handler extracts the tty and the original script’s argument into theTTY and settingsNameOrPath respectively. Then it detects what sort of argument was passed to the original script and converts that to a Terminal app settings name. (In grand AppleScript tradition, the UI for Terminal.app calls its color palettes “Profiles” but the AppleScript dictionary calls them “Settings”.) If the argument was a file URL, we call a handler settingsNameForPath — see below — to get the settings name. Otherwise we use it directly.

Then we drive the Terminal app to look up the desired settings, find the tab that is rendering the current tty, and change the settings of that tab to the desired ones.

Now we just need to define a couple of helpers.

on settingsNameForPath(thePath)
    if thePath ends with "SourceDirs" then return "Man Page"
    if thePath contains "SourceDirs/OmniFocus" then return "OmniFocus"
    if thePath contains "SourceDirs/OmniCurt" then return "OmniCurt"
    if thePath contains "SourceDirs/OmniPlan" then return "OmniPlan"
    if thePath contains "SourceDirs/curtclifton.net" then return "curtclifton.net"
    if thePath contains "SourceDirs" then return "Ocean"
    return missing value
end settingsNameForPath

The settingsNameForPath handler maps from the path argument to custom palette names. If you’re setting up this script for your own use, you’ll want to tweak this handler and your Terminal profiles. I added some app and project specific profiles, like OmniFocus and curtclifton.net. So far I’ve just been tweaking the handler as I add more profiles, though someday it would be nice to make this do a bit more parsing to avoid the repetitive if statements.

on tabForTTY(theTTY)
    tell application "Terminal"
        repeat with aWindow in every window
            repeat with aTab in every tab of aWindow
                set currentTTY to tty of aTab
                if currentTTY is theTTY then
                    return {aTab}
                end if
            end repeat
        end repeat
        return {}
    end tell
end tabForTTY

This last handler iterates through every Terminal tab looking for the one rendering the script’s tty. This might become too slow with a lot of tabs open, but so far it’s been fine with my usual four or five tabs at once.

Finally, we end the here doc and the script:

SCRIPT

This was a fun bit of hackery. I hope you enjoyed it too. You can download the terminalPalette.sh script in its entirety here.


Swift by Northwest Schedule Posted

August 28, 2017

Swift by Northwest has posted their preliminary schedule. The conference will be single-track with some great speakers. I love this format. Single-track conferences always feel more intimate because everyone shares the experience.

The conference is Oct. 27–28 in beautiful Seattle. If you register this week, you can still get the early bird discount.

I hope to see you there!


Swift by Northwest Plans

July 30, 2017

I’m excited to be speaking at Swift by Northwest in October in sunny Seattle. I just put the finishing touches on the abstract for my session:

These are a Few of My Stateful Machines

As app developers we have to deal with the challenges of stateful code every day. State machines are a powerful technique for managing state — but they’re underused in Mac and iOS development, in part because they can be awkward to implement in Objective-C. Swift changes that. Swift’s smart enums make implementing state machines quick, easy, and fun. In this talk we’ll look at how you can make your apps easier to write, debug, and evolve using state machines in Swift. Along the way, we’ll explore the power of enums, including associated values, methods, and properties.

Swift’s destructuring switch statements over smart enums are one of my favorite parts of the language. This session should be great fun to lead. I’ll do my best to make it fun to participate in as well.

Swift by Northwest is shaping up to be a great conference. Some of my favorite people in the Cocoa community are presenting, and Seattle is my favorite city. Early bird registration is open now. I hope you’ll be there!


Version 1.2 of Export OmniFocus View to OmniOutliner

June 25, 2017

Now available, version 1.2 of my Export View to OmniOutliner script. The script makes a new OmniOutliner document from your current view in OmniFocus.

With Version 1.2 you can now export from the Forecast perspective in OmniFocus. Due to a limitation in OmniFocus, the export from Forecast will have empty group titles in OmniOutliner. The tasks are exported correctly, however. (I hope to fix this limitation in a future release of OmniFocus.)

This version of the export script also better handles notes with embedded links.

Installing the Script

To install the script, download the latest version here. Then in OmniFocus, choose Help → Open Scripts Folder. Drag the Export View to OmniOutliner file into the scripts folder. Finally, use Customize Toolbar to add the script to the toolbar in OmniFocus.

Running the Script

Once you’ve installed the script, navigate to whatever perspective you’d like to export. You can even Focus on a particular project, or use View Options (⇧⌘V) to fine tune the display. Once the view in OmniFocus is showing just what you want, run Export View to OmniOutliner from your OmniFocus toolbar. OmniOutliner will launch and the script will create a new document containing just the information in your current OmniFocus view.

Only the titles, notes, and structure are exported from OmniFocus. The OmniOutliner document won’t contain contexts or other information, like defer and due dates, from OmniFocus. I find this is a handy way to get a summary of a project or a task list into OmniOutliner. From there, I can print to PDF to share with others without exposing the details of my own OmniFocus database.

Share and enjoy!

Drop me a note @curtclifton on Twitter if there’s something else you’d like to see automated in OmniFocus.


Appearance Manager

May 6, 2017

In my previous posts on fonts we looked at:

Today I want to share a utility class, AppearanceManager, that works with my UIFont extension and iOS’ appearance proxies to manage all the fonts in an app.

After Font with Style we had a system where we could update the font of a UI element with a call like this:

titleLabel.font = titleLabel.font.appFontWithSameStyle()

Unfortunately, we had to manually call that code for every UI element in every view. This also meant that we needed outlets for every UI element, even if we didn’t do anything else with it. Nearly as bad, every view controller had to override traitCollectionDidChange to update the fonts again in response to the user changing their system preferred content size.

AppearanceManager and a few small extensions on UIKit classes solve all three of these problems. I’ll discuss them in-line in this post; the final code is available here.

UIFont Tweaks

First I extended UIFont with a few more convenience methods:

extension UIFont {
    

    var smallCapsVariant: UIFont {
        let smallCapsFontDescriptor = fontDescriptor.addingAttributes(
            [
                UIFontDescriptorFeatureSettingsAttribute: [
                    [UIFontFeatureTypeIdentifierKey: kLowerCaseType, UIFontFeatureSelectorIdentifierKey: kLowerCaseSmallCapsSelector],
                ]
            ]
        )

        let smallCapsFont = UIFont(descriptor: smallCapsFontDescriptor, size: 0)
        return smallCapsFont
    }

    class func appFont(ofSize size: CGFloat) -> UIFont {
        let font = UIFont(name: "MuseoSlab-500", size: size)
        return font ?? systemFont(ofSize: size)
    }

    class var navigationItemFont: UIFont {
        return appFont(ofSize: 17)
    }

    class var navigationBarTitleFont: UIFont {
        return UIFont.appFont(for: .headline)
    }

    class var buttonFont: UIFont {
        return appFont(ofSize: 17)
    }
}

Appearance­Manager­Updatable Protocol

Then I wrote a bunch of little extensions on UIKit classes. The idea is to add an API to UIViewController and UIView to allow recursively walking the entire view hierarchy of an app, updating fonts as we go.

This API has two methods, collected in a protocol:

protocol AppearanceManagerUpdatable {
    /// Perform a local update of the fonts in this object.
    func updateFontsForContentSizeChange()

    /// Recursively update the fonts in the children of this object.
    func recursivelyUpdateFontsForContentSize()
}

For UIViews, we want to update actual fonts. For UIViewControllers, we want to bounce the messages to the view hierarchy.

Extending UIViews

Let’s look at the UIView extensions first, since they’re easier to understand. The default extension does nothing for fonts, since not every UIView has a font, but it handles the recursion:

extension UIView: AppearanceManagerUpdatable {
    /// Default implementation does nothing.
    ///
    /// Subclasses with font attributes should override to update those fonts.
    func updateFontsForContentSizeChange() {
        // no-op
    }

    /// Calls `recursivelyUpdateFontsForContentSize()` recursively on all subviews, then calls `updateFontsForContentSizeChange()` on self.
    func recursivelyUpdateFontsForContentSize() {
        for view in subviews {
            view.recursivelyUpdateFontsForContentSize()
        }
        updateFontsForContentSizeChange()
    }
}

Next we extend the basic font-bearing UI elements to update their fonts:

extension UILabel {
    override func updateFontsForContentSizeChange() {
        font = font.appFontWithSameStyle()
    }
}

extension UITextField {
    override func updateFontsForContentSizeChange() {
        guard let currentFont = font else {
            return
        }
        font = currentFont.appFontWithSameStyle()
    }
}

extension UITextView {
    override func updateFontsForContentSizeChange() {
        guard let currentFont = font else {
            return
        }
        font = currentFont.appFontWithSameStyle()
    }
}

This brute-force updating makes some UIKit views unhappy. For example, UITabBar embeds private UILabels. You end up with a mess if you change them. So, this extension on UITabBar keeps our recursion from mucking with it:

extension UITabBar {
    override func recursivelyUpdateFontsForContentSize() {
        // Leave tab bar untouched. It doesn't like having subviews twiddled.
    }
}

In my app, I wanted to update the navigation bar title and button fonts specially, so added extensions for them as well:

extension UINavigationBar {
    override func updateFontsForContentSizeChange() {
        titleTextAttributes = [NSFontAttributeName: UIFont.navigationBarTitleFont]
    }

    override func recursivelyUpdateFontsForContentSize() {
        // Leave navigation bar subviews untouched. It doesn't like having subviews twiddled. However, still update self.
        updateFontsForContentSizeChange()
    }
}

extension UIButton {
    override func updateFontsForContentSizeChange() {
        titleLabel?.font = titleLabel!.font.smallCapsVariant
    }

    override func recursivelyUpdateFontsForContentSize() {
        // Leave button subviews untouched. However, still update self.
        updateFontsForContentSizeChange()
    }
}

You might need to tweak these extensions, or add others, if your app uses different views. That’s the primary reason I haven’t tried to make a micro-framework from this technique. Extending it to cover every app would obscure the ideas.

Extending UIViewControllers

Now that we have our views ready to update, let’s extend our UIViewControllers to play along. First, UIViewController itself gets an extension to hand off updates to its view and child view controllers:

extension UIViewController: AppearanceManagerUpdatable {
    /// Default implementation calls `updateFontsForContentSizeChange()` on every view in the view hierarchy rooted at `self.viewIfLoaded`.
    func updateFontsForContentSizeChange() {
        viewIfLoaded?.recursivelyUpdateFontsForContentSize()
    }

    /// Calls `recursivelyUpdateFontsForContentSize()` recursively on all child view controllers, then calls `updateFontsForContentSizeChange()` on self.
    func recursivelyUpdateFontsForContentSize() {
        for viewController in childViewControllers {
            viewController.recursivelyUpdateFontsForContentSize()
        }
        updateFontsForContentSizeChange()
    }
}

Then collection views and table views swing a big hammer to update their views. We just have them do a full reloadData. This seems OK to me, since a user is unlikely to change their preferred content size frequently.

extension UITableViewController {
    override func updateFontsForContentSizeChange() {
        tableView.reloadData()
    }
}

extension UICollectionViewController {
    override func updateFontsForContentSizeChange() {
        collectionView?.reloadData()
    }
}

Extending Custom Classes

Having these hooks on views and view controllers is handy for custom layout. Most of my views just update automatically using this code and autolayout, but in one of my table view cells I want to update a constraint based on font size. I can do that by overriding updateFontsForContentSizeChange like so:

override func updateFontsForContentSizeChange() {
    super.updateFontsForContentSizeChange()

    guard let bodyFont = body.font else {
        assertionFailure("body.font not set")
        return
    }

    let position = bodyFont.capHeight / 2
    completionButtonCenteringConstraint.constant = round(position)
    containerView.setNeedsLayout()
}

Wiring It All Together

The AppearanceManager class triggers the automatic update process. It’s a concise 20 lines of code. The appearance manager holds a reference to the app’s root view controller, watches for content size notifications, and sends font update messages down the view controller hiearchy.

/// Controls app-wide appearance.
///
/// Responsible for updating appearance proxies and passing font update messages down the view controller and view hierarchies when the system font size changes.
class AppearanceManager: NSObject {
    private weak var rootViewController: UIViewController?

    init(rootViewController: UIViewController) {
        self.rootViewController = rootViewController

        super.init()
        UIBarButtonItem.appearance().setTitleTextAttributes([NSFontAttributeName: UIFont.navigationItemFont], for: [.normal])
        UINavigationBar.appearance().titleTextAttributes = [NSFontAttributeName: UIFont.navigationBarTitleFont]

        NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange(_:)), name: .UIContentSizeCategoryDidChange, object: nil)
    }

    dynamic private func contentSizeCategoryDidChange(_ notification: Notification) {
        rootViewController?.recursivelyUpdateFontsForContentSize()
    }
}

My app delegate’s application(_:, willFinishLaunchingWithOptions:) method loads the app’s custom fonts and instantiates an AppearanceManager instance:

UIFont.loadAppFonts()
appearanceManager = AppearanceManager(rootViewController: window.rootViewController!)

In each of my UIViewController subclasses, I add a single line to the viewDidLoad() method:

override func viewDidLoad() {
    
    recursivelyUpdateFontsForContentSize()
}

Finally, for any custom views, such as table view or collection view cells, I update fonts on load and reuse:

override func awakeFromNib() {
            
    recursivelyUpdateFontsForContentSize()
}

override func prepareForReuse() {
    
    recursivelyUpdateFontsForContentSize()
}

We could use swizzling to inject these last three calls, but that seems a step too clever to me.

Next Steps

With just under 300 lines of total code, we can add custom fonts to an app. Although we developers like to complain, the conciseness of this is really a testament to the power and elegance of UIKit.

I’ve collected all the posts in this series, along with the final code and steps for adding the code to your app.

Share and enjoy.