diff --git a/.gitignore b/.gitignore index 96ef6c0..eb489b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target +/examples/*/target Cargo.lock +.vscode diff --git a/Cargo.toml b/Cargo.toml index 055bf7e..0ad97cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,18 +9,26 @@ description = "Detect if dark mode or light mode is enabled" readme = "README.md" build = "build.rs" +[dependencies] +futures = "0.3.30" +anyhow = "1.0.79" + +[dev-dependencies] +tokio = { version = "1.23.0", features = ["full"] } + [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies] -detect-desktop-environment = "0.2" +detect-desktop-environment = "1.0.0" dconf_rs = "0.3" -dirs = "4.0" -zbus = "4.1.2" -rust-ini = "0.18" +zbus = "3.0" +rust-ini = "0.20" +ashpd = "0.7.0" +xdg = "2.4.1" [target.'cfg(windows)'.dependencies] -winreg = "0.10" +winreg = "0.52.0" [target.'cfg(target_os = "macos")'.dependencies] objc = "0.2" [target.'cfg(target_arch = "wasm32")'.dependencies] -web-sys = { version = "0.3", features = [ "MediaQueryList", "Window" ] } +web-sys = { version = "0.3", features = ["MediaQueryList", "Window"] } diff --git a/examples/notify.rs b/examples/notify.rs new file mode 100644 index 0000000..3efa3d5 --- /dev/null +++ b/examples/notify.rs @@ -0,0 +1,10 @@ +use futures::StreamExt; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + while let Some(mode) = dark_light::subscribe().await?.next().await { + println!("System theme changed: {:?}", mode); + } + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 04b7420..069faee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,97 +1,73 @@ -//! Detect if dark mode or light mode is enabled. -//! -//! # Examples -//! -//! ``` -//! let mode = dark_light::detect(); -//! -//! match mode { -//! // Dark mode -//! dark_light::Mode::Dark => {}, -//! // Light mode -//! dark_light::Mode::Light => {}, -//! // Unspecified -//! dark_light::Mode::Default => {}, -//! } -//! ``` - -#[cfg(target_os = "macos")] -mod macos; -#[cfg(target_os = "macos")] -use macos as platform; - -#[cfg(target_os = "windows")] -mod windows; -#[cfg(target_os = "windows")] -use windows as platform; - -#[cfg(any( - target_os = "linux", - target_os = "freebsd", - target_os = "dragonfly", - target_os = "netbsd", - target_os = "openbsd" -))] -mod freedesktop; -#[cfg(any( - target_os = "linux", - target_os = "freebsd", - target_os = "dragonfly", - target_os = "netbsd", - target_os = "openbsd" -))] -use freedesktop as platform; - -#[cfg(target_arch = "wasm32")] -mod websys; -#[cfg(target_arch = "wasm32")] -use websys as platform; - -#[cfg(not(any( - target_os = "macos", - target_os = "windows", - target_os = "linux", - target_os = "freebsd", - target_os = "dragonfly", - target_os = "netbsd", - target_os = "openbsd", - target_arch = "wasm32" -)))] -mod platform { - pub fn detect() -> crate::Mode { - super::Mode::Light - } -} - -#[derive(Copy, Clone, PartialEq, Eq, Debug)] -pub enum Mode { - /// Dark mode - Dark, - /// Light mode - Light, - /// Unspecified - Default, -} - -impl Mode { - fn from(b: bool) -> Self { - if b { - Mode::Dark - } else { - Mode::Light - } - } - fn rgb(r: u32, g: u32, b: u32) -> Self { - let window_background_gray = (r * 11 + g * 16 + b * 5) / 32; - if window_background_gray < 192 { - Self::Dark - } else { - Self::Light - } - } -} - -/// Detect if light mode or dark mode is enabled. If the mode can’t be detected, fall back to [`Mode::Default`]. -pub fn detect() -> Mode { - platform::detect() -} +//! Detect if dark mode or light mode is enabled. +//! +//! # Examples +//! +//! ``` +//! let mode = dark_light::detect(); +//! +//! match mode { +//! // Dark mode +//! dark_light::Mode::Dark => {}, +//! // Light mode +//! dark_light::Mode::Light => {}, +//! // Unspecified +//! dark_light::Mode::Default => {}, +//! } +//! ``` + +mod platforms; +use platforms::platform; + +mod utils; +#[cfg(any( + target_os = "linux", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd" +))] +use utils::rgb::Rgb; + +/// Enum representing dark mode, light mode, or unspecified. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum Mode { + /// Dark mode + Dark, + /// Light mode + Light, + /// Unspecified + Default, +} + +impl Mode { + #[allow(dead_code)] + fn from_bool(b: bool) -> Self { + if b { + Mode::Dark + } else { + Mode::Light + } + } + + #[cfg(any( + target_os = "linux", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd" + ))] + /// Convert an RGB color to [`Mode`]. The color is converted to grayscale, and if the grayscale value is less than 192, [`Mode::Dark`] is returned. Otherwise, [`Mode::Light`] is returned. + fn from_rgb(rgb: Rgb) -> Self { + let window_background_gray = (rgb.0 * 11 + rgb.1 * 16 + rgb.2 * 5) / 32; + if window_background_gray < 192 { + Self::Dark + } else { + Self::Light + } + } +} + +/// Detect if light mode or dark mode is enabled. If the mode can’t be detected, fall back to [`Mode::Default`]. +pub use platform::detect::detect; +/// Notifies the user if the system theme has been changed. +pub use platform::notify::subscribe; diff --git a/src/platforms/freedesktop/detect.rs b/src/platforms/freedesktop/detect.rs new file mode 100644 index 0000000..738acff --- /dev/null +++ b/src/platforms/freedesktop/detect.rs @@ -0,0 +1,47 @@ +use detect_desktop_environment::DesktopEnvironment; + +use crate::Mode; + +use super::{dconf_detect, kde_detect, CINNAMON, GNOME, MATE}; + +pub fn detect() -> Mode { + NonFreeDesktop::detect() +} + +/// Detects the color scheme on a platform. +trait ColorScheme { + fn detect() -> Mode; +} + +/// Represents the FreeDesktop platform. +struct FreeDesktop; + +/// Represents non FreeDesktop platforms. +struct NonFreeDesktop; + +/// Detects the color scheme on FreeDesktop platforms. It makes use of the DBus interface. +impl ColorScheme for FreeDesktop { + fn detect() -> Mode { + todo!() + } +} + +/// Detects the color scheme on non FreeDesktop platforms, having a custom implementation for each desktop environment. +impl ColorScheme for NonFreeDesktop { + fn detect() -> Mode { + match DesktopEnvironment::detect() { + Some(mode) => match mode { + DesktopEnvironment::Kde => match kde_detect() { + Ok(mode) => mode, + Err(_) => Mode::Default, + }, + DesktopEnvironment::Cinnamon => dconf_detect(CINNAMON), + DesktopEnvironment::Gnome => dconf_detect(GNOME), + DesktopEnvironment::Mate => dconf_detect(MATE), + DesktopEnvironment::Unity => dconf_detect(GNOME), + _ => Mode::Default, + }, + None => Mode::Default, + } + } +} diff --git a/src/platforms/freedesktop/mod.rs b/src/platforms/freedesktop/mod.rs new file mode 100644 index 0000000..cd65e05 --- /dev/null +++ b/src/platforms/freedesktop/mod.rs @@ -0,0 +1,52 @@ +use std::str::FromStr; + +use anyhow::Context; +use ini::Ini; + +use crate::{utils::rgb::Rgb, Mode}; + +pub mod detect; +pub mod notify; + +const MATE: &str = "/org/mate/desktop/interface/gtk-theme"; +const GNOME: &str = "/org/gnome/desktop/interface/gtk-theme"; +const CINNAMON: &str = "/org/cinnamon/desktop/interface/gtk-theme"; + +fn dconf_detect(path: &str) -> Mode { + match dconf_rs::get_string(path) { + Ok(theme) => { + if theme.to_lowercase().contains("dark") { + Mode::Dark + } else { + Mode::Light + } + } + Err(_) => Mode::Default, + } +} + +fn kde_detect() -> anyhow::Result { + let xdg = xdg::BaseDirectories::new()?; + let path = xdg + .find_config_file("kdeglobals") + .context("Path not found")?; + let cfg = Ini::load_from_file(path)?; + let properties = cfg + .section(Some("Colors:Window")) + .context("Failed to get section Colors:Window")?; + let background = properties + .get("BackgroundNormal") + .context("Failed to get BackgroundNormal inside Colors:Window")?; + let rgb = Rgb::from_str(background).unwrap(); + Ok(Mode::from_rgb(rgb)) +} + +impl From for Mode { + fn from(value: ashpd::desktop::settings::ColorScheme) -> Self { + match value { + ashpd::desktop::settings::ColorScheme::NoPreference => Mode::Default, + ashpd::desktop::settings::ColorScheme::PreferDark => Mode::Dark, + ashpd::desktop::settings::ColorScheme::PreferLight => Mode::Light, + } + } +} diff --git a/src/platforms/freedesktop/notify.rs b/src/platforms/freedesktop/notify.rs new file mode 100644 index 0000000..c63fdc1 --- /dev/null +++ b/src/platforms/freedesktop/notify.rs @@ -0,0 +1,42 @@ +use ashpd::desktop::settings::{ColorScheme, Settings}; +use futures::{stream, Stream, StreamExt}; +use std::task::Poll; + +use crate::{detect, Mode}; + +pub async fn subscribe() -> anyhow::Result + Send> { + let stream = if get_freedesktop_color_scheme().await.is_ok() { + let proxy = Settings::new().await?; + proxy + .receive_color_scheme_changed() + .await? + .map(Mode::from) + .boxed() + } else { + let mut last_mode = detect(); + stream::poll_fn(move |ctx| -> Poll> { + let current_mode = detect(); + if current_mode != last_mode { + last_mode = current_mode; + Poll::Ready(Some(current_mode)) + } else { + ctx.waker().wake_by_ref(); + Poll::Pending + } + }) + .boxed() + }; + + Ok(stream) +} + +async fn get_freedesktop_color_scheme() -> anyhow::Result { + let proxy = Settings::new().await?; + let color_scheme = proxy.color_scheme().await?; + let mode = match color_scheme { + ColorScheme::PreferDark => Mode::Dark, + ColorScheme::PreferLight => Mode::Light, + ColorScheme::NoPreference => Mode::Default, + }; + Ok(mode) +} diff --git a/src/macos.rs b/src/platforms/macos/detect.rs similarity index 97% rename from src/macos.rs rename to src/platforms/macos/detect.rs index d55f0a2..5c56cb8 100644 --- a/src/macos.rs +++ b/src/platforms/macos/detect.rs @@ -52,5 +52,5 @@ fn is_dark_mode_enabled() -> bool { } pub fn detect() -> crate::Mode { - Mode::from(is_dark_mode_enabled()) + Mode::from_bool(is_dark_mode_enabled()) } diff --git a/src/platforms/macos/mod.rs b/src/platforms/macos/mod.rs new file mode 100644 index 0000000..bfae545 --- /dev/null +++ b/src/platforms/macos/mod.rs @@ -0,0 +1,2 @@ +pub mod detect; +pub mod notify; diff --git a/src/platforms/macos/notify.rs b/src/platforms/macos/notify.rs new file mode 100644 index 0000000..305205f --- /dev/null +++ b/src/platforms/macos/notify.rs @@ -0,0 +1,23 @@ +use std::task::Poll; + +use futures::{stream, Stream}; + +use crate::{detect, Mode}; + +pub async fn subscribe() -> anyhow::Result + Send> { + let mut last_mode = detect(); + + let stream = stream::poll_fn(move |ctx| -> Poll> { + let current_mode = detect(); + + if current_mode != last_mode { + last_mode = current_mode; + Poll::Ready(Some(current_mode)) + } else { + ctx.waker().wake_by_ref(); + Poll::Pending + } + }); + + Ok(stream) +} diff --git a/src/platforms/mod.rs b/src/platforms/mod.rs new file mode 100644 index 0000000..35037de --- /dev/null +++ b/src/platforms/mod.rs @@ -0,0 +1,48 @@ +#[cfg(target_os = "macos")] +pub mod macos; + +#[cfg(target_os = "macos")] +pub use macos as platform; + +#[cfg(target_os = "windows")] +pub mod windows; +#[cfg(target_os = "windows")] +pub use windows as platform; + +#[cfg(any( + target_os = "linux", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd" +))] +pub mod freedesktop; +#[cfg(any( + target_os = "linux", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd" +))] +pub use freedesktop as platform; + +#[cfg(target_arch = "wasm32")] +pub mod websys; +#[cfg(target_arch = "wasm32")] +pub use websys as platform; + +#[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd", + target_arch = "wasm32" +)))] +pub mod platform { + pub fn detect() -> crate::Mode { + super::Mode::Light + } +} diff --git a/src/websys.rs b/src/platforms/websys/detect.rs similarity index 83% rename from src/websys.rs rename to src/platforms/websys/detect.rs index 7f36712..33431e8 100644 --- a/src/websys.rs +++ b/src/platforms/websys/detect.rs @@ -4,7 +4,7 @@ pub fn detect() -> crate::Mode { if let Some(window) = web_sys::window() { let query_result = window.match_media("(prefers-color-scheme: dark)"); if let Ok(Some(mql)) = query_result { - return Mode::from(mql.matches()); + return Mode::from_bool(mql.matches()); } } Mode::Light diff --git a/src/platforms/websys/mod.rs b/src/platforms/websys/mod.rs new file mode 100644 index 0000000..bfae545 --- /dev/null +++ b/src/platforms/websys/mod.rs @@ -0,0 +1,2 @@ +pub mod detect; +pub mod notify; diff --git a/src/platforms/websys/notify.rs b/src/platforms/websys/notify.rs new file mode 100644 index 0000000..305205f --- /dev/null +++ b/src/platforms/websys/notify.rs @@ -0,0 +1,23 @@ +use std::task::Poll; + +use futures::{stream, Stream}; + +use crate::{detect, Mode}; + +pub async fn subscribe() -> anyhow::Result + Send> { + let mut last_mode = detect(); + + let stream = stream::poll_fn(move |ctx| -> Poll> { + let current_mode = detect(); + + if current_mode != last_mode { + last_mode = current_mode; + Poll::Ready(Some(current_mode)) + } else { + ctx.waker().wake_by_ref(); + Poll::Pending + } + }); + + Ok(stream) +} diff --git a/src/windows.rs b/src/platforms/windows/detect.rs similarity index 89% rename from src/windows.rs rename to src/platforms/windows/detect.rs index 725da61..39b9460 100644 --- a/src/windows.rs +++ b/src/platforms/windows/detect.rs @@ -8,7 +8,7 @@ pub fn detect() -> Mode { let hkcu = RegKey::predef(winreg::enums::HKEY_CURRENT_USER); if let Ok(subkey) = hkcu.open_subkey(SUBKEY) { if let Ok(dword) = subkey.get_value::(VALUE) { - return Mode::from(dword == 0); + return Mode::from_bool(dword == 0); } } Mode::Light diff --git a/src/platforms/windows/mod.rs b/src/platforms/windows/mod.rs new file mode 100644 index 0000000..bfae545 --- /dev/null +++ b/src/platforms/windows/mod.rs @@ -0,0 +1,2 @@ +pub mod detect; +pub mod notify; diff --git a/src/platforms/windows/notify.rs b/src/platforms/windows/notify.rs new file mode 100644 index 0000000..22a91fd --- /dev/null +++ b/src/platforms/windows/notify.rs @@ -0,0 +1,23 @@ +use std::task::Poll; + +use futures::{stream, Stream}; + +use crate::{detect, Mode}; + +pub async fn subscribe() -> anyhow::Result + Send> { + let mut last_mode = detect(); + + let stream = stream::poll_fn(move |ctx| -> Poll> { + let current_mode = detect(); + + if current_mode != last_mode { + last_mode = current_mode; + Poll::Ready(Some(current_mode)) + } else { + ctx.waker().wake_by_ref(); + Poll::Pending + } + }); + + Ok(stream) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..9d3d898 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod rgb; diff --git a/src/utils/rgb.rs b/src/utils/rgb.rs new file mode 100644 index 0000000..d43f62b --- /dev/null +++ b/src/utils/rgb.rs @@ -0,0 +1,23 @@ +use std::str::FromStr; + +/// Struct representing an RGB color +pub(crate) struct Rgb(pub(crate) u32, pub(crate) u32, pub(crate) u32); + +impl FromStr for Rgb { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let rgb = s + .split(',') + .map(|s| s.parse::().unwrap_or(255)) + .try_fold(vec![], |mut acc, x| { + if acc.len() < 3 { + acc.push(x); + Ok(acc) + } else { + Err(anyhow::anyhow!("RGB format is invalid")) + } + })?; + Ok(Rgb(rgb[0], rgb[1], rgb[2])) + } +}