Separate theme rendering from client subscriptions and materials

This commit is contained in:
Samuel Onoja committed 2026-10-03 00:12:59 +01:00
1 parent ccf7497b6f
commit cc0bddbec1
32 files changed
+428 -143

No files matched your search

+4 -6
View File
@@ -7,12 +7,10 @@ rust-version.workspace = true
[dependencies]
jiff.workspace = true
ferese-config = { path = "../ferese-config" }
ferese-theme-model = { path = "../ferese-theme-model" }
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" }
[dev-dependencies]
ferese-config = { path = "../ferese-config" }
+3 -3
View File
@@ -355,7 +355,7 @@ mod tests {
use cosmic::widget::button::Catalog;
let palette = Palette {
appearance: ferese_config::theme::Appearance::Dark,
appearance: ferese_theme_model::Appearance::Dark,
high_contrast: false,
background: Color::from_rgb8(17, 24, 33),
sidebar: Color::from_rgb8(17, 24, 33),
@@ -387,8 +387,8 @@ mod tests {
#[test]
fn switch_thumb_remains_readable_in_every_preset_and_state() {
for preset in crate::PRESETS {
let resolved = ferese_config::theme::ResolvedTheme {
for preset in ferese_config::presets::PRESETS {
let resolved = ferese_theme_model::ResolvedTheme {
appearance: preset.appearance,
tokens: ferese_config::theme::preset(preset.id, preset.appearance).unwrap(),
..ferese_config::theme::default_theme()
+4 -4
View File
@@ -2,8 +2,8 @@ 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 ferese_theme_model::Appearance;
use ferese_theme_model::families::{Family, Palette};
use iced_accessibility::{A11yTree, accesskit};
pub struct TileOptions {
@@ -184,9 +184,9 @@ pub fn svg(family: &Family, variant: Option<Appearance>, active: Option<Appearan
.or(family.dark.as_ref())
.or(family.light.as_ref())
.unwrap();
let foreground = ferese_config::theme::hex(ferese_config::theme::readable(
let foreground = ferese_theme_model::hex(ferese_theme_model::readable(
[1.; 4],
ferese_config::theme::rgba(&p.accent).unwrap(),
ferese_theme_model::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));
+2 -6
View File
@@ -1,4 +1,4 @@
//! Shared colors, typography, controls and compositor materials for Ferese applications.
//! Rendering helpers for supplied theme values: colors, typography and controls.
mod button;
pub mod calendar;
mod contrast;
@@ -6,16 +6,12 @@ pub mod controls;
pub mod gallery;
mod geometry;
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 geometry::inner_radius;
pub use palette::{Palette, mix, parse_color, surface_shade};
pub use presets::{PRESETS, Preset};
pub use palette::{Palette, material_opacity, mix, parse_color, surface_shade};
pub use typography::{font, text};
-113
View File
@@ -1,113 +0,0 @@
use std::sync::Arc;
use ferese_protocols::material::v1::client::ferese_material_manager_v1::FereseMaterialManagerV1;
use ferese_protocols::material::v1::client::ferese_surface_material_v1::FereseSurfaceMaterialV1;
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::{wl_registry, wl_surface};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, delegate_noop};
/// Keeps a compositor-owned dialog material attached for the window lifetime.
#[derive(Clone, Debug)]
pub struct ModalMaterial {
_binding: Arc<Binding>,
}
#[derive(Debug)]
struct Binding {
connection: Connection,
manager: FereseMaterialManagerV1,
material: FereseSurfaceMaterialV1,
}
impl ModalMaterial {
/// Attach while Iced owns the native window; keep its background transparent on success.
/// Other compositors return an error so the caller can retain its own surface fill.
pub fn attach(window: &dyn cosmic::iced::window::Window) -> Result<Self, String> {
use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle};
let display = window.display_handle().map_err(|error| error.to_string())?;
let handle = window.window_handle().map_err(|error| error.to_string())?;
let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) =
(display.as_raw(), handle.as_raw())
else {
return Err("Dialog materials require Wayland".into());
};
// Iced lends live native handles; this backend borrows its connection.
let backend =
unsafe { wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast()) };
let connection = Connection::from_backend(backend);
let id = unsafe {
wayland_client::backend::ObjectId::from_ptr(
wl_surface::WlSurface::interface(),
handle.surface.as_ptr().cast(),
)
}
.map_err(|error| error.to_string())?;
let surface = wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
let (globals, mut queue) =
registry_queue_init::<MaterialState>(&connection).map_err(|error| error.to_string())?;
let qh = queue.handle();
let manager = globals
.bind::<FereseMaterialManagerV1, _, _>(&qh, 1..=1, ())
.map_err(|error| error.to_string())?;
let material = manager.get_surface_material(&surface, &qh, ());
let binding = Binding {
connection,
manager,
material,
};
let mut state = MaterialState::default();
queue.roundtrip(&mut state).map_err(|error| error.to_string())?;
if !state.ready {
return Err("Dialog material attachment was not acknowledged".into());
}
Ok(Self {
_binding: Arc::new(binding),
})
}
}
impl Drop for Binding {
fn drop(&mut self) {
self.material.destroy();
self.manager.destroy();
let _ = self.connection.flush();
}
}
#[derive(Default)]
struct MaterialState {
ready: bool,
}
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for MaterialState {
fn event(
_state: &mut Self,
_registry: &wl_registry::WlRegistry,
_event: wl_registry::Event,
_data: &GlobalListContents,
_connection: &Connection,
_qh: &QueueHandle<Self>,
) {
}
}
impl Dispatch<FereseSurfaceMaterialV1, ()> for MaterialState {
fn event(
state: &mut Self,
_material: &FereseSurfaceMaterialV1,
event: ferese_protocols::material::v1::client::ferese_surface_material_v1::Event,
_data: &(),
_connection: &Connection,
_qh: &QueueHandle<Self>,
) {
if matches!(
event,
ferese_protocols::material::v1::client::ferese_surface_material_v1::Event::Ready
) {
state.ready = true;
}
}
}
delegate_noop!(MaterialState: ignore FereseMaterialManagerV1);
+18 -10
View File
@@ -2,7 +2,7 @@ use cosmic::iced::Color;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Palette {
pub appearance: ferese_config::theme::Appearance,
pub appearance: ferese_theme_model::Appearance,
pub high_contrast: bool,
pub background: Color,
pub sidebar: Color,
@@ -16,7 +16,7 @@ pub struct Palette {
}
impl Palette {
pub fn from_resolved(theme: &ferese_config::theme::ResolvedTheme) -> Self {
pub fn from_resolved(theme: &ferese_theme_model::ResolvedTheme) -> Self {
let color = |value: &str| parse_color(value).expect("validated resolved theme color");
Self {
appearance: theme.appearance,
@@ -46,7 +46,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.);
use cosmic::cosmic_theme::ThemeBuilder;
use ferese_config::theme::Appearance;
use ferese_theme_model::Appearance;
let builder = match (self.appearance, self.high_contrast) {
(Appearance::Light, false) => ThemeBuilder::light(),
(Appearance::Dark, false) => ThemeBuilder::dark(),
@@ -109,6 +109,14 @@ pub fn surface_shade(base: Color) -> Color {
}
}
pub fn material_opacity(theme: &ferese_theme_model::ResolvedTheme) -> f32 {
if theme.tokens.material.style == "translucent" {
theme.tokens.material.opacity as f32
} else {
1.
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -116,11 +124,11 @@ mod tests {
#[test]
fn native_theme_preserves_the_resolved_accessibility_mode() {
for appearance in [
ferese_config::theme::Appearance::Light,
ferese_config::theme::Appearance::Dark,
ferese_theme_model::Appearance::Light,
ferese_theme_model::Appearance::Dark,
] {
for high_contrast in [false, true] {
let mut resolved = ferese_config::theme::ResolvedTheme {
let mut resolved = ferese_theme_model::ResolvedTheme {
appearance,
..ferese_config::theme::default_theme()
};
@@ -134,8 +142,8 @@ mod tests {
#[test]
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
for preset in crate::PRESETS {
let mut resolved = ferese_config::theme::ResolvedTheme {
for preset in ferese_config::presets::PRESETS {
let mut resolved = ferese_theme_model::ResolvedTheme {
appearance: preset.appearance,
tokens: ferese_config::theme::preset(preset.id, preset.appearance).unwrap(),
..ferese_config::theme::default_theme()
@@ -145,7 +153,7 @@ mod tests {
resolved.tokens.geometry.shell_radius = 0.;
let palette = Palette::from_resolved(&resolved);
let native = palette.native_theme();
assert_eq!(crate::service::opacity(&resolved), 0.25);
assert_eq!(crate::material_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] {
@@ -171,7 +179,7 @@ mod tests {
let mut theme = ferese_config::theme::default_theme();
theme.tokens.material.style = style.into();
theme.tokens.material.opacity = opacity;
assert_eq!(crate::service::opacity(&theme), expected);
assert_eq!(crate::material_opacity(&theme), expected);
}
}
}
-1
View File
@@ -1 +0,0 @@
pub use ferese_config::presets::{PRESETS, Preset};
-78
View File
@@ -1,78 +0,0 @@
use cosmic::iced::futures::SinkExt;
use ferese_ipc::theme::Snapshot;
pub fn current() -> Snapshot {
ferese_ipc::theme::Connection::connect()
.ok()
.and_then(|mut connection| connection.get(ferese_config::families::builtins).ok())
.unwrap_or_else(fallback)
}
/// Standalone appearance; it does not represent compositor acceptance.
pub fn fallback() -> Snapshot {
let theme = ferese_config::theme::default_theme();
Snapshot {
version: ferese_ipc::theme::SCHEMA_VERSION,
revision: 0,
mode: ferese_config::theme::Mode::Dark,
presented: theme.clone(),
theme,
warnings: Vec::new(),
error: None,
families: ferese_config::families::builtins(),
fallback_note: None,
}
}
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(ferese_config::families::builtins) else {
return;
};
loop {
let revision = snapshot.revision;
if send.send(Some(snapshot)).is_err() {
return;
}
match connection.watch(revision, ferese_config::families::builtins) {
Ok(next) => snapshot = next,
Err(_) => return,
}
}
});
while receive.changed().await.is_ok() {
let snapshot = receive.borrow_and_update().clone();
if let Some(snapshot) = snapshot
&& output.send(snapshot).await.is_err()
{
return;
}
}
drop(cancellation);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
})
}
-266
View File
@@ -1,266 +0,0 @@
{
"version": 2,
"revision": 0,
"mode": "dark",
"theme": {
"appearance": "dark",
"tokens": {
"colors": {
"surface_base": "#111821",
"surface_raised": "#1E2530",
"application_background": "#171E27",
"text_primary": "#F4F7FB",
"text_muted": "#8793A2",
"accent": "#3D7BE6",
"on_accent": "#FFFFFF",
"border": "#FFFFFF18",
"shadow": "#00000055"
},
"material": {
"style": "solid",
"opacity": 0.78,
"blur_radius": 12.0,
"tint_strength": 0.5
},
"geometry": {
"border_width": 1.0,
"focus_ring_width": 2.0,
"window_radius": 14.0,
"shell_radius": 14.0,
"top_bar_height": 28.0,
"top_bar_margin_top": 0,
"top_bar_margin_horizontal": 0,
"top_bar_window_gap": 0,
"panel_padding": 12.0,
"control_gap": 12.0
},
"typography": {
"font_family": "Inter"
},
"background": {
"path": "<wallpaper>",
"lock_path": null,
"mode": "fill"
},
"surface": {
"bar": {
"background": "#111821",
"text_primary": "#F4F7FB",
"text_muted": "#8793A2"
}
},
"shadow": {
"soft": {
"offset_y": 4.0,
"blur": 18.0,
"opacity": 0.2
}
},
"border": {
"gradient": null
},
"focus_ring": {
"gradient": null
}
},
"requested_accent": "#3D7BE6",
"accessibility": {
"increase_contrast": false,
"reduce_transparency": false
},
"reduced_motion": false
},
"presented": {
"appearance": "dark",
"tokens": {
"colors": {
"surface_base": "#111821",
"surface_raised": "#1E2530",
"application_background": "#171E27",
"text_primary": "#F4F7FB",
"text_muted": "#8793A2",
"accent": "#3D7BE6",
"on_accent": "#FFFFFF",
"border": "#FFFFFF18",
"shadow": "#00000055"
},
"material": {
"style": "solid",
"opacity": 0.78,
"blur_radius": 12.0,
"tint_strength": 0.5
},
"geometry": {
"border_width": 1.0,
"focus_ring_width": 2.0,
"window_radius": 14.0,
"shell_radius": 14.0,
"top_bar_height": 28.0,
"top_bar_margin_top": 0,
"top_bar_margin_horizontal": 0,
"top_bar_window_gap": 0,
"panel_padding": 12.0,
"control_gap": 12.0
},
"typography": {
"font_family": "Inter"
},
"background": {
"path": "<wallpaper>",
"lock_path": null,
"mode": "fill"
},
"surface": {
"bar": {
"background": "#111821",
"text_primary": "#F4F7FB",
"text_muted": "#8793A2"
}
},
"shadow": {
"soft": {
"offset_y": 4.0,
"blur": 18.0,
"opacity": 0.2
}
},
"border": {
"gradient": null
},
"focus_ring": {
"gradient": null
}
},
"requested_accent": "#3D7BE6",
"accessibility": {
"increase_contrast": false,
"reduce_transparency": false
},
"reduced_motion": false
},
"warnings": [],
"error": null,
"families": [
{
"id": "ferese-blue",
"name": "Ferese Blue",
"light": {
"background": "#F5F7FB",
"bar": "#F5F7FB",
"surface": "#E7E9ED",
"text": "#182234",
"muted": "#586579",
"accent": "#3D7BE6"
},
"dark": {
"background": "#111821",
"bar": "#111821",
"surface": "#1E2530",
"text": "#F4F7FB",
"muted": "#8793A2",
"accent": "#3D7BE6"
}
},
{
"id": "catppuccin",
"name": "Catppuccin",
"light": {
"background": "#EFF1F5",
"bar": "#EFF1F5",
"surface": "#E6E9EF",
"text": "#4C4F69",
"muted": "#5C5F77",
"accent": "#8839EF"
},
"dark": {
"background": "#1E1E2E",
"bar": "#1E1E2E",
"surface": "#313244",
"text": "#CDD6F4",
"muted": "#A6ADC8",
"accent": "#CBA6F7"
}
},
{
"id": "gruvbox",
"name": "Gruvbox",
"light": {
"background": "#FBF1C7",
"bar": "#FBF1C7",
"surface": "#F2E5BC",
"text": "#3C3836",
"muted": "#665C54",
"accent": "#AF3A03"
},
"dark": {
"background": "#282828",
"bar": "#282828",
"surface": "#3C3836",
"text": "#EBDBB2",
"muted": "#BDAE93",
"accent": "#FE8019"
}
},
{
"id": "rose-pine",
"name": "Rosé Pine",
"light": {
"background": "#FAF4ED",
"bar": "#FAF4ED",
"surface": "#FFFAF3",
"text": "#464261",
"muted": "#716D89",
"accent": "#B4637A"
},
"dark": {
"background": "#232136",
"bar": "#232136",
"surface": "#2A273F",
"text": "#E0DEF4",
"muted": "#918DAA",
"accent": "#EA9A97"
}
},
{
"id": "tokyo-night",
"name": "Tokyo Night",
"light": {
"background": "#E1E2E7",
"bar": "#E1E2E7",
"surface": "#D0D5E3",
"text": "#3358B0",
"muted": "#4D5A8C",
"accent": "#2C76DD"
},
"dark": {
"background": "#1A1B26",
"bar": "#1A1B26",
"surface": "#292E42",
"text": "#C0CAF5",
"muted": "#A9B1D6",
"accent": "#7AA2F7"
}
},
{
"id": "everforest",
"name": "Everforest",
"light": {
"background": "#FFFBEF",
"bar": "#FFFBEF",
"surface": "#F8F5E4",
"text": "#5C6A72",
"muted": "#677367",
"accent": "#849601"
},
"dark": {
"background": "#272E33",
"bar": "#272E33",
"surface": "#2E383C",
"text": "#D3C6AA",
"muted": "#9DA9A0",
"accent": "#A7C080"
}
}
],
"fallback_note": null
}
-63
View File
@@ -1,63 +0,0 @@
use ferese_ipc::theme::Snapshot;
use ferese_theme::service::fallback;
use serde_json::{Value, json};
fn fixture() -> Value {
serde_json::from_str(include_str!("fixtures/theme-v2.json")).unwrap()
}
#[test]
fn default_snapshot_preserves_wire_values_and_environment_wallpaper() {
let mut snapshot = fallback();
let wallpaper = Some(ferese_config::default_wallpaper().into());
assert_eq!(snapshot.theme.tokens.background.path, wallpaper);
assert_eq!(snapshot.presented.tokens.background.path, wallpaper);
snapshot.theme.tokens.background.path = Some("<wallpaper>".into());
snapshot.presented.tokens.background.path = Some("<wallpaper>".into());
assert_eq!(serde_json::to_value(snapshot).unwrap(), fixture());
}
#[test]
fn older_snapshots_supply_catalog_but_explicit_empty_catalog_stays_empty() {
let mut value = fixture();
value.as_object_mut().unwrap().remove("families");
value.as_object_mut().unwrap().remove("fallback_note");
let snapshot: Snapshot = Snapshot::decode(value.clone(), ferese_config::families::builtins).unwrap();
assert_eq!(snapshot.families, ferese_config::families::builtins());
assert_eq!(snapshot.fallback_note, None);
value["families"] = json!([]);
assert!(
Snapshot::decode(value.clone(), ferese_config::families::builtins)
.unwrap()
.families
.is_empty()
);
value["families"] = Value::Null;
assert!(Snapshot::decode(value, ferese_config::families::builtins).is_err());
}
#[test]
fn offline_preview_resolves_without_changing_published_values() {
let before = fallback();
let document = ferese_config::Document::parse("theme { mode \"light\"; family \"gruvbox\"; }\n").unwrap();
let candidate = ferese_config::theme::resolve(
&document,
std::path::Path::new("/unused"),
"2026-10-02T12:00:00Z".parse().unwrap(),
|_| panic!("built-in preview must not read theme files"),
)
.unwrap();
assert_eq!(candidate.theme.appearance, ferese_config::theme::Appearance::Light);
assert_eq!(candidate.theme.tokens.colors.surface_base, "#FBF1C7");
assert_eq!(before, fallback());
}
#[test]
fn complete_snapshots_do_not_resolve_fallbacks_and_future_versions_are_rejected() {
let value = fixture();
let snapshot = Snapshot::decode(value.clone(), || panic!("unexpected catalog resolution")).unwrap();
assert_eq!(serde_json::to_value(snapshot).unwrap(), value);
let mut future = value;
future["version"] = json!(ferese_ipc::theme::SCHEMA_VERSION + 1);
assert_eq!(Snapshot::decode(future, Vec::new).unwrap_err(), "Unsupported theme snapshot version");
}