Skip to content

Commit

Permalink
add per-app grayscale settings
Browse files Browse the repository at this point in the history
  • Loading branch information
brettferdosi committed Feb 3, 2021
1 parent 7d56852 commit 927f821
Show file tree
Hide file tree
Showing 9 changed files with 243 additions and 66 deletions.
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@

# grayscale

Grayscale is a menu bar app for macOS that allows you to easily toggle the grayscale display filter. To my knowledge, it is the first app to tie in with the system grayscale filter present in modern versions of macOS. This means that it works seamlessly with configuration options in System Preferences, and enabling or disabling grayscale will persist through sleep and shutdown. To learn why you may want to enable the grayscale filter, you can check out the following links:
`grayscale` is a macOS status bar app for managing the system grayscale display filter. It allows you to toggle grayscale mode easily by clicking the status bar icon or using a keyboard shortcut, and it also supports enabling or disabling grayscale based on which application is currently active.

Using the grayscale filter can be effective in reducing screen time. For more information, check out the following links:

- https://www.nytimes.com/2018/01/12/technology/grayscale-phone.html
- https://blog.mozilla.org/internetcitizen/2018/02/13/grayscale/

<img src="https://github.com/brettferdosi/grayscale/raw/doc/demo.png">

Graysacle has been tested on macOS 10.15 Catalina but may also work on other versions.
`grayscale` has been tested on macOS 11 Big Sur but may also work on other versions.

## Using grayscale

`grayscale` enables or disables grayscale mode based on the active application. It stores a a default grayscale value, which determines whether grayscale mode should be on or off for all applications that have not overridden it. To toggle the default value, you can left click the status bar icon or use a keyboard shortcut. Right-clicking the icon brings up a menu, which allows you to view the default value, override it for the active application, and configure the keyboard shortcut.

`grayscale` is designed to make using grayscale mode practical. It's not realistic to keep your screen in grayscale all the time, and automatic transitions reduce the burden of manually turing it on and off. I recommend enabling grayscale by default and disabling it for specific applications that benefit from colors but don't use them to capture your attention, like a text editor with syntax highlighting. Potentially addictive applications that sometimes need color, like web browsers, can then be used with the default setting (i.e. with grayscale enabled), and you can use the keyboard shortcut to toggle grayscale as necessary.

## Installing grayscale

Expand All @@ -24,11 +32,3 @@ Clone this git repository using `git clone --recurse-submodules` and run `xcodeb
**Optional: open at login**

Automatically open grayscale at login by following Apple's instructions [here](https://support.apple.com/guide/mac-help/open-items-automatically-when-you-log-in-mh15189/mac) (add grayscale to the list in System Preferences > Users & Groups > Login Items).

## Using grayscale

Left click the menu bar icon to toggle the system grayscale filter. Right click the menu bar icon to access the application's menu. From the menu, select Set Keyboard Shortcut then click on the Record Shortcut button to set a global keyboard shortcut for toggling the filter.

## Troubleshooting

This app uses reverse-engineered private frameworks to toggle the grayscale filter (see `Sources/Bridge.h` for details). If you notice any abnormal behavior, please open an issue with as much information as possible, and I will do my best to fix it. If grayscale toggling via the app stops working, you may be able to get it working again by manually toggling the grayscale filter once in System Preferences > Accessibility > Display > Color Filters.
2 changes: 1 addition & 1 deletion Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSSupportsSuddenTermination</key>
<true/>
<false/>
</dict>
</plist>
2 changes: 1 addition & 1 deletion Resources/ShortcutWindow.xib
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<window title="Grayscale Keyboard Shortcut" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="F0z-JX-Cv5">
<window title="Grayscale Default Toggle Shortcut" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="F0z-JX-Cv5">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="196" y="240" width="324" height="86"/>
Expand Down
198 changes: 149 additions & 49 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,56 +9,132 @@
import Cocoa
import Carbon

// user defaults keys
let grayscaleShortcutName = "grayscale_shortcut"
let perAppGrayscaleEnabledDictName = "grayscale_dict"

var statusItem: NSStatusItem!
var statusMenu: NSMenu!
var shortcutWindowController: ShortcutWindowController!
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {

// logging
enum LogLevel: Int {
case VERBOSE = 0
case ALWAYS_PRINT
}
let currentLogLevel: LogLevel = .ALWAYS_PRINT
func grayscaleLog(logLevel: LogLevel = .ALWAYS_PRINT, _ format: String,
file: String = #file, caller: String = #function, args: CVarArg...) {
if (logLevel.rawValue >= currentLogLevel.rawValue) {
let fileName = file.components(separatedBy: "/").last ?? ""
NSLog("\(fileName):\(caller) " + format, args)
// application state management

var currentApplication: NSRunningApplication!
var defaultGrayscaleEnabled: Bool = false
var perAppGrayscaleEnabledDict: [String: Bool] = [:]

func applicationDidFinishLaunching(_ aNotification: Notification) {
grayscaleLog("")

defaultGrayscaleEnabled = grayscaleEnabled()
currentApplication = NSWorkspace.shared.frontmostApplication!
perAppGrayscaleEnabledDict = UserDefaults.standard.dictionary(forKey: perAppGrayscaleEnabledDictName) as? [String: Bool] ?? [:]

createUI()
updateUI()

MASShortcutBinder.shared().bindShortcut(withDefaultsKey: grayscaleShortcutName, toAction: toggleDefaultGrayscale)

NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(handleApplicationChange(_:)), name: NSWorkspace.didActivateApplicationNotification, object: nil)
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(handleDisplayChange(_:)), name: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, object: nil)
}
}

// see Sources/Bridge.h for information about toggling grayscale
func toggleGrayscale() {
let enabled = MADisplayFilterPrefGetCategoryEnabled(SYSTEM_FILTER) &&
(MADisplayFilterPrefGetType(SYSTEM_FILTER) == GRAYSCALE_TYPE)
if enabled {
MADisplayFilterPrefSetCategoryEnabled(SYSTEM_FILTER, false)
} else {
MADisplayFilterPrefSetType(SYSTEM_FILTER, GRAYSCALE_TYPE)
MADisplayFilterPrefSetCategoryEnabled(SYSTEM_FILTER, true)
func applicationWillTerminate(_ aNotification: Notification) {
grayscaleLog("")

setGrayscale(defaultGrayscaleEnabled)

MASShortcutMonitor.shared().unregisterAllShortcuts()
}
_UniversalAccessDStart(UNIVERSALACCESSD_MAGIC)
}

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
@objc func handleDisplayChange(_ aNotification: Notification) {
// if the new grayscale value doesn't match our application state, it was changed
// from somewhere outside this application (e.g. the settings pane). for now we don't do
// anything special in this case - just let the grayscale filter stay out of sync
// with the application state until the user does something that brings it back in
// line, like switching active apps.
}

@objc func handleApplicationChange(_ aNotification: Notification) {
currentApplication = (aNotification.userInfo!["NSWorkspaceApplicationKey"] as! NSRunningApplication)
grayscaleLog("\(currentApplication.localizedName!) got focus")
updateUI()
}

@objc func appSpecificMenuClick(_ sender: Any) {
let senderObject = sender as! NSObject
if senderObject == appSpecificSubMenuItemGrayscaleDefault {
grayscaleLog("default")
perAppGrayscaleEnabledDict.removeValue(forKey: currentApplication.bundleIdentifier!)
}

if senderObject == appSpecificSubMenuItemGrayscaleEnabled {
grayscaleLog("enabled")
perAppGrayscaleEnabledDict[currentApplication.bundleIdentifier!] = true
}

if senderObject == appSpecificSubMenuItemGrayscaleDisabled {
grayscaleLog("disabled")
perAppGrayscaleEnabledDict[currentApplication.bundleIdentifier!] = false
}

UserDefaults.standard.setValue(perAppGrayscaleEnabledDict, forKey: perAppGrayscaleEnabledDictName)

updateUI()
}

func toggleDefaultGrayscale() {
grayscaleLog("toggling default from \(defaultGrayscaleEnabled)")
defaultGrayscaleEnabled = !defaultGrayscaleEnabled
updateUI()
}

@objc func buttonClick(_ sender: Any) {
@objc func statusBarButtonClick(_ sender: Any) {
let event = NSApp.currentEvent!
if event.type == NSEvent.EventType.leftMouseUp {
toggleGrayscale()
toggleDefaultGrayscale()
} else if event.type == NSEvent.EventType.rightMouseUp {
// BFG: need a hack to mimic this behavior when .popUpMenu() goes away
statusItem.popUpMenu(statusMenu)
}
}

func applicationDidFinishLaunching(_ aNotification: Notification) {
grayscaleLog("")
@objc func quit(_ sender: Any) {
NSApp.terminate(self)
}

shortcutWindowController = ShortcutWindowController()
// user interface

func updateUI() {
defaultGrayscaleMenuItem.title = defaultGrayscaleEnabled ? "Grayscale Default: Enabled" : "Grayscale Default: Disabled"
appSpecificMenuItem.title = currentApplication.localizedName!

if let appSpecificEnableGrayscale = perAppGrayscaleEnabledDict[currentApplication.bundleIdentifier!] {
if appSpecificEnableGrayscale {
// grayscale is enabled for this app
updateAppSpecificSubMenu(.ENABLED)
setGrayscale(true)
} else {
// grayscale is disabled for this app
updateAppSpecificSubMenu(.DISABLED)
setGrayscale(false)
}
} else {
// no app-specific behavior - use the default grayscale value
updateAppSpecificSubMenu(.DEFAULT)
setGrayscale(defaultGrayscaleEnabled)
}
}

var statusItem: NSStatusItem!
var statusMenu: NSMenu!
var defaultGrayscaleMenuItem: NSMenuItem!
var appSpecificMenuItem: NSMenuItem!
var appSpecificSubMenuItemGrayscaleDefault: NSMenuItem!
var appSpecificSubMenuItemGrayscaleDisabled: NSMenuItem!
var appSpecificSubMenuItemGrayscaleEnabled: NSMenuItem!
var shortcutWindowController: ShortcutWindowController!

func createUI() {
shortcutWindowController = ShortcutWindowController(toggleDefaultGrayscale)

statusMenu = NSMenu()
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
Expand All @@ -70,34 +146,59 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

menuButton.image = NSImage(named: "MenuBarIcon")
menuButton.action = #selector(buttonClick(_:))
menuButton.action = #selector(statusBarButtonClick(_:))
menuButton.sendAction(on: [.leftMouseUp, .rightMouseUp])

statusMenu.addItem(NSMenuItem(title: "About", action: #selector(showAboutPanel(_:)), keyEquivalent: ""))
defaultGrayscaleMenuItem = NSMenuItem(title: "Grayscale Default", action: nil, keyEquivalent: "")
statusMenu.addItem(defaultGrayscaleMenuItem)

appSpecificMenuItem = NSMenuItem(title: "App Name", action: nil, keyEquivalent: "")
appSpecificSubMenuItemGrayscaleDefault = NSMenuItem(title: "Default", action: #selector(appSpecificMenuClick(_:)), keyEquivalent: "")
appSpecificSubMenuItemGrayscaleEnabled = NSMenuItem(title: "Enable Grayscale", action: #selector(appSpecificMenuClick(_:)), keyEquivalent: "")
appSpecificSubMenuItemGrayscaleDisabled = NSMenuItem(title: "Disable Grayscale", action: #selector(appSpecificMenuClick(_:)), keyEquivalent: "")
appSpecificMenuItem.submenu = NSMenu()
appSpecificMenuItem.submenu!.addItem(appSpecificSubMenuItemGrayscaleDefault)
appSpecificMenuItem.submenu!.addItem(appSpecificSubMenuItemGrayscaleEnabled)
appSpecificMenuItem.submenu!.addItem(appSpecificSubMenuItemGrayscaleDisabled)

statusMenu.addItem(appSpecificMenuItem)
statusMenu.addItem(NSMenuItem.separator())

statusMenu.addItem(NSMenuItem(title: "Set Keyboard Shortcut", action: #selector(showShortcutWindow(_:)), keyEquivalent: ""))
statusMenu.addItem(NSMenuItem(title: "Default Toggle Shortcut", action: #selector(showShortcutWindow(_:)), keyEquivalent: ""))
statusMenu.addItem(NSMenuItem.separator())

statusMenu.addItem(NSMenuItem(title: "Quit", action: #selector(quit(_:)), keyEquivalent: ""))
statusMenu.addItem(NSMenuItem(title: "About", action: #selector(showAboutPanel(_:)), keyEquivalent: ""))
statusMenu.addItem(NSMenuItem.separator())

// load previously saved shortcut, if any
MASShortcutBinder.shared().bindShortcut(withDefaultsKey: grayscaleShortcutName, toAction: toggleGrayscale)
statusMenu.addItem(NSMenuItem(title: "Quit", action: #selector(quit(_:)), keyEquivalent: ""))
}

func applicationWillTerminate(_ aNotification: Notification) {
@objc func showShortcutWindow(_ sender: Any) {
grayscaleLog("")

MASShortcutMonitor.shared().unregisterAllShortcuts()
shortcutWindowController.showWindow(sender)
}

@objc func quit(_ sender: Any) {
NSApp.terminate(self)
enum AppSpecificGrayscaleStatus: Int {
case DEFAULT = 0
case ENABLED
case DISABLED
}

@objc func showShortcutWindow(_ sender: Any) {
grayscaleLog("")
shortcutWindowController.showWindow(sender)
func updateAppSpecificSubMenu(_ grayscaleStatus: AppSpecificGrayscaleStatus) {
switch grayscaleStatus {
case .DEFAULT:
appSpecificSubMenuItemGrayscaleDefault.state = .on
appSpecificSubMenuItemGrayscaleEnabled.state = .off
appSpecificSubMenuItemGrayscaleDisabled.state = .off
case .ENABLED:
appSpecificSubMenuItemGrayscaleDefault.state = .off
appSpecificSubMenuItemGrayscaleEnabled.state = .on
appSpecificSubMenuItemGrayscaleDisabled.state = .off
case .DISABLED:
appSpecificSubMenuItemGrayscaleDefault.state = .off
appSpecificSubMenuItemGrayscaleEnabled.state = .off
appSpecificSubMenuItemGrayscaleDisabled.state = .on
}
}

@objc func showAboutPanel(_ sender: Any) {
Expand All @@ -123,4 +224,3 @@ class AppDelegate: NSObject, NSApplicationDelegate {
NSApp.orderFrontStandardAboutPanel(options: [ .credits : credits ])
}
}

6 changes: 3 additions & 3 deletions Sources/Bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ extern void MADisplayFilterPrefSetCategoryEnabled(int filter, _Bool enable);
extern int MADisplayFilterPrefGetType(int filter);
extern void MADisplayFilterPrefSetType(int filter, int type);

int SYSTEM_FILTER = 0x1;
int GRAYSCALE_TYPE = 0x1;
int __attribute__((weak)) SYSTEM_FILTER = 0x1;
int __attribute__((weak)) GRAYSCALE_TYPE = 0x1;

// --

Expand All @@ -74,7 +74,7 @@ int GRAYSCALE_TYPE = 0x1;

extern void _UniversalAccessDStart(int magic);

int UNIVERSALACCESSD_MAGIC = 0x8;
int __attribute__((weak)) UNIVERSALACCESSD_MAGIC = 0x8;

// --

Expand Down
34 changes: 34 additions & 0 deletions Sources/Grayscale.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// Grayscale.swift
// grayscale
//
// Created by Brett Gutstein on 1/31/21.
// Copyright © 2021 Brett Gutstein. All rights reserved.
//

// see Sources/Bridge.h for information about toggling grayscale
func grayscaleEnabled() -> Bool {
return MADisplayFilterPrefGetCategoryEnabled(SYSTEM_FILTER) &&
(MADisplayFilterPrefGetType(SYSTEM_FILTER) == GRAYSCALE_TYPE)
}

func enableGrayscale() {
MADisplayFilterPrefSetType(SYSTEM_FILTER, GRAYSCALE_TYPE)
MADisplayFilterPrefSetCategoryEnabled(SYSTEM_FILTER, true)
_UniversalAccessDStart(UNIVERSALACCESSD_MAGIC)
}

func disableGrayscale() {
MADisplayFilterPrefSetCategoryEnabled(SYSTEM_FILTER, false)
_UniversalAccessDStart(UNIVERSALACCESSD_MAGIC)
}

func toggleGrayscale() {
grayscaleEnabled() ? disableGrayscale() : enableGrayscale()
}

func setGrayscale(_ enable: Bool) {
if grayscaleEnabled() != enable {
toggleGrayscale()
}
}
22 changes: 22 additions & 0 deletions Sources/Logging.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// Logging.swift
// grayscale
//
// Created by Brett Gutstein on 1/31/21.
// Copyright © 2021 Brett Gutstein. All rights reserved.
//

enum LogLevel: Int {
case VERBOSE = 0
case ALWAYS_PRINT
}

let currentLogLevel: LogLevel = .ALWAYS_PRINT

func grayscaleLog(logLevel: LogLevel = .ALWAYS_PRINT, _ format: String,
file: String = #file, caller: String = #function, args: CVarArg...) {
if (logLevel.rawValue >= currentLogLevel.rawValue) {
let fileName = file.components(separatedBy: "/").last ?? ""
NSLog("\(fileName):\(caller) " + format, args)
}
}
Loading

0 comments on commit 927f821

Please sign in to comment.