Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,16 @@ public static Brightness getCurrentBrightness() {

/// Returns the brightness requested by the current system or platform settings.
private static Brightness getAutomaticBrightness() {
// On macOS the launcher pins the application appearance (see #applyNativeDarkMode), and JavaFX derives its
// color scheme from NSApp.effectiveAppearance. Reading FXUtils.DARK_MODE here would therefore read back the
// brightness this launcher itself applied, so the system setting is read directly instead.
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && MacOSNativeUtils.isSupported()) {
@Nullable Boolean systemDarkMode = MacOSNativeUtils.isSystemInDarkMode();
if (systemDarkMode != null) {
return systemDarkMode ? Brightness.DARK : Brightness.LIGHT;
}
}

if (FXUtils.DARK_MODE != null) {
return FXUtils.DARK_MODE.get() ? Brightness.DARK : Brightness.LIGHT;
}
Expand Down Expand Up @@ -1151,13 +1161,32 @@ public void handle(WindowEvent event) {
});
}
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && MacOSNativeUtils.isSupported()) {
MacOSNativeUtils.setAppearance(darkModeProperty().get());
applyMacOSAppearance(darkModeProperty().get());

ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), MacOSNativeUtils::setAppearance);
ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), Themes::applyMacOSAppearance);
stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener);
}
}

/// Applies the launcher brightness to the macOS application appearance.
///
/// Setting an explicit appearance is what makes native windows owned by the application - notably the file
/// chooser - follow the launcher brightness instead of the system one. It has a cost: while an appearance is
/// pinned, `NSApp.effectiveAppearance` no longer reports the system appearance, so JavaFX stops reporting
/// system color scheme changes.
///
/// The appearance is therefore only pinned while the launcher brightness actually differs from the system
/// brightness. When they agree - which is the case whenever the brightness mode follows the system - the
/// appearance is cleared, so the system appearance is inherited and JavaFX keeps observing system changes.
private static void applyMacOSAppearance(boolean dark) {
@Nullable Boolean systemDarkMode = MacOSNativeUtils.isSystemInDarkMode();
if (systemDarkMode != null && systemDarkMode == dark) {
MacOSNativeUtils.clearAppearance();
} else {
MacOSNativeUtils.setAppearance(dark);
}
}

/// Prevents instantiation.
private Themes() {
}
Expand Down
57 changes: 57 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/MacOSNativeUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,63 @@ public static void setAppearance(boolean dark, boolean highContrast) {
}
}

/// Clears the explicit application appearance, so that the application inherits the system appearance again.
///
/// While an explicit appearance is set, `NSApp.effectiveAppearance` reports that appearance rather than the
/// system one. JavaFX derives [javafx.application.Platform.Preferences#colorSchemeProperty()] from
/// `effectiveAppearance`, so leaving an appearance pinned makes the reported color scheme follow the launcher
/// instead of the system.
public static void clearAppearance() {
if (nsApp == null) return;

try {
var objc = ObjectiveCRuntime.INSTANCE;
// A null Pointer maps to Objective-C nil, which resets the appearance to inherited.
objc.objc_msgSend(nsApp, objc.sel_registerName("setAppearance:"), (Pointer) null);
} catch (Throwable t) {
LOG.warning("Failed to clear macOS appearance", t);
}
}

/// Reads whether the system is currently using dark mode, independently of the application appearance.
///
/// This reads the `AppleInterfaceStyle` user default, which reflects the system setting and is unaffected by
/// [#setAppearance(boolean)]. It must not be derived from `effectiveAppearance` or from the JavaFX color
/// scheme, because both report the pinned application appearance once one has been set.
///
/// @return whether the system is in dark mode, or `null` if it could not be determined
public static @Nullable Boolean isSystemInDarkMode() {
if (nsApp == null) return null;

try {
var objc = ObjectiveCRuntime.INSTANCE;

@Nullable Pointer userDefaults = objc.objc_getClass("NSUserDefaults");
if (isNull(userDefaults)) return null;

@Nullable Pointer defaults = objc.objc_msgSend(userDefaults, objc.sel_registerName("standardUserDefaults"));
if (isNull(defaults)) return null;

@Nullable Pointer nsString = objc.objc_getClass("NSString");
if (isNull(nsString)) return null;

@Nullable Pointer key = objc.objc_msgSend(nsString, objc.sel_registerName("stringWithUTF8String:"), "AppleInterfaceStyle");
if (isNull(key)) return null;

@Nullable Pointer value = objc.objc_msgSend(defaults, objc.sel_registerName("stringForKey:"), key);
// The key is absent in light mode.
if (isNull(value)) return Boolean.FALSE;

@Nullable Pointer utf8 = objc.objc_msgSend(value, objc.sel_registerName("UTF8String"));
if (isNull(utf8)) return null;

return "Dark".equalsIgnoreCase(utf8.getString(0));
} catch (Throwable t) {
LOG.warning("Failed to read macOS system appearance", t);
return null;
}
}

private MacOSNativeUtils() {
}
}