Add native appearance, screenshot and wallpaper portals
This commit is contained in:
1 parent
d8a23748c8
commit
915bc8afd6
20 files changed
+1575
-33
No files matched your search
@@ -0,0 +1,191 @@
|
||||
use ferese_config::Document;
|
||||
use ferese_theme::Palette;
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use tokio::sync::RwLock;
|
||||
use zbus::{
|
||||
Connection,
|
||||
object_server::SignalEmitter,
|
||||
zvariant::{OwnedValue, Value},
|
||||
};
|
||||
|
||||
const APPEARANCE: &str = "org.freedesktop.appearance";
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
type Values = HashMap<String, HashMap<String, OwnedValue>>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Appearance {
|
||||
scheme: u32,
|
||||
accent: (f64, f64, f64),
|
||||
reduced_motion: u32,
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
fn from_document(document: Option<&Document>) -> Self {
|
||||
let palette = Palette::from_document(document);
|
||||
let animations = document
|
||||
.and_then(|document| document.get("animations.enabled"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
Self {
|
||||
scheme: if ferese_theme::luminance(palette.sidebar) > 0.5 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
},
|
||||
accent: (
|
||||
palette.accent.r as f64,
|
||||
palette.accent.g as f64,
|
||||
palette.accent.b as f64,
|
||||
),
|
||||
reduced_motion: u32::from(
|
||||
!animations
|
||||
|| document
|
||||
.and_then(|doc| doc.get("animations.reduced_motion"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn values(self) -> HashMap<String, OwnedValue> {
|
||||
HashMap::from([
|
||||
("color-scheme".into(), self.scheme.into()),
|
||||
(
|
||||
"accent-color".into(),
|
||||
Value::from(self.accent).try_to_owned().unwrap(),
|
||||
),
|
||||
("contrast".into(), 0u32.into()),
|
||||
("reduced-motion".into(), self.reduced_motion.into()),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Settings(Arc<RwLock<Appearance>>);
|
||||
|
||||
impl Settings {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self(Arc::new(RwLock::new(
|
||||
load().unwrap_or_else(|| Appearance::from_document(None)),
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) async fn watch(self, connection: Connection) {
|
||||
let emitter = SignalEmitter::new(&connection, PATH).unwrap();
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
let Some(next) = load() else { continue };
|
||||
let mut current = self.0.write().await;
|
||||
if *current == next {
|
||||
continue;
|
||||
}
|
||||
let previous = current.values();
|
||||
*current = next;
|
||||
drop(current);
|
||||
for (key, value) in next.values() {
|
||||
if previous.get(&key) != Some(&value) {
|
||||
let _ = Self::setting_changed(&emitter, APPEARANCE, &key, value).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[zbus::interface(name = "org.freedesktop.impl.portal.Settings")]
|
||||
impl Settings {
|
||||
#[zbus(property, name = "version")]
|
||||
fn version(&self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
async fn read_all(&self, namespaces: Vec<String>) -> Values {
|
||||
if matches_namespace(&namespaces, APPEARANCE) {
|
||||
HashMap::from([(APPEARANCE.into(), self.0.read().await.values())])
|
||||
} else {
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
|
||||
async fn read(&self, namespace: &str, key: &str) -> zbus::fdo::Result<OwnedValue> {
|
||||
if namespace == APPEARANCE {
|
||||
if let Some(value) = self.0.read().await.values().remove(key) {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
Err(zbus::fdo::Error::InvalidArgs("Unknown setting".into()))
|
||||
}
|
||||
|
||||
#[zbus(signal)]
|
||||
async fn setting_changed(
|
||||
emitter: &SignalEmitter<'_>,
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
value: OwnedValue,
|
||||
) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
fn matches_namespace(filters: &[String], namespace: &str) -> bool {
|
||||
filters.is_empty()
|
||||
|| filters.iter().any(|filter| {
|
||||
filter.is_empty()
|
||||
|| filter == namespace
|
||||
|| filter.strip_suffix(".*").is_some_and(|prefix| {
|
||||
namespace
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(|suffix| suffix.starts_with('.'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn load() -> Option<Appearance> {
|
||||
let path = ferese_config::config_path()?;
|
||||
let source = match std::fs::read_to_string(path) {
|
||||
Ok(source) if source.len() <= 1024 * 1024 => source,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Some(Appearance::from_document(None));
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let document = Document::parse(&source).ok()?;
|
||||
Some(Appearance::from_document(Some(&document)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn namespaces_support_only_trailing_section_wildcards() {
|
||||
for filter in [
|
||||
"",
|
||||
"org.freedesktop.appearance",
|
||||
"org.freedesktop.*",
|
||||
"org.*",
|
||||
] {
|
||||
assert!(matches_namespace(&[filter.into()], APPEARANCE));
|
||||
}
|
||||
for filter in [
|
||||
"org.freedesktop*",
|
||||
"org.*.appearance",
|
||||
"org.freedesktop.appearance.*",
|
||||
"other",
|
||||
] {
|
||||
assert!(!matches_namespace(&[filter.into()], APPEARANCE));
|
||||
}
|
||||
assert!(matches_namespace(&[], APPEARANCE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_ferese_theme_and_reduced_motion() {
|
||||
let doc = Document::parse("theme { colors { surface-base \"#ffffff\"; accent \"#ff8000\"; }; }; animations { enabled #false; }").unwrap();
|
||||
let appearance = Appearance::from_document(Some(&doc));
|
||||
assert_eq!(appearance.scheme, 2);
|
||||
assert_eq!(appearance.reduced_motion, 1);
|
||||
assert_eq!(appearance.accent.0, 1.);
|
||||
assert_eq!(appearance.accent.2, 0.);
|
||||
assert_eq!(
|
||||
u32::try_from(appearance.values().remove("contrast").unwrap()).unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user