use core::fmt;
use core::str::FromStr;
use std::cell::{Cell, RefCell};
use std::time::Duration;
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2::{DefinedClass, MainThreadOnly, define_class, msg_send, sel};
#[cfg(feature = "private-spi")]
use objc2_app_kit::NSWorkspace;
use objc2_app_kit::{NSAppearance, NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSColor};
use objc2_core_foundation::{
CFPreferencesAppSynchronize, CFPreferencesCopyValue, CFString, kCFPreferencesAnyApplication,
kCFPreferencesAnyHost, kCFPreferencesCurrentUser,
};
use objc2_foundation::{
MainThreadMarker, NSKeyValueObservingOptions, NSObject, NSObjectNSKeyValueObserverRegistration,
NSObjectProtocol, NSString, NSTimer, NSUserDefaults,
};
pub const ICON_APPEARANCE_KEY: &str = "AppleIconAppearanceTheme";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum IconStyle {
Regular,
RegularDark,
Clear,
Tinted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ThemeMode {
Light,
Dark,
Auto,
}
impl ThemeMode {
fn as_str(self) -> &'static str {
match self {
Self::Light => "Light",
Self::Dark => "Dark",
Self::Auto => "Auto",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum IconAppearanceToken {
RegularAutomatic,
RegularLight,
RegularDark,
ClearAutomatic,
ClearLight,
ClearDark,
TintedAutomatic,
TintedLight,
TintedDark,
}
impl IconAppearanceToken {
pub const ALL: &'static [Self] = &[
Self::RegularAutomatic,
Self::RegularLight,
Self::RegularDark,
Self::ClearAutomatic,
Self::ClearLight,
Self::ClearDark,
Self::TintedAutomatic,
Self::TintedLight,
Self::TintedDark,
];
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::RegularAutomatic => "RegularAutomatic",
Self::RegularLight => "RegularLight",
Self::RegularDark => "RegularDark",
Self::ClearAutomatic => "ClearAutomatic",
Self::ClearLight => "ClearLight",
Self::ClearDark => "ClearDark",
Self::TintedAutomatic => "TintedAutomatic",
Self::TintedLight => "TintedLight",
Self::TintedDark => "TintedDark",
}
}
#[must_use]
pub fn raw(self) -> i64 {
match self {
Self::RegularAutomatic => 0,
Self::RegularLight => 1,
Self::RegularDark => 2,
Self::ClearAutomatic => 3,
Self::ClearLight => 4,
Self::ClearDark => 5,
Self::TintedAutomatic => 6,
Self::TintedLight => 7,
Self::TintedDark => 8,
}
}
#[must_use]
pub fn style(self) -> IconStyle {
match self {
Self::RegularAutomatic | Self::RegularLight => IconStyle::Regular,
Self::RegularDark => IconStyle::RegularDark,
Self::ClearAutomatic | Self::ClearLight | Self::ClearDark => IconStyle::Clear,
Self::TintedAutomatic | Self::TintedLight | Self::TintedDark => IconStyle::Tinted,
}
}
#[must_use]
pub fn mode(self) -> ThemeMode {
match self {
Self::RegularAutomatic | Self::ClearAutomatic | Self::TintedAutomatic => {
ThemeMode::Auto
}
Self::RegularLight | Self::ClearLight | Self::TintedLight => ThemeMode::Light,
Self::RegularDark | Self::ClearDark | Self::TintedDark => ThemeMode::Dark,
}
}
#[must_use]
pub fn is_tinted(self) -> bool {
self.style() == IconStyle::Tinted
}
}
impl fmt::Display for IconAppearanceToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
impl FromStr for IconAppearanceToken {
type Err = UnknownToken;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::ALL
.iter()
.copied()
.find(|t| t.name() == s)
.ok_or_else(|| UnknownToken(s.to_owned()))
}
}
impl TryFrom<i64> for IconAppearanceToken {
type Error = UnknownThemeValue;
fn try_from(value: i64) -> Result<Self, Self::Error> {
Self::ALL
.iter()
.copied()
.find(|t| t.raw() == value)
.ok_or(UnknownThemeValue(value))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownToken(String);
impl UnknownToken {
#[must_use]
pub fn found(&self) -> &str {
&self.0
}
}
impl fmt::Display for UnknownToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "unknown icon appearance token {:?}", self.0)
}
}
impl core::error::Error for UnknownToken {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnknownThemeValue(i64);
impl UnknownThemeValue {
#[must_use]
pub fn found(&self) -> i64 {
self.0
}
}
impl fmt::Display for UnknownThemeValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "icon appearance theme {} is out of range 0..=8", self.0)
}
}
impl core::error::Error for UnknownThemeValue {}
#[derive(Debug, Clone)]
pub struct WidgetStyle {
token: IconAppearanceToken,
tint: Option<Retained<NSColor>>,
}
impl WidgetStyle {
#[must_use]
pub fn from_token(token: IconAppearanceToken, tint: Option<Retained<NSColor>>) -> Self {
let tint = if token.is_tinted() { tint } else { None };
Self { token, tint }
}
#[must_use]
pub fn token(&self) -> IconAppearanceToken {
self.token
}
#[must_use]
pub fn style(&self) -> IconStyle {
self.token.style()
}
#[must_use]
pub fn mode(&self) -> ThemeMode {
self.token.mode()
}
#[must_use]
pub fn tint(&self) -> Option<&NSColor> {
self.tint.as_deref()
}
#[must_use]
pub fn is_tinted(&self) -> bool {
self.token.is_tinted()
}
#[must_use]
pub fn appearance(&self) -> Option<Retained<NSAppearance>> {
let name = match self.mode() {
ThemeMode::Light => unsafe { NSAppearanceNameAqua },
ThemeMode::Dark => unsafe { NSAppearanceNameDarkAqua },
ThemeMode::Auto => return None,
};
NSAppearance::appearanceNamed(name)
}
#[must_use]
pub fn is_dark(&self, ambient: &NSAppearance) -> bool {
match self.mode() {
ThemeMode::Light => false,
ThemeMode::Dark => true,
ThemeMode::Auto => crate::is_dark(ambient),
}
}
}
impl fmt::Display for WidgetStyle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match (self.style(), self.mode()) {
(IconStyle::Regular, ThemeMode::Auto) => f.write_str("Dark ▸ Auto"),
(IconStyle::Regular, _) => f.write_str("Default"),
(IconStyle::RegularDark, _) => f.write_str("Dark"),
(IconStyle::Clear, m) => write!(f, "Clear ▸ {}", m.as_str()),
(IconStyle::Tinted, m) => write!(f, "Tinted ▸ {}", m.as_str()),
}
}
}
impl PartialEq for WidgetStyle {
fn eq(&self, other: &Self) -> bool {
if self.token != other.token {
return false;
}
match (&self.tint, &other.tint) {
(None, None) => true,
(Some(a), Some(b)) => a.isEqual(Some(b)),
_ => false,
}
}
}
fn resolve(theme: i64) -> IconAppearanceToken {
IconAppearanceToken::try_from(theme).unwrap_or(IconAppearanceToken::RegularAutomatic)
}
#[cfg(feature = "private-spi")]
fn workspace_config() -> Option<Retained<AnyObject>> {
let workspace = NSWorkspace::sharedWorkspace();
if !workspace.respondsToSelector(sel!(currentIconAppearanceConfiguration)) {
return None;
}
unsafe { msg_send![&*workspace, currentIconAppearanceConfiguration] }
}
#[cfg(not(feature = "private-spi"))]
fn workspace_config() -> Option<Retained<AnyObject>> {
None
}
#[cfg(not(feature = "private-spi"))]
fn config_tint(_cfg: &AnyObject) -> Option<Retained<NSColor>> {
None
}
#[cfg(not(feature = "private-spi"))]
fn config_theme(_cfg: &AnyObject) -> Option<i64> {
None
}
#[cfg(feature = "private-spi")]
fn responds(obj: &AnyObject, sel: objc2::runtime::Sel) -> bool {
unsafe { msg_send![obj, respondsToSelector: sel] }
}
#[cfg(feature = "private-spi")]
fn config_tint(cfg: &AnyObject) -> Option<Retained<NSColor>> {
if !responds(cfg, sel!(resolvedIconTintColor)) {
return None;
}
unsafe { msg_send![cfg, resolvedIconTintColor] }
}
#[cfg(feature = "private-spi")]
fn config_theme(cfg: &AnyObject) -> Option<i64> {
if !responds(cfg, sel!(iconAppearanceTheme)) {
return None;
}
let theme: isize = unsafe { msg_send![cfg, iconAppearanceTheme] };
Some(theme as i64)
}
pub fn current() -> WidgetStyle {
let cfg = workspace_config();
let tint = cfg.as_deref().and_then(config_tint);
CFPreferencesAppSynchronize(unsafe { kCFPreferencesAnyApplication });
let raw = CFPreferencesCopyValue(
&CFString::from_str(ICON_APPEARANCE_KEY),
unsafe { kCFPreferencesAnyApplication },
unsafe { kCFPreferencesCurrentUser },
unsafe { kCFPreferencesAnyHost },
)
.and_then(|v| v.downcast::<CFString>().ok())
.map(|s| s.to_string());
let token = match raw.as_deref() {
Some(name) => name.parse().unwrap_or(IconAppearanceToken::RegularLight),
None => cfg
.as_deref()
.and_then(config_theme)
.map_or(IconAppearanceToken::RegularLight, resolve),
};
WidgetStyle::from_token(token, tint)
}
pub const WATCHED_KEYS: &[&str] = &[
ICON_APPEARANCE_KEY,
"AppleAccentColor",
"AppleInterfaceStyle",
];
struct ObserverIvars {
on_change: Box<dyn Fn(&WidgetStyle)>,
applied: RefCell<WidgetStyle>,
timer: RefCell<Option<Retained<NSTimer>>>,
alive: Cell<bool>,
}
impl fmt::Debug for ObserverIvars {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ObserverIvars")
.field("applied", &self.applied)
.field("ticking", &self.timer.borrow().is_some())
.field("alive", &self.alive)
.finish_non_exhaustive()
}
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[ivars = ObserverIvars]
#[derive(Debug)]
struct Observer;
unsafe impl NSObjectProtocol for Observer {}
impl Observer {
#[unsafe(method(observeValueForKeyPath:ofObject:change:context:))]
fn observe_value(
&self,
key_path: Option<&NSString>,
_of_object: Option<&AnyObject>,
_change: Option<&AnyObject>,
_context: *mut core::ffi::c_void,
) {
let Some(key) = key_path else { return };
let key = key.to_string();
if !WATCHED_KEYS.contains(&key.as_str()) {
return;
}
unsafe {
let _: () = msg_send![
self,
performSelectorOnMainThread: sel!(reconcileDeferred),
withObject: core::ptr::null::<AnyObject>(),
waitUntilDone: false,
];
}
}
#[unsafe(method(reconcileDeferred))]
fn reconcile_deferred(&self) {
if !self.ivars().alive.get() {
return;
}
self.reconcile(true);
}
#[unsafe(method(reconcileTick:))]
fn reconcile_tick(&self, _timer: Option<&AnyObject>) {
if !self.ivars().alive.get() {
return;
}
self.reconcile(false);
}
}
);
impl Observer {
fn reconcile(&self, force: bool) {
let want = current();
let mut applied = self.ivars().applied.borrow_mut();
if !force && *applied == want {
return;
}
*applied = want.clone();
drop(applied);
(self.ivars().on_change)(&want);
}
}
const RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
#[must_use = "dropping a StyleObserver unregisters it and change delivery stops; \
bind it for as long as you want notifications"]
#[derive(Debug)]
pub struct StyleObserver {
observer: Retained<Observer>,
}
impl StyleObserver {
pub fn new(mtm: MainThreadMarker, on_change: impl Fn(&WidgetStyle) + 'static) -> Self {
let initial = current();
let this = Observer::alloc(mtm).set_ivars(ObserverIvars {
on_change: Box::new(on_change),
applied: RefCell::new(initial.clone()),
timer: RefCell::new(None),
alive: Cell::new(true),
});
let observer: Retained<Observer> = unsafe { msg_send![super(this), init] };
let defaults = NSUserDefaults::standardUserDefaults();
for key in WATCHED_KEYS {
unsafe {
defaults.addObserver_forKeyPath_options_context(
&observer,
&NSString::from_str(key),
NSKeyValueObservingOptions::New,
core::ptr::null_mut(),
);
}
}
{
let interval = RECONCILE_INTERVAL;
let timer = unsafe {
NSTimer::scheduledTimerWithTimeInterval_target_selector_userInfo_repeats(
interval.as_secs_f64(),
&observer,
sel!(reconcileTick:),
None,
true,
)
};
observer.ivars().timer.replace(Some(timer));
}
let this = Self { observer };
(this.observer.ivars().on_change)(&initial);
this
}
#[must_use]
pub fn current(&self) -> WidgetStyle {
self.observer.ivars().applied.borrow().clone()
}
}
impl Drop for StyleObserver {
fn drop(&mut self) {
self.observer.ivars().alive.set(false);
let defaults = NSUserDefaults::standardUserDefaults();
for key in WATCHED_KEYS {
unsafe {
defaults.removeObserver_forKeyPath(&self.observer, &NSString::from_str(key));
}
}
if let Some(timer) = self.observer.ivars().timer.borrow_mut().take() {
timer.invalidate();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn style(theme: i64) -> WidgetStyle {
WidgetStyle::from_token(resolve(theme), None)
}
#[test]
fn every_token_maps_to_its_documented_enum() {
let expected = [
("RegularAutomatic", 0),
("RegularLight", 1),
("RegularDark", 2),
("ClearAutomatic", 3),
("ClearLight", 4),
("ClearDark", 5),
("TintedAutomatic", 6),
("TintedLight", 7),
("TintedDark", 8),
];
for (name, raw) in expected {
let token: IconAppearanceToken = name.parse().expect("a documented token");
assert_eq!(token.raw(), raw, "{name}");
assert_eq!(token.name(), name, "round-trip");
assert_eq!(IconAppearanceToken::try_from(raw), Ok(token), "{raw}");
}
}
#[test]
fn all_is_complete_and_in_preference_order() {
assert_eq!(IconAppearanceToken::ALL.len(), 9);
for (i, token) in IconAppearanceToken::ALL.iter().copied().enumerate() {
assert_eq!(token.raw(), i as i64, "{token} is out of order");
}
}
#[test]
fn dark_is_not_a_token() {
let err = "Dark".parse::<IconAppearanceToken>().unwrap_err();
assert_eq!(err.found(), "Dark");
}
#[test]
fn an_unrecognised_token_string_has_no_enum() {
assert!("Nonsense".parse::<IconAppearanceToken>().is_err());
}
#[test]
fn the_two_error_domains_report_their_own_input() {
let bad_name = "Nonsense".parse::<IconAppearanceToken>().unwrap_err();
assert_eq!(
bad_name.to_string(),
r#"unknown icon appearance token "Nonsense""#
);
let bad_value = IconAppearanceToken::try_from(9999).unwrap_err();
assert_eq!(bad_value.found(), 9999);
assert_eq!(
bad_value.to_string(),
"icon appearance theme 9999 is out of range 0..=8"
);
}
#[test]
fn out_of_range_integers_fall_back_to_regular_automatic() {
let s = style(99);
assert_eq!(s.style(), IconStyle::Regular);
assert_eq!(s.mode(), ThemeMode::Auto);
assert_eq!(s.to_string(), "Dark ▸ Auto");
assert!(!s.is_tinted());
}
#[test]
fn the_string_and_integer_fallbacks_differ() {
assert_eq!(resolve(9999), IconAppearanceToken::RegularAutomatic);
assert_eq!(
"garbage"
.parse::<IconAppearanceToken>()
.unwrap_or(IconAppearanceToken::RegularLight),
IconAppearanceToken::RegularLight
);
assert_ne!(
IconAppearanceToken::RegularAutomatic,
IconAppearanceToken::RegularLight
);
}
#[test]
fn only_the_tinted_family_carries_a_tint() {
for theme in 0..=5 {
assert!(!style(theme).is_tinted());
}
for theme in 6..=8 {
assert!(style(theme).is_tinted());
}
}
#[test]
fn regular_automatic_and_regular_light_display_differently() {
assert_eq!(style(0).to_string(), "Dark ▸ Auto");
assert_eq!(style(1).to_string(), "Default");
assert_eq!(style(2).to_string(), "Dark");
}
#[test]
fn display_carries_the_mode_for_clear_and_tinted() {
assert_eq!(style(3).to_string(), "Clear ▸ Auto");
assert_eq!(style(4).to_string(), "Clear ▸ Light");
assert_eq!(style(5).to_string(), "Clear ▸ Dark");
assert_eq!(style(8).to_string(), "Tinted ▸ Dark");
}
#[test]
fn forced_appearance_names_match_the_mode() {
let aqua = unsafe { NSAppearanceNameAqua };
let dark = unsafe { NSAppearanceNameDarkAqua };
for theme in [1, 4, 7] {
let a = style(theme)
.appearance()
.unwrap_or_else(|| panic!("theme {theme} must force an appearance"));
assert_eq!(&*a.name(), aqua, "theme {theme} must force Aqua");
}
for theme in [2, 5, 8] {
let a = style(theme)
.appearance()
.unwrap_or_else(|| panic!("theme {theme} must force an appearance"));
assert_eq!(&*a.name(), dark, "theme {theme} must force DarkAqua");
}
}
#[test]
fn each_theme_enum_maps_to_its_documented_family_and_mode() {
use IconStyle::*;
use ThemeMode::*;
let expected = [
(0, Regular, Auto),
(1, Regular, Light),
(2, RegularDark, Dark),
(3, Clear, Auto),
(4, Clear, Light),
(5, Clear, Dark),
(6, Tinted, Auto),
(7, Tinted, Light),
(8, Tinted, Dark),
(99, Regular, Auto),
(-1, Regular, Auto),
];
for (theme, want_style, want_mode) in expected {
let s = style(theme);
assert_eq!(s.style(), want_style, "theme {theme} family");
assert_eq!(s.mode(), want_mode, "theme {theme} mode");
}
}
#[test]
fn widget_style_eq_separates_mode_and_tint() {
assert_ne!(style(4), style(5), "Clear/Light must not equal Clear/Dark");
let red = NSColor::colorWithSRGBRed_green_blue_alpha(1.0, 0.0, 0.0, 1.0);
let red2 = NSColor::colorWithSRGBRed_green_blue_alpha(1.0, 0.0, 0.0, 1.0);
let blue = NSColor::colorWithSRGBRed_green_blue_alpha(0.0, 0.0, 1.0, 1.0);
let t1 = WidgetStyle::from_token(IconAppearanceToken::TintedDark, Some(red));
let t2 = WidgetStyle::from_token(IconAppearanceToken::TintedDark, Some(red2));
let t3 = WidgetStyle::from_token(IconAppearanceToken::TintedDark, Some(blue));
let t4 = WidgetStyle::from_token(IconAppearanceToken::TintedDark, None);
assert_eq!(t1, t2, "equal colours must compare equal");
assert_ne!(t1, t3, "different colours must compare unequal");
assert_ne!(t1, t4, "Some(tint) must not equal None");
}
#[test]
fn the_tint_reaches_exactly_the_tinted_themes() {
for theme in [0, 1, 2, 3, 4, 5, 6, 7, 8, 99] {
let colour = NSColor::colorWithSRGBRed_green_blue_alpha(0.0, 1.0, 0.0, 1.0);
let s = WidgetStyle::from_token(resolve(theme), Some(colour));
assert_eq!(
s.tint().is_some(),
(6..=8).contains(&theme),
"theme {theme}: tint must reach exactly the Tinted family"
);
}
}
#[test]
fn auto_inherits_rather_than_forcing_an_appearance() {
assert!(style(0).appearance().is_none());
assert!(style(3).appearance().is_none());
assert!(style(6).appearance().is_none());
assert!(style(4).appearance().is_some());
assert!(style(5).appearance().is_some());
}
#[test]
fn is_dark_resolves_auto_but_forced_modes_ignore_the_ambient() {
let light = NSAppearance::appearanceNamed(unsafe { NSAppearanceNameAqua }).unwrap();
let dark = NSAppearance::appearanceNamed(unsafe { NSAppearanceNameDarkAqua }).unwrap();
assert!(!style(3).is_dark(&light));
assert!(style(3).is_dark(&dark));
assert!(!style(4).is_dark(&dark), "Clear ▸ Light stays light");
assert!(style(5).is_dark(&light), "Clear ▸ Dark stays dark");
}
}