Add resolved appearance engine and paired theme settings

Resolve light, dark and automatic appearance centrally and publish live theme snapshots to native surfaces and the appearance portal. Add paired preset families, custom theme imports, accessibility transforms and contrast-safe transitions.

Refine the Settings gallery and Auto controls, move overrides under advanced customization, and share palette-aware switches and circular slider thumbs. Document configuration and include theme licenses and integration coverage.
This commit is contained in:
Samuel Onoja committed 2026-09-30 12:51:49 +01:00
1 parent 2bb4c086f5
commit 3d950a0411
58 files changed
+5556 -644

No files matched your search

+4
View File
@@ -6,6 +6,10 @@ license.workspace = true
rust-version = "1.95"
[dependencies]
jiff.workspace = true
kdl = "6.7.1"
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true
+92
View File
@@ -0,0 +1,92 @@
use serde::{Deserialize, Serialize};
use crate::theme::{Appearance, Tokens};
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Palette {
pub background: String,
pub bar: String,
pub surface: String,
pub text: String,
pub muted: String,
pub accent: String,
}
impl From<&Tokens> for Palette {
fn from(tokens: &Tokens) -> Self {
Self {
background: tokens.colors.surface_base.clone(),
bar: tokens.surface.bar.background.clone(),
surface: tokens.colors.surface_raised.clone(),
text: tokens.colors.text_primary.clone(),
muted: tokens.colors.text_muted.clone(),
accent: tokens.colors.accent.clone(),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Family {
pub id: String,
pub name: String,
pub light: Option<Palette>,
pub dark: Option<Palette>,
}
impl Family {
pub fn palette(&self, appearance: Appearance) -> Option<&Palette> {
match appearance {
Appearance::Light => self.light.as_ref(),
Appearance::Dark => self.dark.as_ref(),
}
}
pub fn availability(&self) -> &'static str {
match (self.light.is_some(), self.dark.is_some()) {
(true, true) => "light and dark",
(true, false) => "light only",
_ => "dark only",
}
}
}
pub const BUILTINS: [(&str, &str, &str, &str); 6] = [
("ferese-blue", "Ferese Blue", "ferese-blue-light", "ferese-blue"),
("catppuccin", "Catppuccin", "catppuccin-latte", "catppuccin-mocha"),
("gruvbox", "Gruvbox", "gruvbox-light", "gruvbox"),
("rose-pine", "Rosé Pine", "rose-pine-dawn", "rose-pine-moon"),
("tokyo-night", "Tokyo Night", "tokyo-night-day", "tokyo-night"),
("everforest", "Everforest", "everforest-light", "everforest-dark"),
];
pub fn family_id(preset: &str) -> &str {
BUILTINS
.iter()
.find(|row| row.2 == preset || row.3 == preset)
.map_or(preset, |row| row.0)
}
pub fn variant(id: &str, appearance: Appearance) -> Option<&'static str> {
if let Some(row) = BUILTINS.iter().find(|row| row.0 == id) {
return Some(if appearance == Appearance::Light { row.2 } else { row.3 });
}
match (id, appearance) {
("dracula", Appearance::Dark) => Some("dracula"),
("monochrome", Appearance::Dark) => Some("monochrome"),
("monokai", Appearance::Dark) => Some("monokai"),
("ayu-light", Appearance::Light) => Some("ayu-light"),
_ => None,
}
}
pub fn builtins() -> Vec<Family> {
BUILTINS
.iter()
.map(|(id, name, light, dark)| Family {
id: (*id).into(),
name: (*name).into(),
light: Some(Palette::from(&crate::theme::preset(light, Appearance::Light).unwrap())),
dark: Some(Palette::from(&crate::theme::preset(dark, Appearance::Dark).unwrap())),
})
.collect()
}
+152 -2
View File
@@ -1,3 +1,7 @@
pub mod families;
pub mod presets;
pub mod theme;
use std::fmt;
use std::path::PathBuf;
@@ -45,7 +49,7 @@ pub fn default_wallpaper() -> &'static str {
}
fn field(name: &str, parent: &str) -> String {
if parent == "commands" {
if matches!(parent, "commands" | "custom_themes") {
return name.to_owned();
}
@@ -210,6 +214,14 @@ impl Document {
&self.value
}
/// A runtime view; the original KDL remains available for comment-preserving edits.
pub fn with_theme(&self, theme: &theme::ResolvedTheme) -> Self {
let mut document = self.clone();
document.value["theme"] = serde_json::to_value(&theme.tokens).expect("serializable theme tokens");
document.value["theme"]["appearance"] = serde_json::to_value(theme.appearance).unwrap();
document
}
pub fn get(&self, path: &str) -> Option<&Value> {
let mut value = &self.value;
@@ -229,7 +241,26 @@ impl Document {
}
pub fn set(&mut self, path: &str, value: Value) -> Result<(), Error> {
if value.as_str() == Some("")
&& matches!(
path,
"theme.file" | "theme.light.file" | "theme.dark.file" | "theme.accent"
)
{
return self.unset(path);
}
let mut candidate = self.clone();
if matches!(
path,
"theme.mode"
| "theme.family"
| "theme.light.family"
| "theme.dark.family"
| "theme.light.preset"
| "theme.dark.preset"
) {
candidate.migrate_theme_selection()?;
}
let parts = path.split('.').collect::<Vec<_>>();
set_in(&mut candidate.doc, &parts, "", value)?;
format_document(&mut candidate.doc);
@@ -262,6 +293,67 @@ impl Document {
Ok(())
}
fn migrate_theme_selection(&mut self) -> Result<(), Error> {
if self.get("theme.mode").is_some() {
return Ok(());
}
if self.get("theme.light").is_some() || self.get("theme.dark").is_some() {
set_in(&mut self.doc, &["theme", "mode"], "", Value::String("dark".into()))?;
return self.refresh();
}
let Some(index) = self.doc.nodes().iter().position(|node| node.name().value() == "theme") else {
return Ok(());
};
if let Some(children) = self.doc.nodes_mut()[index].children_mut().as_mut() {
let mut moved = Vec::new();
for name in ["colors", "surface", "border", "focus-ring"] {
if let Some(index) = children.nodes().iter().position(|node| node.name().value() == name) {
moved.push(children.nodes_mut().remove(index));
}
}
if !moved.is_empty() {
let dark = section_mut(children, &["dark"], "theme")?;
dark.nodes_mut().extend(moved);
}
}
set_in(&mut self.doc, &["theme", "mode"], "", Value::String("dark".into()))?;
self.refresh()
}
pub fn unset(&mut self, path: &str) -> Result<(), Error> {
if self.get(path).is_none() {
return Ok(());
}
let mut candidate = self.clone();
let (name, parents) = path.rsplit_once('.').map_or((path, ""), |(p, n)| (n, p));
let doc = section_mut(
&mut candidate.doc,
&parents.split('.').filter(|s| !s.is_empty()).collect::<Vec<_>>(),
"",
)?;
if let Some(index) = doc
.nodes()
.iter()
.position(|node| field(node.name().value(), parents.rsplit('.').next().unwrap_or("")) == name)
{
let node = doc.nodes_mut().remove(index);
let mut comments = String::new();
collect_comments(&node, &mut comments);
if let Some(format) = doc.format_mut() {
format.trailing.push_str(&comments);
} else {
doc.set_format(kdl::KdlDocumentFormat {
leading: String::new(),
trailing: comments,
});
}
}
format_document(&mut candidate.doc);
candidate.refresh()?;
*self = candidate;
Ok(())
}
pub fn remove(&mut self, path: &str, index: usize) -> Result<(), Error> {
let mut candidate = self.clone();
let (last, parents) = path.rsplit_once('.').map_or((path, ""), |(p, l)| (l, p));
@@ -288,6 +380,37 @@ impl Document {
}
}
fn collect_comments(node: &KdlNode, output: &mut String) {
let mut keep = |text: &str| {
if text.contains("//") || text.contains("/*") {
output.push_str(text);
output.push('\n');
}
};
if let Some(format) = node.format() {
keep(&format.leading);
keep(&format.before_children);
keep(&format.before_terminator);
keep(&format.terminator);
keep(&format.trailing);
}
for entry in node.entries() {
if let Some(format) = entry.format() {
keep(&format.leading);
keep(&format.trailing);
}
}
if let Some(children) = node.children() {
if let Some(format) = children.format() {
keep(&format.leading);
keep(&format.trailing);
}
for child in children.nodes() {
collect_comments(child, output);
}
}
}
impl fmt::Display for Document {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.doc.fmt(f)
@@ -506,7 +629,7 @@ fn kdl_value(value: &Value) -> Result<KdlValue, Error> {
}
fn node_name(key: &str, parent: &str) -> String {
if parent == "commands" {
if matches!(parent, "commands" | "custom_themes") {
return key.into();
}
@@ -599,6 +722,33 @@ pub fn from_str<T: DeserializeOwned>(source: &str) -> Result<T, Error> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adopting_appearance_modes_preserves_legacy_colors_and_comments() {
let mut document = Document::parse(
"// my desktop\ntheme {\n colors {\n accent \"#123456\" // my accent\n }\n}\n",
)
.unwrap();
document.set("theme.mode", serde_json::json!("light")).unwrap();
assert_eq!(
document.get("theme.dark.colors.accent"),
Some(&serde_json::json!("#123456"))
);
assert!(document.get("theme.colors").is_none());
assert!(document.to_string().contains("// my accent"));
document.unset("theme.dark.colors").unwrap();
assert!(document.to_string().contains("// my accent"));
assert!(document.to_string().contains("// my desktop"));
Document::parse(&document.to_string()).unwrap();
}
#[test]
fn clearing_optional_theme_references_removes_them_without_empty_paths() {
let mut document = Document::parse("theme { file \"custom_theme.kdl\"; } ").unwrap();
document.set("theme.file", serde_json::json!("")).unwrap();
assert!(document.get("theme.file").is_none());
document.set("theme.file", serde_json::json!("")).unwrap();
}
#[test]
fn widget_output_arrays_and_command_names_survive_edits() {
let source = r#"commands {
+239
View File
@@ -0,0 +1,239 @@
use crate::theme::Appearance;
pub struct Preset {
pub id: &'static str,
pub appearance: Appearance,
pub name: &'static str,
pub description: &'static str,
pub base: &'static str,
pub raised: &'static str,
pub application_background: &'static str,
pub text: &'static str,
pub muted: &'static str,
pub accent: &'static str,
pub border: &'static str,
pub gradient_end: &'static str,
pub opacity: f64,
pub blur_radius: f64,
pub tint_strength: f64,
pub shadow_opacity: f64,
}
pub const PRESETS: [Preset; 12] = [
Preset {
appearance: Appearance::Dark,
id: "ferese-blue",
name: "Ferese Blue",
description: "Default",
base: "#111821",
raised: "#1E2530",
application_background: "#171E27",
text: "#F4F7FB",
muted: "#8793A2",
accent: "#3D7BE6",
border: "#FFFFFF18",
gradient_end: "#3D7BE6",
opacity: 0.78,
blur_radius: 12.0,
tint_strength: 0.5,
shadow_opacity: 0.2,
},
Preset {
appearance: Appearance::Dark,
id: "catppuccin-mocha",
name: "Catppuccin",
description: "Mocha",
base: "#1E1E2E",
raised: "#313244",
application_background: "#1E1E2E",
text: "#CDD6F4",
muted: "#A6ADC8",
accent: "#CBA6F7",
border: "#45475A88",
gradient_end: "#B4BEFE",
opacity: 0.84,
blur_radius: 18.0,
tint_strength: 0.62,
shadow_opacity: 0.2,
},
Preset {
appearance: Appearance::Dark,
id: "gruvbox",
name: "Gruvbox",
description: "Dark",
base: "#282828",
raised: "#3C3836",
application_background: "#282828",
text: "#EBDBB2",
muted: "#BDAE93",
accent: "#FE8019",
border: "#50494588",
gradient_end: "#FABD2F",
opacity: 0.86,
blur_radius: 14.0,
tint_strength: 0.66,
shadow_opacity: 0.22,
},
Preset {
appearance: Appearance::Dark,
id: "rose-pine-moon",
name: "Rosé Pine",
description: "Moon",
base: "#232136",
raised: "#2A273F",
application_background: "#232136",
text: "#E0DEF4",
muted: "#918DAA",
accent: "#EA9A97",
border: "#56526E88",
gradient_end: "#C4A7E7",
opacity: 0.88,
blur_radius: 18.0,
tint_strength: 0.7,
shadow_opacity: 0.18,
},
Preset {
appearance: Appearance::Dark,
id: "tokyo-night",
name: "Tokyo Night",
description: "Night",
base: "#1A1B26",
raised: "#292E42",
application_background: "#1A1B26",
text: "#C0CAF5",
muted: "#A9B1D6",
accent: "#7AA2F7",
border: "#41486888",
gradient_end: "#BB9AF7",
opacity: 0.85,
blur_radius: 20.0,
tint_strength: 0.66,
shadow_opacity: 0.22,
},
Preset {
appearance: Appearance::Dark,
id: "everforest-dark",
name: "Everforest",
description: "Dark · hard",
base: "#272E33",
raised: "#2E383C",
application_background: "#272E33",
text: "#D3C6AA",
muted: "#9DA9A0",
accent: "#A7C080",
border: "#49535B88",
gradient_end: "#83C092",
opacity: 0.9,
blur_radius: 14.0,
tint_strength: 0.7,
shadow_opacity: 0.18,
},
Preset {
appearance: Appearance::Light,
id: "ferese-blue-light",
name: "Ferese Blue",
description: "Default",
base: "#F5F7FB",
raised: "#E7E9ED",
application_background: "#EFF1F5",
text: "#182234",
muted: "#586579",
accent: "#3D7BE6",
border: "#18223430",
gradient_end: "#3D7BE6",
opacity: 0.94,
blur_radius: 10.0,
tint_strength: 1.0,
shadow_opacity: 0.1,
},
Preset {
appearance: Appearance::Light,
id: "catppuccin-latte",
name: "Catppuccin",
description: "Latte",
base: "#EFF1F5",
raised: "#E6E9EF",
application_background: "#EFF1F5",
text: "#4C4F69",
muted: "#5C5F77",
accent: "#8839EF",
border: "#9CA0B066",
gradient_end: "#7287FD",
opacity: 0.95,
blur_radius: 12.0,
tint_strength: 1.0,
shadow_opacity: 0.08,
},
Preset {
appearance: Appearance::Light,
id: "gruvbox-light",
name: "Gruvbox",
description: "Light",
base: "#FBF1C7",
raised: "#F2E5BC",
application_background: "#FBF1C7",
text: "#3C3836",
muted: "#665C54",
accent: "#AF3A03",
border: "#92837466",
gradient_end: "#B57614",
opacity: 0.96,
blur_radius: 9.0,
tint_strength: 1.0,
shadow_opacity: 0.11,
},
Preset {
appearance: Appearance::Light,
id: "rose-pine-dawn",
name: "Rosé Pine",
description: "Dawn",
base: "#FAF4ED",
raised: "#FFFAF3",
application_background: "#FAF4ED",
text: "#464261",
muted: "#716D89",
accent: "#B4637A",
border: "#CECACD66",
gradient_end: "#907AA9",
opacity: 0.96,
blur_radius: 12.0,
tint_strength: 1.0,
shadow_opacity: 0.07,
},
Preset {
appearance: Appearance::Light,
id: "tokyo-night-day",
name: "Tokyo Night",
description: "Day",
base: "#E1E2E7",
raised: "#D0D5E3",
application_background: "#E1E2E7",
text: "#3358B0",
muted: "#4D5A8C",
accent: "#2C76DD",
border: "#8990B366",
gradient_end: "#9854F1",
opacity: 0.96,
blur_radius: 10.0,
tint_strength: 1.0,
shadow_opacity: 0.1,
},
Preset {
appearance: Appearance::Light,
id: "everforest-light",
name: "Everforest",
description: "Light · hard",
base: "#FFFBEF",
raised: "#F8F5E4",
application_background: "#FFFBEF",
text: "#5C6A72",
muted: "#677367",
accent: "#849601",
border: "#939F9166",
gradient_end: "#35A77C",
opacity: 0.96,
blur_radius: 8.0,
tint_strength: 1.0,
shadow_opacity: 0.08,
},
];
File diff suppressed because it is too large. Load diff
+1
View File
@@ -8,3 +8,4 @@ rust-version.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
ferese-config = { path = "../ferese-config" }
+2
View File
@@ -5,6 +5,8 @@ use std::{fmt, io};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub mod theme;
pub const VERSION: u32 = 1;
pub const MAX_PAYLOAD_SIZE: usize = 1024 * 1024;
+89
View File
@@ -0,0 +1,89 @@
use std::io;
use std::net::Shutdown;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::Duration;
use ferese_config::theme::Snapshot;
use serde_json::{Value, json};
use crate::{Request, Response, VERSION, read_frame, write_frame};
pub fn socket_path() -> io::Result<PathBuf> {
std::env::var_os("XDG_RUNTIME_DIR")
.filter(|value| !value.is_empty())
.map(|directory| PathBuf::from(directory).join("ferese/control.sock"))
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set"))
}
pub struct Connection {
stream: UnixStream,
id: u64,
}
impl Connection {
pub fn connect() -> io::Result<Self> {
let stream = UnixStream::connect(socket_path()?)?;
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
Ok(Self { stream, id: 0 })
}
pub fn cancellation(&self) -> io::Result<Cancellation> {
self.stream.try_clone().map(Cancellation)
}
pub fn call(&mut self, command: &str, args: Value) -> Result<Value, String> {
self.id = self.id.wrapping_add(1);
let request = Request {
version: VERSION,
id: self.id,
kind: "command".into(),
command: command.into(),
args,
};
write_frame(&mut self.stream, &request).map_err(|e| e.to_string())?;
let response: Response = read_frame(&mut self.stream).map_err(|e| e.to_string())?;
if response.id != self.id || response.version != VERSION {
return Err("Unexpected theme IPC response".into());
}
if let Some(error) = response.error {
return Err(error.message);
}
response.result.ok_or_else(|| "Missing theme snapshot".into())
}
pub fn get(&mut self) -> Result<Snapshot, String> {
let snapshot: Snapshot =
serde_json::from_value(self.call("theme-get", json!({}))?).map_err(|e| e.to_string())?;
if snapshot.version != ferese_config::theme::SCHEMA_VERSION {
return Err("Unsupported theme snapshot version".into());
}
Ok(snapshot)
}
pub fn watch(&mut self, revision: u64) -> Result<Snapshot, String> {
self.stream.set_read_timeout(None).map_err(|e| e.to_string())?;
let snapshot: Snapshot =
serde_json::from_value(self.call("theme-watch", json!({"since": revision}))?).map_err(|e| e.to_string())?;
if snapshot.version != ferese_config::theme::SCHEMA_VERSION {
return Err("Unsupported theme snapshot version".into());
}
Ok(snapshot)
}
}
pub struct Cancellation(UnixStream);
impl Drop for Cancellation {
fn drop(&mut self) {
let _ = self.0.shutdown(Shutdown::Both);
}
}
pub fn current() -> Snapshot {
Connection::connect()
.ok()
.and_then(|mut connection| connection.get().ok())
.unwrap_or_default()
}
+6 -1
View File
@@ -8,6 +8,11 @@ rust-version = "1.95"
[dependencies]
jiff.workspace = true
ferese-config = { path = "../ferese-config" }
libcosmic.workspace = true
libcosmic = { workspace = true, features = ["a11y"] }
ferese-protocols = { path = "../ferese-protocols", default-features = false, features = ["client"] }
wayland-client.workspace = true
ferese-ipc = { path = "../ferese-ipc" }
serde_json.workspace = true
tokio = { version = "1", features = ["rt", "sync", "time"] }
iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" }
+2 -1
View File
@@ -4,8 +4,9 @@ The shared native UI theme for the shell, settings, lock screen, authentication
prompt and screen-sharing/recording chooser.
- `Palette` resolves KDL colors and corners and builds the native toolkit theme.
- `PRESETS` defines the six built-in color schemes.
- `PRESETS` defines twelve variants in six families.
- `font` and `text` provide shared font selection and text.
- `gallery` supplies split theme tiles, accessible radio groups and keyboard navigation.
- `controls` supplies surfaces, navigation, font-aware text buttons and input styles.
- `menus` supplies headings, rows, sections, separators, switches and slider styles.
- `calendar` supplies date/time labels, stacked clock digits and calendar grids.
+58 -1
View File
@@ -34,6 +34,36 @@ pub fn text_button<'a, M: Clone + 'a>(
.class(button_style(palette, selected))
}
pub fn switch<'a, M: Clone + 'a>(enabled: bool, palette: Palette) -> button::Button<'a, M> {
let paint = move |focused| button::Style {
background: None,
text_color: Some(palette.text),
icon_color: Some(palette.text),
border_radius: 12.into(),
border_width: if focused { 1. } else { 0. },
border_color: palette.accent,
..Default::default()
};
button::custom(crate::menus::switch(enabled, palette, 1.))
.padding(2)
.class(theme::Button::Custom {
active: Box::new(move |focused, _| paint(focused)),
hovered: Box::new(move |focused, _| paint(focused)),
pressed: Box::new(move |focused, _| paint(focused)),
disabled: Box::new(move |_| paint(false)),
})
}
pub(crate) fn switch_colors(palette: Palette, enabled: bool, hovered: bool) -> (Color, Color) {
let track = if enabled {
palette.accent
} else {
mix(palette.card, palette.text, if hovered { 0.18 } else { 0.12 })
};
let thumb = crate::foreground(track, palette.text);
(track, thumb)
}
pub fn navigation_style(p: Palette, selected: bool) -> theme::Button {
styled_button(p, selected, true)
}
@@ -48,7 +78,7 @@ pub fn settings_input(p: Palette) -> theme::TextInput {
icon_color: Some(p.muted),
text_color: Some(p.text),
placeholder_color: p.muted,
selected_text_color: p.sidebar,
selected_text_color: p.on_accent,
selected_fill: p.accent,
label_color: p.muted,
};
@@ -233,3 +263,30 @@ pub fn filled_button(fill: Color, foreground: Color, radius: f32, opacity: f32)
disabled: Box::new(move |_| paint(0.)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn switch_thumb_remains_readable_in_every_preset_and_state() {
for preset in crate::PRESETS {
let resolved = ferese_config::theme::ResolvedTheme {
appearance: preset.appearance,
tokens: ferese_config::theme::preset(preset.id, preset.appearance).unwrap(),
..Default::default()
};
let palette = Palette::from_resolved(&resolved);
for enabled in [false, true] {
for hovered in [false, true] {
let (track, thumb) = switch_colors(palette, enabled, hovered);
assert!(
crate::contrast(track, thumb) >= 4.5,
"{}: enabled={enabled}, hovered={hovered}",
preset.name,
);
}
}
}
}
}
+448
View File
@@ -0,0 +1,448 @@
use cosmic::iced::advanced::widget::{Id, Operation, Tree, tree};
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer};
use cosmic::iced::{Event, Length, Rectangle, Size, keyboard};
use cosmic::{Element, Renderer, Theme};
use ferese_config::families::{Family, Palette};
use ferese_config::theme::Appearance;
use iced_accessibility::{A11yTree, accesskit};
pub fn tile<M: Clone + 'static>(
family: &Family,
variant: Option<Appearance>,
active: Option<Appearance>,
selected: bool,
palette: crate::Palette,
font: cosmic::font::Font,
id: Id,
on_select: M,
navigate: impl Fn(&keyboard::key::Named) -> Option<M> + 'static,
) -> Element<'static, M> {
use cosmic::widget::{button, column, row};
let mut caption = row([])
.spacing(6)
.align_y(cosmic::iced::Alignment::Center)
.push(crate::text(family.name.clone(), font).size(12));
if family.light.is_none() || family.dark.is_none() {
caption = caption.push(
crate::text(
if family.light.is_none() {
"Dark only"
} else {
"Light only"
},
font,
)
.size(10)
.class(cosmic::theme::Text::Color(palette.muted)),
);
}
let image = preview(family, variant, active, selected);
let image =
cosmic::widget::responsive(move |size| image.clone().height(Length::Fixed(size.width * 126. / 200.)).into())
.height(Length::Shrink);
let content = column([]).spacing(2).push(image).push(caption);
let tile = button::custom(content)
.id(id.clone())
.name(format!("{}, {}", family.name, family.availability()))
.width(Length::Fill)
.padding(8)
.class(tile_style(palette, selected))
.on_press(on_select);
radio(tile, id, selected, navigate)
}
fn tile_style(palette: crate::Palette, selected: bool) -> cosmic::theme::Button {
let style = move |focused: bool, hovered: bool| cosmic::widget::button::Style {
background: Some(if hovered { palette.sidebar } else { palette.card }.into()),
border_width: if selected { 2. } else { 0. },
border_color: palette.accent,
border_radius: 10.into(),
outline_width: if focused { 2. } else { 0. },
outline_color: palette.text,
text_color: Some(palette.text),
icon_color: Some(palette.text),
..Default::default()
};
cosmic::theme::Button::Custom {
active: Box::new(move |focused, _| style(focused, false)),
hovered: Box::new(move |focused, _| style(focused, true)),
pressed: Box::new(move |focused, _| style(focused, true)),
disabled: Box::new(move |_| style(false, false)),
}
}
pub fn preview(
family: &Family,
variant: Option<Appearance>,
active: Option<Appearance>,
selected: bool,
) -> cosmic::widget::icon::Icon {
use cosmic::widget::icon;
let key = format!("{family:?}/{variant:?}/{active:?}/{selected}");
static CACHE: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, icon::Handle>>> =
std::sync::OnceLock::new();
let cache = CACHE.get_or_init(Default::default);
let mut cache = cache.lock().unwrap();
let handle = cache
.entry(key)
.or_insert_with(|| icon::from_svg_bytes(svg(family, variant, active, selected).into_bytes()).symbolic(false))
.clone();
// Bound cached previews when users repeatedly edit imported palettes.
if cache.len() > 128 {
cache.clear();
}
handle
.icon()
.width(Length::Fill)
.height(Length::Fixed(112.))
.content_fit(cosmic::iced::ContentFit::Fill)
}
fn desktop(p: &Palette) -> String {
format!(
r#"<rect width="200" height="126" fill="{}"/>
<rect x="8" y="8" width="184" height="14" rx="4" fill="{}" stroke="{}" stroke-opacity=".18"/>
<path d="M15 15h10m145 0h15" stroke="{}" stroke-width="2" stroke-linecap="round"/>
<rect x="24" y="35" width="152" height="77" rx="7" fill="{}"/>
<path d="M24 51h152" stroke="{}" stroke-opacity=".22"/>
<path d="M35 44h34M36 63h105m-105 10h90m-90 10h115" stroke="{}" stroke-width="2" stroke-linecap="round"/>
<rect x="76" y="92" width="48" height="10" rx="3" fill="{}"/>"#,
p.background, p.bar, p.muted, p.muted, p.surface, p.muted, p.text, p.accent
)
}
pub fn svg(family: &Family, variant: Option<Appearance>, active: Option<Appearance>, selected: bool) -> String {
let mut result = String::from(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="126" viewBox="0 0 200 126"><defs>
<clipPath id="light"><rect width="100" height="126"/></clipPath><clipPath id="dark"><rect x="100" width="100" height="126"/></clipPath>
<pattern id="missing" width="8" height="8" patternUnits="userSpaceOnUse"><rect width="8" height="8" fill="#D9DDE2"/><path d="M-2 2l4-4M0 8l8-8M6 10l4-4" stroke="#C4C9D1"/></pattern></defs>"##,
);
for appearance in [Appearance::Light, Appearance::Dark] {
if variant.is_some_and(|variant| variant != appearance) {
continue;
}
let clip = match (variant, appearance) {
(None, Appearance::Light) => "light",
(None, Appearance::Dark) => "dark",
_ => "",
};
result.push_str(&format!(
"<g{}>",
if clip.is_empty() {
String::new()
} else {
format!(" clip-path=\"url(#{clip})\"")
}
));
if let Some(palette) = family.palette(appearance) {
result.push_str(&desktop(palette));
if active == Some(appearance) {
let (x, width) = if variant.is_some() {
(8, 184)
} else if appearance == Appearance::Light {
(8, 84)
} else {
(108, 84)
};
result.push_str(&format!(
"<rect x=\"{x}\" y=\"120\" width=\"{width}\" height=\"3\" rx=\"1.5\" fill=\"{}\"/>",
palette.accent
));
}
} else {
let x = if variant.is_some() || appearance == Appearance::Light {
0
} else {
100
};
let width = if variant.is_some() { 200 } else { 100 };
let label = if appearance == Appearance::Light {
"No light variant"
} else {
"No dark variant"
};
result.push_str(&format!(r##"<rect x="{x}" width="{width}" height="126" fill="url(#missing)"/><text x="{}" y="68" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#353B45">{label}</text>"##, x + width / 2));
}
result.push_str("</g>");
}
if selected {
let p = family
.palette(active.unwrap_or(Appearance::Dark))
.or(family.dark.as_ref())
.or(family.light.as_ref())
.unwrap();
let foreground = ferese_config::theme::hex(ferese_config::theme::readable(
[1.; 4],
ferese_config::theme::rgba(&p.accent).unwrap(),
4.5,
));
result.push_str(&format!(r#"<circle cx="183" cy="16" r="10" fill="{}"/><path d="M178 16l3 3 6-6" fill="none" stroke="{foreground}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>"#, p.accent));
}
result.push_str("</svg>");
result
}
pub fn neighbor(index: usize, count: usize, key: &keyboard::key::Named) -> Option<usize> {
use keyboard::key::Named::*;
if count == 0 {
return None;
}
Some(match key {
ArrowRight => (index + 1) % count,
ArrowLeft => (index + count - 1) % count,
ArrowDown => {
if index + 3 < count {
index + 3
} else {
index % 3
}
}
ArrowUp => {
if index >= 3 {
index - 3
} else {
((count - 1 - index) / 3) * 3 + index
}
}
Enter => index,
Home => 0,
End => count - 1,
_ => return None,
})
}
#[derive(Default)]
struct Focus(bool);
impl cosmic::iced::advanced::widget::operation::Focusable for Focus {
fn is_focused(&self) -> bool {
self.0
}
fn focus(&mut self) {
self.0 = true;
}
fn unfocus(&mut self) {
self.0 = false;
}
}
pub struct Radio<'a, M> {
content: Element<'a, M>,
id: Id,
selected: bool,
group: bool,
label: String,
navigate: Option<Box<dyn Fn(&keyboard::key::Named) -> Option<M> + 'a>>,
}
pub fn radio<'a, M: Clone + 'a>(
content: impl Into<Element<'a, M>>,
id: Id,
selected: bool,
navigate: impl Fn(&keyboard::key::Named) -> Option<M> + 'a,
) -> Element<'a, M> {
Element::new(Radio {
content: content.into(),
id,
selected,
group: false,
label: String::new(),
navigate: Some(Box::new(navigate)),
})
}
pub fn group<'a, M: Clone + 'a>(content: impl Into<Element<'a, M>>, id: Id, label: &str) -> Element<'a, M> {
Element::new(Radio {
content: content.into(),
id,
selected: false,
group: true,
label: label.into(),
navigate: None,
})
}
impl<M: Clone> Widget<M, Theme, Renderer> for Radio<'_, M> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Focus>()
}
fn state(&self) -> tree::State {
tree::State::new(Focus::default())
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.content)]
}
fn diff(&mut self, tree: &mut Tree) {
tree.diff_children(std::slice::from_mut(&mut self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(&mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation<()>) {
if self.group {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
} else if self.selected {
operation.focusable(Some(&self.id), layout.bounds(), tree.state.downcast_mut::<Focus>());
} else {
tree.state.downcast_mut::<Focus>().0 = false;
}
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, M>,
viewport: &Rectangle,
) {
if !self.group {
if let Event::A11y(id, request) = event {
if !iced_accessibility::IdEq::eq(&id.0, &self.id.0) {
return;
}
if matches!(request.action, accesskit::Action::Focus | accesskit::Action::Click) {
if let Some(message) = self
.navigate
.as_ref()
.and_then(|navigate| navigate(&keyboard::key::Named::Enter))
{
shell.publish(message);
shell.capture_event();
return;
}
}
}
if matches!(event, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))) {
tree.state.downcast_mut::<Focus>().0 = self.selected && cursor.is_over(layout.bounds());
}
}
if !self.group && self.selected && tree.state.downcast_ref::<Focus>().0 {
if let Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(key),
..
}) = event
&& key.as_str() == " "
&& let Some(message) = self
.navigate
.as_ref()
.and_then(|navigate| navigate(&keyboard::key::Named::Enter))
{
shell.publish(message);
shell.capture_event();
return;
}
if let Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(key),
..
}) = event
{
if let Some(message) = self.navigate.as_ref().and_then(|navigate| navigate(key)) {
shell.publish(message);
shell.capture_event();
return;
}
}
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
if !self.group && self.selected && tree.state.downcast_ref::<Focus>().0 {
use renderer::Renderer as _;
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: cosmic::iced::Border {
color: style.text_color,
width: 2.,
radius: 10.into(),
},
..Default::default()
},
cosmic::iced::Color::TRANSPARENT,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
}
fn a11y_nodes(&self, layout: Layout<'_>, tree: &Tree, cursor: mouse::Cursor) -> A11yTree {
let children = self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor);
if self.group {
let mut node = accesskit::Node::new(accesskit::Role::RadioGroup);
node.set_label(self.label.clone());
A11yTree::node_with_child_tree(iced_accessibility::A11yNode::new(node, self.id.clone()), children)
} else {
let mut children = children;
for node in children.root_mut() {
node.node_mut().set_role(accesskit::Role::RadioButton);
node.node_mut().set_toggled(if self.selected {
accesskit::Toggled::True
} else {
accesskit::Toggled::False
});
}
children
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arrow_navigation_wraps_rows_and_columns() {
use keyboard::key::Named::*;
assert_eq!(neighbor(0, 6, &ArrowLeft), Some(5));
assert_eq!(neighbor(1, 6, &ArrowUp), Some(4));
assert_eq!(neighbor(4, 6, &ArrowDown), Some(1));
assert_eq!(neighbor(5, 6, &Home), Some(0));
assert_eq!(neighbor(0, 7, &End), Some(6));
}
#[test]
fn paired_previews_share_one_window_across_the_seam() {
let family = ferese_config::families::builtins().remove(1);
let svg = svg(&family, None, Some(Appearance::Dark), true);
assert_eq!(svg.matches("x=\"24\" y=\"35\"").count(), 2);
assert!(svg.contains("clip-path=\"url(#light)\""));
assert!(svg.contains("clip-path=\"url(#dark)\""));
}
}
+3 -1
View File
@@ -3,15 +3,17 @@ mod button;
pub mod calendar;
mod contrast;
pub mod controls;
pub mod gallery;
pub mod icons;
pub mod material;
pub mod menus;
mod palette;
mod presets;
pub mod service;
mod typography;
pub use button::accent_button;
pub use contrast::{accent_color, accent_pair, apply, composite, contrast, foreground, luminance};
pub use palette::{Palette, material_opacity, mix, parse_color, surface_shade};
pub use palette::{Palette, mix, parse_color, surface_shade};
pub use presets::{PRESETS, Preset};
pub use typography::{font, text};
+6 -28
View File
@@ -77,26 +77,11 @@ pub fn separator<'a, M: 'a>(foreground: Color, opacity: f32) -> Element<'a, M> {
}
pub fn switch<'a, M: 'a>(enabled: bool, palette: crate::Palette, opacity: f32) -> Element<'a, M> {
let track = if enabled {
palette.accent
} else {
Color {
a: 0.22,
..palette.text
}
};
let thumb = if enabled {
crate::foreground(track, palette.text)
} else {
palette.text
};
let (track, thumb) = crate::controls::switch_colors(palette, enabled, false);
let knob = container(cosmic::widget::Space::new().width(16).height(16))
.width(16)
.height(16)
.class(crate::controls::surface(
Color { a: opacity, ..thumb },
palette.radius.min(8.),
));
.class(crate::controls::surface(Color { a: opacity, ..thumb }, 8.));
container(knob)
.width(36)
.height(20)
@@ -106,14 +91,11 @@ pub fn switch<'a, M: 'a>(enabled: bool, palette: crate::Palette, opacity: f32) -
} else {
cosmic::iced::alignment::Horizontal::Left
})
.class(crate::controls::surface(
track.scale_alpha(opacity),
palette.radius.min(10.),
))
.class(crate::controls::surface(track.scale_alpha(opacity), 10.))
.into()
}
pub fn slider(foreground: Color, opacity: f32, radius: f32) -> cosmic::theme::iced::Slider {
pub fn slider(foreground: Color, opacity: f32) -> cosmic::theme::iced::Slider {
let style = std::rc::Rc::new(move |_: &cosmic::Theme| {
use cosmic::iced::widget::slider::{Breakpoint, Handle, HandleShape, Rail, Style};
Style {
@@ -128,16 +110,12 @@ pub fn slider(foreground: Color, opacity: f32, radius: f32) -> cosmic::theme::ic
),
width: 4.,
border: cosmic::iced::Border {
radius: radius.min(2.).into(),
radius: 2.into(),
..Default::default()
},
},
handle: Handle {
shape: HandleShape::Rectangle {
width: 10,
height: 10,
border_radius: radius.min(5.).into(),
},
shape: HandleShape::Circle { radius: 6. },
background: foreground.into(),
border_width: 0.,
border_color: Color::TRANSPARENT,
+35 -67
View File
@@ -1,12 +1,13 @@
use cosmic::iced::Color;
use ferese_config::Document;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Palette {
pub appearance: ferese_config::theme::Appearance,
pub background: Color,
pub sidebar: Color,
pub card: Color,
pub accent: Color,
pub on_accent: Color,
pub text: Color,
pub muted: Color,
pub error: Color,
@@ -14,33 +15,23 @@ pub struct Palette {
}
impl Palette {
pub fn from_document(document: Option<&Document>) -> Self {
let color = |key, fallback| {
document
.and_then(|document| document.get(key))
.and_then(|value| value.as_str())
.and_then(parse_color)
.unwrap_or_else(|| parse_color(fallback).expect("valid default theme color"))
};
let base = color("theme.colors.surface_base", "#111821");
pub fn from_resolved(theme: &ferese_config::theme::ResolvedTheme) -> Self {
let color = |value: &str| parse_color(value).expect("validated resolved theme color");
Self {
background: mix(base, surface_shade(base), 0.025),
sidebar: base,
card: mix(base, surface_shade(base), 0.055),
accent: color("theme.colors.accent", "#3D7BE6"),
text: color("theme.colors.text_primary", "#F4F7FB"),
muted: color("theme.colors.text_muted", "#8793A2"),
error: mix(base, Color::from_rgb8(210, 80, 80), 0.2),
radius: document
.and_then(|document| {
document
.get("theme.geometry.shell_radius")
.or_else(|| document.get("appearance.corner_radius"))
})
.and_then(|value| value.as_f64())
.filter(|value| value.is_finite())
.unwrap_or(14.)
.clamp(0., 64.) as f32,
appearance: theme.appearance,
background: color(&theme.tokens.colors.application_background),
sidebar: color(&theme.tokens.colors.surface_base),
card: color(&theme.tokens.colors.surface_raised),
accent: color(&theme.tokens.colors.accent),
on_accent: color(&theme.tokens.colors.on_accent),
text: color(&theme.tokens.colors.text_primary),
muted: color(&theme.tokens.colors.text_muted),
error: mix(
color(&theme.tokens.colors.surface_base),
Color::from_rgb8(210, 80, 80),
0.2,
),
radius: theme.tokens.geometry.shell_radius as f32,
}
}
@@ -52,7 +43,7 @@ impl Palette {
pub fn native_theme(self) -> cosmic::Theme {
let rgba = |c: Color| cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, 1.);
let builder = if crate::luminance(self.sidebar) > 0.5 {
let builder = if self.appearance == ferese_config::theme::Appearance::Light {
cosmic::cosmic_theme::ThemeBuilder::light()
} else {
cosmic::cosmic_theme::ThemeBuilder::dark()
@@ -70,9 +61,9 @@ impl Palette {
.bg_color(rgba(self.background))
.primary_container_bg(rgba(self.card))
.text_tint(rgba(self.text).color)
.accent(crate::accent_color(self.accent, self.card))
.accent(rgba(self.accent).color)
.build();
crate::apply(&mut native, self.text);
crate::apply(&mut native, self.on_accent);
cosmic::Theme::custom(std::sync::Arc::new(native))
}
@@ -113,22 +104,6 @@ pub fn surface_shade(base: Color) -> Color {
}
}
pub fn material_opacity(document: Option<&Document>) -> f32 {
let style = document
.and_then(|document| document.get("theme.material.style"))
.and_then(|value| value.as_str())
.unwrap_or("solid");
if style != "translucent" {
return 1.;
}
document
.and_then(|document| document.get("theme.material.opacity"))
.and_then(|value| value.as_f64())
.filter(|value| value.is_finite())
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY)
.clamp(0., 1.) as f32
}
#[cfg(test)]
mod tests {
use super::*;
@@ -136,23 +111,15 @@ mod tests {
#[test]
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
for preset in crate::PRESETS {
let document = Document::parse(&format!(
r##"theme {{
colors {{
surface-base "{}"
accent "{}"
text-primary "{}"
text-muted "{}"
}}
material {{ style translucent; opacity 0.25; }}
geometry {{ shell-radius 0; }}
}}"##,
preset.base, preset.accent, preset.text, preset.muted,
))
.unwrap();
let palette = Palette::from_document(Some(&document));
let mut resolved = ferese_config::theme::ResolvedTheme::default();
resolved.appearance = preset.appearance;
resolved.tokens = ferese_config::theme::preset(preset.id, preset.appearance).unwrap();
resolved.tokens.material.style = "translucent".into();
resolved.tokens.material.opacity = 0.25;
resolved.tokens.geometry.shell_radius = 0.;
let palette = Palette::from_resolved(&resolved);
let native = palette.native_theme();
assert_eq!(material_opacity(Some(&document)), 0.25);
assert_eq!(crate::service::opacity(&resolved), 0.25);
assert_eq!(native.cosmic().primary(false).base.alpha, 1.);
assert_eq!(native.cosmic().corner_radii.radius_m, [0.; 4]);
for component in [&native.cosmic().accent, &native.cosmic().accent_button] {
@@ -172,12 +139,13 @@ mod tests {
for (style, opacity, expected) in [
("solid", 0.4, 1.),
("translucent", 0.4, 0.4),
("translucent", 2., 1.),
("translucent", -1., 0.),
("translucent", 1., 1.),
("translucent", 0., 0.),
] {
let document =
Document::parse(&format!("theme {{ material {{ style {style}; opacity {opacity}; }} }}")).unwrap();
assert_eq!(material_opacity(Some(&document)), expected);
let mut theme = ferese_config::theme::ResolvedTheme::default();
theme.tokens.material.style = style.into();
theme.tokens.material.opacity = opacity;
assert_eq!(crate::service::opacity(&theme), expected);
}
}
}
+1 -73
View File
@@ -1,73 +1 @@
pub struct Preset {
pub name: &'static str,
pub description: &'static str,
pub accent: &'static str,
pub base: &'static str,
pub text: &'static str,
pub muted: &'static str,
pub border: &'static str,
pub gradient_end: &'static str,
}
pub const PRESETS: [Preset; 6] = [
Preset {
name: "Ferese Blue",
description: "Default · logo blue",
accent: "#3D7BE6",
base: "#111821",
text: "#F4F7FB",
muted: "#8793A2",
border: "#FFFFFF18",
gradient_end: "#3D7BE6",
},
Preset {
name: "Monochrome",
description: "Dark · grayscale",
accent: "#E5E5E5",
base: "#101012",
text: "#EDEDF0",
muted: "#97979F",
border: "#FFFFFF18",
gradient_end: "#696973",
},
Preset {
name: "Gruvbox",
description: "Dark · warm orange",
accent: "#FE8019",
base: "#282828",
text: "#EBDBB2",
muted: "#BDAE93",
border: "#504945",
gradient_end: "#FABD2F",
},
Preset {
name: "Dracula",
description: "Dark · purple",
accent: "#BD93F9",
base: "#282A36",
text: "#F8F8F2",
muted: "#A4ADCD",
border: "#44475A",
gradient_end: "#FF79C6",
},
Preset {
name: "Ayu Light",
description: "Light · paper",
accent: "#8F5300",
base: "#F8F9FA",
text: "#5C6166",
muted: "#596574",
border: "#C8CDD3",
gradient_end: "#996000",
},
Preset {
name: "Monokai",
description: "Dark · warm green",
accent: "#A6E22E",
base: "#272822",
text: "#F8F8F2",
muted: "#B2B29F",
border: "#414339",
gradient_end: "#E6DB74",
},
];
pub use ferese_config::presets::{PRESETS, Preset};
+57
View File
@@ -0,0 +1,57 @@
use cosmic::iced::futures::SinkExt;
use ferese_config::theme::Snapshot;
pub fn current() -> Snapshot {
ferese_ipc::theme::current()
}
pub fn opacity(theme: &ferese_config::theme::ResolvedTheme) -> f32 {
if theme.tokens.material.style == "translucent" {
theme.tokens.material.opacity as f32
} else {
1.
}
}
pub fn subscription() -> cosmic::iced::Subscription<Snapshot> {
cosmic::iced::Subscription::run(stream)
}
fn stream() -> impl cosmic::iced::futures::Stream<Item = Snapshot> {
cosmic::iced::stream::channel(1, async |mut output| {
loop {
let connection = tokio::task::spawn_blocking(ferese_ipc::theme::Connection::connect).await;
let Ok(Ok(mut connection)) = connection else {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
continue;
};
let Ok(cancellation) = connection.cancellation() else {
return;
};
let (send, mut receive) = tokio::sync::watch::channel(None);
tokio::task::spawn_blocking(move || {
let Ok(mut snapshot) = connection.get() else { return };
loop {
let revision = snapshot.revision;
if send.send(Some(snapshot)).is_err() {
return;
}
match connection.watch(revision) {
Ok(next) => snapshot = next,
Err(_) => return,
}
}
});
while receive.changed().await.is_ok() {
let snapshot = receive.borrow_and_update().clone();
if let Some(snapshot) = snapshot {
if output.send(snapshot).await.is_err() {
return;
}
}
}
drop(cancellation);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
})
}