Separate theme rendering from client subscriptions and materials
This commit is contained in:
1 parent
ccf7497b6f
commit
cc0bddbec1
32 files changed
+428
-143
No files matched your search
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "ferese-theme-client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ferese-config = { path = "../ferese-config" }
|
||||
ferese-ipc = { path = "../ferese-ipc" }
|
||||
ferese-protocols = { path = "../ferese-protocols", default-features = false, features = ["client"] }
|
||||
libcosmic.workspace = true
|
||||
wayland-client.workspace = true
|
||||
tokio = { version = "1", features = ["rt", "sync", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Theme subscriptions and window-scoped material bindings for Ferese clients.
|
||||
//! Applications own subscription cancellation and keep bindings within window lifetimes.
|
||||
pub mod material;
|
||||
pub mod service;
|
||||
@@ -0,0 +1,175 @@
|
||||
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);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmic::iced::window::raw_window_handle::{
|
||||
DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WaylandDisplayHandle, WaylandWindowHandle,
|
||||
WindowHandle,
|
||||
};
|
||||
use std::ptr::NonNull;
|
||||
use wayland_client::protocol::wl_compositor;
|
||||
|
||||
// Supplies the same borrowed Wayland handles used by Iced, backed by a real client.
|
||||
struct Window {
|
||||
connection: Connection,
|
||||
surface: wl_surface::WlSurface,
|
||||
}
|
||||
impl HasDisplayHandle for Window {
|
||||
fn display_handle(&self) -> Result<DisplayHandle<'_>, HandleError> {
|
||||
let pointer = NonNull::new(self.connection.backend().display_ptr().cast()).unwrap();
|
||||
// The connection outlives the returned handle.
|
||||
Ok(unsafe { DisplayHandle::borrow_raw(WaylandDisplayHandle::new(pointer).into()) })
|
||||
}
|
||||
}
|
||||
impl HasWindowHandle for Window {
|
||||
fn window_handle(&self) -> Result<WindowHandle<'_>, HandleError> {
|
||||
let pointer = NonNull::new(self.surface.id().as_ptr().cast()).unwrap();
|
||||
// The surface outlives the returned handle.
|
||||
Ok(unsafe { WindowHandle::borrow_raw(WaylandWindowHandle::new(pointer).into()) })
|
||||
}
|
||||
}
|
||||
delegate_noop!(MaterialState: ignore wl_compositor::WlCompositor);
|
||||
delegate_noop!(MaterialState: ignore wl_surface::WlSurface);
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a private nested Ferese compositor"]
|
||||
fn attachment_acknowledgement_and_last_clone_release() {
|
||||
assert_eq!(std::env::var("FERESE_TEST_MATERIAL").as_deref(), Ok("1"));
|
||||
let connection = Connection::connect_to_env().unwrap();
|
||||
let (globals, mut queue) = registry_queue_init::<MaterialState>(&connection).unwrap();
|
||||
let qh = queue.handle();
|
||||
let compositor: wl_compositor::WlCompositor = globals.bind(&qh, 1..=6, ()).unwrap();
|
||||
let window = Window {
|
||||
surface: compositor.create_surface(&qh, ()),
|
||||
connection,
|
||||
};
|
||||
let binding = ModalMaterial::attach(&window).expect("compositor acknowledges attachment");
|
||||
let retained = binding.clone();
|
||||
let weak = Arc::downgrade(&binding._binding);
|
||||
drop(binding);
|
||||
assert!(weak.upgrade().is_some());
|
||||
drop(retained);
|
||||
assert!(weak.upgrade().is_none());
|
||||
queue.roundtrip(&mut MaterialState::default()).unwrap();
|
||||
// The compositor rejects duplicate materials. Reattachment proves the final
|
||||
// drop destroyed the first material while preserving the borrowed display.
|
||||
let replacement = ModalMaterial::attach(&window).expect("last clone released the surface material");
|
||||
drop(replacement);
|
||||
queue.roundtrip(&mut MaterialState::default()).unwrap();
|
||||
window.surface.destroy();
|
||||
queue.roundtrip(&mut MaterialState::default()).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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 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;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmic::iced::futures::StreamExt;
|
||||
use ferese_ipc::{Request, Response, read_frame, write_frame};
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn subscription_reconnects_and_drop_cancels_a_pending_watch() {
|
||||
const CHILD: &str = "FERESE_THEME_SUBSCRIPTION_TEST";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"service::tests::subscription_reconnects_and_drop_cancels_a_pending_watch",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.env("XDG_RUNTIME_DIR", root.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
// No compositor: return the same standalone values without connecting elsewhere.
|
||||
assert_eq!(current(), fallback());
|
||||
let path = ferese_ipc::theme::socket_path().unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let listener = UnixListener::bind(path).unwrap();
|
||||
let (watch_send, watch_receive) = std::sync::mpsc::channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
for revision in [1, 2] {
|
||||
let (mut connection, _) = listener.accept().unwrap();
|
||||
connection.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let get: Request = read_frame(&mut connection).unwrap();
|
||||
assert_eq!(get.command, "theme-get");
|
||||
let mut snapshot = fallback();
|
||||
snapshot.revision = revision;
|
||||
write_frame(
|
||||
&mut connection,
|
||||
&Response::success(get.id, serde_json::to_value(snapshot).unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
let watch: Request = read_frame(&mut connection).unwrap();
|
||||
assert_eq!(watch.command, "theme-watch");
|
||||
assert_eq!(watch.args["since"], revision);
|
||||
if revision == 2 {
|
||||
watch_send.send(()).unwrap();
|
||||
use std::io::Read;
|
||||
// EOF, not the read deadline: dropping the stream must shut down the watch.
|
||||
assert_eq!(connection.read(&mut [0]).unwrap(), 0);
|
||||
}
|
||||
// First connection closes while watched; the stream must reconnect.
|
||||
}
|
||||
});
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
let mut subscription = Box::pin(stream());
|
||||
for revision in [1, 2] {
|
||||
let snapshot = tokio::time::timeout(Duration::from_secs(5), subscription.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(snapshot.revision, revision);
|
||||
}
|
||||
tokio::task::spawn_blocking(move || watch_receive.recv_timeout(Duration::from_secs(5)).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
drop(subscription);
|
||||
});
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use ferese_ipc::theme::Snapshot;
|
||||
use ferese_theme_client::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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user