commit e226b1870fb65e1a3a1e182149d04830eb546239
parent bda3fa80b703901f6f29e2bcafabd4859d9a1fce
Author: triesap <triesap@radroots.dev>
Date: Mon, 19 Jan 2026 07:59:54 +0000
app-lib: add theme helpers
- add theme mode and layer types
- add get_system_theme and theme_set helpers
- add theme error handling and unit tests
- enable media query support in web sys
Diffstat:
3 files changed, 132 insertions(+), 0 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
@@ -53,6 +53,7 @@ web-sys = { version = "0.3.77", features = [
"CacheStorage",
"Headers",
"Geolocation",
+ "MediaQueryList",
"Navigator",
"Notification",
"NotificationOptions",
diff --git a/crates/app-lib/src/lib.rs b/crates/app-lib/src/lib.rs
@@ -9,6 +9,7 @@ pub mod query;
pub mod sleep;
pub mod storage;
pub mod symbols;
+pub mod theme;
pub use browser::{browser_platform, BrowserPlatformInfo};
pub use fetch::{fetch_json, FetchJsonError, FetchJsonErrorKind, FetchJsonResult};
@@ -21,3 +22,6 @@ pub use storage::{build_storage_key, build_storage_key_with_prefix, fmt_id, fmt_
pub use symbols::{
fmt_cl, value_constrain, SYMBOL_BULLET, SYMBOL_DASH, SYMBOL_DOWN, SYMBOL_PERCENT, SYMBOL_UP,
};
+pub use theme::{
+ get_system_theme, parse_layer, theme_set, ThemeError, ThemeLayer, ThemeMode,
+};
diff --git a/crates/app-lib/src/theme.rs b/crates/app-lib/src/theme.rs
@@ -0,0 +1,127 @@
+#![forbid(unsafe_code)]
+
+use std::fmt;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ThemeMode {
+ Light,
+ Dark,
+}
+
+impl ThemeMode {
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ ThemeMode::Light => "light",
+ ThemeMode::Dark => "dark",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ThemeLayer {
+ Layer0,
+ Layer1,
+ Layer2,
+}
+
+impl ThemeLayer {
+ pub const fn as_u8(self) -> u8 {
+ match self {
+ ThemeLayer::Layer0 => 0,
+ ThemeLayer::Layer1 => 1,
+ ThemeLayer::Layer2 => 2,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ThemeError {
+ WindowUnavailable,
+ DocumentUnavailable,
+ ElementUnavailable,
+}
+
+impl fmt::Display for ThemeError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ ThemeError::WindowUnavailable => f.write_str("error.app.theme.window_unavailable"),
+ ThemeError::DocumentUnavailable => f.write_str("error.app.theme.document_unavailable"),
+ ThemeError::ElementUnavailable => f.write_str("error.app.theme.element_unavailable"),
+ }
+ }
+}
+
+impl std::error::Error for ThemeError {}
+
+pub fn parse_layer(layer: Option<i32>, fallback: Option<ThemeLayer>) -> ThemeLayer {
+ match layer {
+ Some(0) => ThemeLayer::Layer0,
+ Some(1) => ThemeLayer::Layer1,
+ Some(2) => ThemeLayer::Layer2,
+ _ => fallback.unwrap_or(ThemeLayer::Layer0),
+ }
+}
+
+pub fn get_system_theme(fallback: ThemeMode) -> ThemeMode {
+ #[cfg(target_arch = "wasm32")]
+ {
+ if let Some(window) = web_sys::window() {
+ if let Ok(Some(query)) = window.match_media("(prefers-color-scheme: dark)") {
+ if query.matches() {
+ return ThemeMode::Dark;
+ }
+ return ThemeMode::Light;
+ }
+ }
+ fallback
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ fallback
+ }
+}
+
+pub fn theme_set(theme_key: &str, color_mode: ThemeMode) -> Result<(), ThemeError> {
+ #[cfg(target_arch = "wasm32")]
+ {
+ let window = web_sys::window().ok_or(ThemeError::WindowUnavailable)?;
+ let document = window.document().ok_or(ThemeError::DocumentUnavailable)?;
+ let element = document.document_element().ok_or(ThemeError::ElementUnavailable)?;
+ let value = format!("{theme_key}_{}", color_mode.as_str());
+ element
+ .set_attribute("data-theme", &value)
+ .map_err(|_| ThemeError::ElementUnavailable)?;
+ Ok(())
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let _ = theme_key;
+ let _ = color_mode;
+ Err(ThemeError::WindowUnavailable)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{get_system_theme, parse_layer, theme_set, ThemeError, ThemeLayer, ThemeMode};
+
+ #[test]
+ fn parse_layer_handles_fallback() {
+ assert_eq!(parse_layer(Some(2), None).as_u8(), 2);
+ assert_eq!(
+ parse_layer(Some(4), Some(ThemeLayer::Layer1)),
+ ThemeLayer::Layer1
+ );
+ }
+
+ #[test]
+ fn get_system_theme_uses_fallback() {
+ assert_eq!(get_system_theme(ThemeMode::Dark), ThemeMode::Dark);
+ }
+
+ #[test]
+ fn theme_set_errors_on_non_wasm() {
+ let err = theme_set("radroots", ThemeMode::Light).expect_err("non-wasm error");
+ assert_eq!(err, ThemeError::WindowUnavailable);
+ }
+}