Terminal Apps Respecting System Dark Mode

22 Aug 2023 11:39 PM    macos neovim alacritty swift   updated 22 Aug 2023 5:27 PM
convert to local time zone


This article is dedicated to Alex, for this glowing review and proving the inspiration to finish this post.

endorsement

Since macOS Mojave (10.14) in 2018, macOS has had a system-wide Dark Mode. In macOS Catalina (10.15) (2019), Apple introduced automatic shifting between light and dark modes based on a schedule or sunrise/sunset1.

If you’ve ever used the stock macOS Terminal.app with no customization, you may have noticed that the default “Basic” profile does in fact shift with dark mode:

default macos terminal profile shifting in and out of dark mode

but as soon as you configure anything — font, colors, anything at all — it no longer shifts:

customized macos terminal profile failing to shift into dark mode

I Do Not Like This.

As it so happens, I’m no longer using Terminal.app (primarily for its lack of 24-bit truecolor support or support for the OSC52 escape code) and I am instead using my own fork of Alacritty. While Alacritty also doesn’t support shifting by default, it does support both a feature called “live config reload” (where the configuration file is reloaded upon changes, even if the app is open) and an IPC socket API.

I also want to have my Neovim installation shift with light and dark mode, because even on a light terminal emulator, Neovim puts everything on a dark background.


So now we know what we want to do. The next question is how to do it.

the overarching system

macOS allows you to add “observers” for system “notifications”. These aren’t the notifications that the user would see in Notification Center, but rather for system events (such as undo/redo, waking from sleep, changes to the Contacts database, when a game controller connects, when the user changes their preferred units in Health, when the music player’s queue changes, when the clipboard changes, or more2). Among the notifications you can observe is one that fires whenever the system appearance changes. We’ll listen for that event, and then run a series of (user-configurable) commands every time our callback is triggered.

registering callbacks

If you google how to do this, most of the results you’ll find will point you to the (pre-dark mode!) old, undocumented way of listening for this event (and you may see some Objective-C example code). However, when Dark Mode was introduced, a newer, better way was added (and documented). We use the new one when we can, but support the legacy method:

if #available(macOS 10.14, *) {
    print("Registering appearance change callback")
    observation = NSApplication.shared.observe(\.effectiveAppearance) { (app, _) in
        try! callback()
    }
} else {
    print("Registering legacy theme change callback")
    DistributedNotificationCenter.default.addObserver(
            forName: Notification.Name("AppleInterfaceThemeChangedNotification"),
            object: nil,
            queue: nil) { (notification) in
        try! callback()
    }
}

When using the newer method, note how I store the observation in observation (declared outside of this snippet). You must keep a reference to the observation if you want it to actually observe.

parsing commands

Also note the callback() function, which is defined like so:

public static func callback() throws {
    let dir = ProcessInfo.processInfo.environment["XDG_CONFIG_DIR", default: "~/.config"]
    print("[callback] Reading from \(dir)")
    let url = URL(fileURLWithPath: dir.replacingOccurrences(of: "~", with: FileManager.default.homeDirectoryForCurrentUser.absoluteString)).appendingPathComponent("dmn").appendingPathComponent("commands.json")
    let data = try Data(contentsOf: url)
    print("[callback] Read contents of \(url)")
    let commands: [Command] = try JSONDecoder().decode([Command].self, from: data)
    print("[callback] Decoded \(commands.count) command(s) to run")

    let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark"

    for command in commands {
        print("[callback] Running command `\(command.executable)` with argument(s) '\(command.arguments.joined(separator: "', '"))'")
        shell(command.executable, args: command.arguments.map { $0.replacingOccurrences(of: "{}", with: isDark ? "dark" : "light")})
    }
}

It reads a JSON file in ~/.config/dmn/commands.json (or respects your $XDG_CONFIG_DIR if set), which is a list of commands to run every time the theme changes. Mine looks like this:

[
    {
        "executable": "/Users/sam/.local/bin/nvim-ctrl",
        "arguments": ["set background={}"]
    },
    {
        "executable": "/usr/bin/env",
        "arguments": ["/Users/sam/.local/bin/alacritty-color-switcher"]
    },
    {
        "executable": "/usr/bin/env",
        "arguments": ["/Users/sam/.local/bin/nvim-starting-color-switcher"]
    },
    {
        "executable": "/Users/sam/.local/bin/lvim-ctrl",
        "arguments": ["set background={}"]
    },
    {
        "executable": "/Users/sam/.local/bin/cheat-starting-color-switcher",
        "arguments": []
    },
]

running commands

Next, let’s look at the shell function:

@discardableResult
func shell(_ exec: String, args: [String]) -> Int32 {
    let task = Process()
    let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark"
    var env = ProcessInfo.processInfo.environment
    env["DARKMODE"] = isDark ? "1" : "0"
    env["MODE"] = isDark ? "dark" : "light"
    task.environment = env
    task.launchPath = exec
    task.arguments = args
    task.standardError = FileHandle.standardError
    task.standardOutput = FileHandle.standardOutput
    task.launch()
    task.waitUntilExit()
    return task.terminationStatus
}

We check whether we’re in light or dark mode, set some environment variables, and then run whatever the command is. Note that the way I’m currently checking for dark mode is not ideal, but there is an open ticket to use the officially supported method.

Finally, we also register our callback on when the system wakes from sleep, in case the computer has shifted themes while asleep:

NSWorkspace.shared.notificationCenter.addObserver(
        forName: NSWorkspace.didWakeNotification,
        object: nil,
        queue: nil) { (notification) in
    try! callback()
}
NSApplication.shared.run()

and then begin the Cocoa main loop with NSApplication.shared.run(). Without this line, the program would exit immediately.


running as a service

This works great when I’m running it, but I don’t want to leave a window running all the time. Instead, I’d like it to run as a background service, ideally restarting if it ever crashes. On macOS, the way you do this is with a LaunchAgent, which is a user-scoped service. LaunchAgents are configured with .plist files, and the one for this program looks like this:

<?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>Label</key>
    <string>com.samasaur.dmn</string>
    <key>KeepAlive</key>
    <true/>
    <key>StandardErrorPath</key>
    <string>/tmp/dmn-stderr.log</string>
    <key>StandardOutPath</key>
    <string>/tmp/dmn-stdout.log</string>
    <key>Program</key>
    <string>/Users/sam/.local/bin/dmn</string>
</dict>
</plist>

In all honesty, I have no idea how I enabled this service. Check man launchctl, and good luck.


addendum: scripts for specific commands

Remember my personal commands.json? Let’s take a look in more detail:

[
    {
        "executable": "/Users/sam/.local/bin/nvim-ctrl",
        "arguments": ["set background={}"]
    },
    {
        "executable": "/usr/bin/env",
        "arguments": ["/Users/sam/.local/bin/alacritty-color-switcher"]
    },
    {
        "executable": "/usr/bin/env",
        "arguments": ["/Users/sam/.local/bin/nvim-starting-color-switcher"]
    },
    {
        "executable": "/Users/sam/.local/bin/lvim-ctrl",
        "arguments": ["set background={}"]
    },
    {
        "executable": "/Users/sam/.local/bin/cheat-starting-color-switcher",
        "arguments": []
    },
]

active neovim and lunarvim instances

The nvim-ctrl and lvim-ctrl scripts both fall into this category. They connect to every running Neovim/LunarVim instance using the RPC API, and run one command, which is specified in commands.json as set background={}, where {} is replaced with either dark or light. The source code for those scripts is available on my GitHub3.

and the rest

The other three scripts all look more or less like this:

#!/usr/bin/env bash
#
case "$DARKMODE" in
    1) a="mocha" ;;
    0) a="latte" ;;
    *) echo "Unknown setting" ;;
esac

sed -i "" -e "s#^colors: \*.*#colors: *catppuccin-${a}#g" /Users/sam/.config/alacritty/alacritty.yml

(this is alacritty-color-switcher). They all replace one line of some config file. The Alacritty one also affects running Alacritty instances, due to live config reload, while the other two scripts affect config files that are only read when the corresponding program starts up.

and it’s very easy to add more scripts as needed:

 [
     {
         "executable": "/Users/sam/.local/bin/nvim-ctrl",
         "arguments": ["set background={}"]
     },
     {
         "executable": "/usr/bin/env",
         "arguments": ["/Users/sam/.local/bin/alacritty-color-switcher"]
     },
     {
         "executable": "/usr/bin/env",
         "arguments": ["/Users/sam/.local/bin/nvim-starting-color-switcher"]
     },
     {
         "executable": "/Users/sam/.local/bin/lvim-ctrl",
         "arguments": ["set background={}"]
     },
     {
         "executable": "/Users/sam/.local/bin/cheat-starting-color-switcher",
         "arguments": []
     },
+    {
+        "executable: "/usr/bin/say",
+        "arguments": ["Switching to {} mode"]
+    },
 ]
  1. While researching which versions of macOS introduced dark mode, I discovered that macOS Ventura (13) apparently will no longer shift into/out of dark mode “until your Mac has been idle for at least a minute,” and won’t shift at all if “an app is preventing the display from sleeping” (Apple Support article). Just another reason not to upgrade I’ve actually upgraded after writing the first draft of this article, and I don’t notice the delay in practice. Your mileage may vary. 

  2. This was an essentially random list of notifications. You can see a larger but not full list at the Apple docs 

  3. The lvim-ctrl script is a slightly modified version of this GitHub link, changed so that it finds LunarVim instances instead of Neovim instances. 


One response

  1. Gravatar for Aziz

    Terminals these days, no respect, none at all

    Aziz – November 13th, 2023 at 10:04 PM UTC

Respond to this