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
View File
@@ -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;
+175
View File
@@ -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();
}
}
+154
View File
@@ -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();
}
}