Skip to content

macOS: no way to pin an app to the light appearance (windows are forced to match the system) #11092

Closed

Description

@steinybot

Summary

A macOS Perry app can't pin itself to the light appearance. When a window is created, app.rs asks perry_system_is_dark_mode() and calls window.setAppearance(DarkAqua | Aqua) to match the system. A window-level appearance overrides the app-level one, so an app with only a light palette still gets DarkAqua windows on a Mac set to Dark. AppKit then draws the parts the app doesn't style (title bar, NSTextField placeholder text, selection) in dark colors over the app's light surfaces. Placeholders come out near-white on a white field.

// Match window appearance to the system setting so native controls
// (NSTextField, NSPopUpButton, etc.) use the correct light/dark theme.
// isDarkMode() is called here (after NSApp exists) to get the correct value.
let is_dark = super::perry_system_is_dark_mode() != 0;
let appearance_name = if is_dark {
NSString::from_str("NSAppearanceNameDarkAqua")
} else {
NSString::from_str("NSAppearanceNameAqua")
};
let appearance_cls = objc2::runtime::AnyClass::get(c"NSAppearance").unwrap();
let appearance: *mut objc2::runtime::AnyObject = objc2::msg_send![
appearance_cls, appearanceNamed: &*appearance_name
];
if !appearance.is_null() {
let _: () = objc2::msg_send![&*window, setAppearance: appearance];
}

Why the usual AppKit opt-outs don't work

  • NSRequiresAquaSystemAppearance = true in the bundle's Info.plist sets the app appearance to Aqua. perry_system_is_dark_mode() never reaches that check, because its first check reads the AppleInterfaceStyle user default, which falls through to the global Dark, and returns dark.
  • Passing -AppleInterfaceStyle Light -NSRequiresAquaSystemAppearance YES on the command line does make the window Aqua. It can't ship, though: the args need a launcher script as CFBundleExecutable, and with a launcher the bundle's ATSApplicationFontsPath fonts no longer load.

pub extern "C" fn perry_system_is_dark_mode() -> i64 {
unsafe {
// Method 1: NSUserDefaults — works before the window exists and for
// explicit Dark mode. Returns nil for Auto mode.
let defaults_cls = objc2::runtime::AnyClass::get(c"NSUserDefaults").unwrap();
let defaults: *mut objc2::runtime::AnyObject =
objc2::msg_send![defaults_cls, standardUserDefaults];
let key = objc2_foundation::NSString::from_str("AppleInterfaceStyle");
let style: *mut objc2::runtime::AnyObject = objc2::msg_send![defaults, stringForKey: &*key];
if !style.is_null() {
let dark_str = objc2_foundation::NSString::from_str("Dark");
let is_dark: bool = objc2::msg_send![style, isEqualToString: &*dark_str];
if is_dark {
return 1;
}
}
// Method 2: NSApp.effectiveAppearance — works once the app is initialized.
let app_cls = objc2::runtime::AnyClass::get(c"NSApplication").unwrap();
let app: *mut objc2::runtime::AnyObject = objc2::msg_send![app_cls, sharedApplication];
let appearance: *mut objc2::runtime::AnyObject = objc2::msg_send![app, effectiveAppearance];
if !appearance.is_null() {
let name: *mut objc2::runtime::AnyObject = objc2::msg_send![appearance, name];
if !name.is_null() {
let dark_name = objc2_foundation::NSString::from_str("NSAppearanceNameDarkAqua");
let is_dark: bool = objc2::msg_send![name, isEqualToString: &*dark_name];
if is_dark {
return 1;
}
}
}
0
}
}

Request

Let an app choose the window appearance. Either of these would do:

  • An App({ appearance: "light" | "dark" | "system" }) option, default "system", that sets NSApp.appearance and skips the per-window override.
  • Or leave window.appearance unset so windows inherit NSApp.effectiveAppearance. AppKit resolves that from the system setting by default, and from NSRequiresAquaSystemAppearance when the bundle sets it.

Related

#10861 asks for appearance-adaptive colors, so an app can support both appearances. This issue is the other half: an app that supports only one appearance needs a way to say so.

Environment

Perry 0.5.1638 (local build), macOS 26.6.2 arm64, system appearance Dark.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions