From 0709484efb0d17ee53b5e5ad8e73237ca24ef2cc Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sat, 26 Sep 2026 14:50:14 +0100 Subject: [PATCH] Overhaul resize presentation and wallpaper rendering Coordinate native-pixel resize handoffs, fractional geometry and shared animation timing. Delegate wallpaper rendering to the compositor, advertise DMA-BUF device feedback, and improve callback pacing and render metrics. Handle monitor-local shell surfaces, lid-driven output evacuation and workspace restoration. --- Cargo.lock | 2 + README.md | 24 +- compositor/Cargo.toml | 1 + compositor/src/backends/direct.rs | 203 +++++++- compositor/src/config.rs | 8 + compositor/src/handlers/compositor.rs | 42 +- compositor/src/input.rs | 18 +- compositor/src/main.rs | 14 +- compositor/src/metrics.rs | 30 ++ compositor/src/overview.rs | 3 + compositor/src/presentation.rs | 309 +++++++++++++ compositor/src/resize_transaction.rs | 78 ++++ compositor/src/state.rs | 267 ++++++++++- compositor/src/wallpaper.rs | 227 +++++++++ compositor/src/winit.rs | 618 ++++++++++++++++++------- crates/ferese-animation/src/lib.rs | 69 ++- crates/ferese-core/src/output.rs | 61 ++- shell/ferese-shell/src/control.rs | 4 +- shell/ferese-shell/src/main.rs | 369 ++++++++++----- tools/ferese-effects-probe/src/main.rs | 65 ++- 20 files changed, 2093 insertions(+), 319 deletions(-) create mode 100644 compositor/src/presentation.rs create mode 100644 compositor/src/resize_transaction.rs create mode 100644 compositor/src/wallpaper.rs diff --git a/Cargo.lock b/Cargo.lock index c7a29a0..38be843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1576,6 +1576,7 @@ dependencies = [ "ferese-ipc", "ferese-layout", "ferese-protocols", + "image", "libc", "serde", "serde_json", @@ -2674,6 +2675,7 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "image-webp", "moxcms", "num-traits", "png 0.18.1", diff --git a/README.md b/README.md index 4a3472f..89ec9dc 100644 --- a/README.md +++ b/README.md @@ -185,11 +185,26 @@ shadow. Window spacing uses the workspace outer gap; `top_bar_window_gap` under `[theme.geometry]` adds optional extra space (default 0). Solid and translucent shell surfaces also omit outlines. The dark startup fallback matches the material palette. -The shell decodes wallpaper in parallel with startup and prefers the lightweight +The compositor decodes wallpaper in parallel with startup. Small shell surfaces prefer the lightweight software renderer, with GPU rendering available as a fallback. Set `ICED_BACKEND=wgpu` to explicitly use GPU rendering for shell content. Use release builds when evaluating startup and animation performance; debug image processing is considerably slower. +Resize presentation configures the destination once and coordinates the +matching client commits with the workspace motion (300 ms maximum wait). +Application content stays at native pixel size; animated bounds crop/reveal +it rather than stretching text. A resize-only previous-frame snapshot fades +out over 80 ms, with a 64 MiB total snapshot budget and automatic release. +All monitors share one elapsed-time animation clock, and window content, +clips, borders and shadows use the same physical-pixel edges. + +Ferese renders `[theme.background]` wallpapers directly: `path` selects the +image and `mode = "fill"` or `"fit"` controls crop/letterboxing. One decoded +image and one texture per GPU context serve every output; resizing requires +no image reload or full-screen shell buffers. The launched shell receives +`FERESE_COMPOSITOR_WALLPAPER=1` to disable its redundant wallpaper surfaces. +When no valid image path is configured, the shell's existing fallback remains. + Keyboard and direct-session touchpad settings are applied at startup: ```toml @@ -245,6 +260,13 @@ transforms are `normal`, `rotate_90`, `rotate_180`, `rotate_270`, `flipped`, and their rotated flipped variants. Output configuration is applied at startup and on hotplug; live configuration reload remains deferred. +On DRM, closing the laptop lid disables internal panels when a usable external +output is available. With no usable external output, the panel stays enabled; +Ferese does not change the system's suspend policy. Workspaces are evacuated +to a remaining output without merging their windows, and return to their +original output when the lid reopens or a disconnected monitor returns. +Normal use of an evacuated workspace does not cancel its automatic return. + Commands are argv arrays and are started directly, without a shell. The default `Super+Enter` binding runs the built-in `terminal = ["foot"]` command: diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 3ee3d60..05df774 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -13,6 +13,7 @@ ferese-ipc = { path = "../crates/ferese-ipc" } ferese-layout = { path = "../crates/ferese-layout" } ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] } libc.workspace = true +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } smithay.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 66a9742..9101c59 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -12,7 +12,9 @@ use smithay::{ Fourcc, gbm::{GbmAllocator, GbmBufferFlags, GbmDevice}, }, - drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, GbmBufferedSurface}, + drm::{ + DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, GbmBufferedSurface, NodeType, + }, egl::{EGLContext, EGLDisplay}, input::InputEvent, libinput::{LibinputInputBackend, LibinputSessionInterface}, @@ -36,7 +38,7 @@ use smithay::{ wayland_server::backend::GlobalId, }, utils::{DeviceFd, Monotonic, Time, Transform}, - wayland::presentation::Refresh, + wayland::{dmabuf::DmabufFeedbackBuilder, presentation::Refresh}, }; use crate::{ @@ -51,11 +53,39 @@ use crate::{ pub struct DirectBackendState { pub session: LibSeatSession, pub active: bool, + lid_closed: bool, devices: HashMap, presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>, pub(crate) connected_outputs: Vec, } +impl DirectBackendState { + pub(crate) fn capture_resize_snapshot( + &mut self, + window: &smithay::desktop::Window, + geometry: smithay::utils::Rectangle, + output: &Output, + remaining: usize, + ) -> Result, smithay::backend::renderer::gles::GlesError> + { + let Some(device) = self.devices.values_mut().find(|device| { + device + .outputs + .values() + .any(|candidate| &candidate.output == output) + }) else { + return Ok(None); + }; + crate::winit::capture_resize_snapshot( + &mut device.renderer, + window, + geometry, + output.current_scale().fractional_scale(), + remaining, + ) + } +} + #[derive(Clone, Debug)] pub(crate) struct ConnectedOutputInfo { pub connector: String, @@ -94,6 +124,7 @@ struct DirectDevice { } struct DirectOutput { + internal: bool, connector: connector::Handle, mode: DrmMode, settings: OutputSettings, @@ -143,6 +174,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() state.direct_backend = Some(DirectBackendState { session, active: session_active, + lid_closed: initial_lid_closed(), devices: HashMap::new(), presentation: HashMap::new(), connected_outputs: Vec::new(), @@ -279,10 +311,25 @@ fn open_primary_device( state.shm_state.update_formats(renderer.shm_formats()); let dmabuf_formats = renderer.dmabuf_formats(); let display_handle = state.display_handle.clone(); + // Mesa's Wayland EGL path needs the main device, not just a v3 format + // list, to choose a hardware render node. Without feedback, nested EGL + // clients can fall back to llvmpipe despite a GPU-backed DRM compositor. + let render_node = node + .node_with_type(NodeType::Render) + .and_then(Result::ok) + .unwrap_or(node); + let feedback = DmabufFeedbackBuilder::new(render_node.dev_id(), dmabuf_formats).build()?; state .dmabuf_state - .create_global::(&display_handle, dmabuf_formats); - let scan = select_outputs(&drm, &state.output_profiles)?; + .create_global_with_default_feedback::(&display_handle, &feedback); + let mut scan = select_outputs(&drm, &state.output_profiles)?; + apply_lid_policy( + &mut scan, + state + .direct_backend + .as_ref() + .is_some_and(|backend| backend.lid_closed), + ); if scan.selections.is_empty() { return Err(io::Error::new( io::ErrorKind::NotFound, @@ -418,6 +465,68 @@ pub fn switch_vt(state: &mut Ferese, vt: i32) { } } +pub(crate) fn set_lid_closed(state: &mut Ferese, closed: bool) { + let Some(backend) = state.direct_backend.as_mut() else { + return; + }; + if backend.lid_closed == closed { + return; + } + backend.lid_closed = closed; + tracing::info!(closed, "laptop lid state changed"); + let nodes = backend.devices.keys().copied().collect::>(); + for node in nodes { + rescan_device(state, node); + } +} + +fn initial_lid_closed() -> bool { + std::fs::read_dir("/proc/acpi/button/lid") + .ok() + .into_iter() + .flatten() + .filter_map(Result::ok) + .filter_map(|entry| std::fs::read_to_string(entry.path().join("state")).ok()) + .any(|state| state.split_whitespace().last() == Some("closed")) +} + +fn internal_connector(interface: connector::Interface) -> bool { + matches!( + interface, + connector::Interface::EmbeddedDisplayPort + | connector::Interface::LVDS + | connector::Interface::DSI + ) +} + +fn apply_lid_policy(scan: &mut OutputScan, closed: bool) { + let external_available = scan + .selections + .iter() + .any(|selection| !internal_connector(selection.connector.interface())); + if !lid_hides_panel(closed, external_available) { + return; + } + let internal = scan + .selections + .iter() + .filter(|selection| internal_connector(selection.connector.interface())) + .map(|selection| selection.connector.to_string()) + .collect::>(); + scan.selections + .retain(|selection| !internal_connector(selection.connector.interface())); + for output in &mut scan.connected_outputs { + if internal.contains(&output.connector) { + output.enabled = false; + output.current_mode = None; + } + } +} + +fn lid_hides_panel(closed: bool, external_available: bool) -> bool { + closed && external_available +} + fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) { let missed_deadlines = state .direct_backend @@ -570,7 +679,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { return; }; - let scan = match select_outputs(&device.drm, &state.output_profiles) { + let mut scan = match select_outputs(&device.drm, &state.output_profiles) { Ok(scan) => scan, Err(error) => { tracing::error!(?node, %error, "failed to scan DRM connectors"); @@ -578,6 +687,11 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { return; } }; + let lid_closed = state + .direct_backend + .as_ref() + .is_some_and(|backend| backend.lid_closed); + apply_lid_policy(&mut scan, lid_closed); if let Some(backend) = state.direct_backend.as_mut() { backend.connected_outputs = scan.connected_outputs; } @@ -587,6 +701,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { .map(|selection| (selection.crtc, selection)) .collect::>(); let existing = device.outputs.keys().copied().collect::>(); + let mut deferred_removals = Vec::new(); for crtc in existing { let unchanged = device.outputs.get(&crtc).is_some_and(|output| { @@ -601,6 +716,14 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { continue; } + // Add replacements on free CRTCs before removing vanished outputs so + // workspace evacuation always has a real destination. Reused CRTCs + // still need their old surface released first. + if !selections.contains_key(&crtc) { + deferred_removals.push(crtc); + continue; + } + if let Some(output) = device.outputs.remove(&crtc) { state.display_handle.disable_global::(output.global); state.unregister_output(&output.output); @@ -643,7 +766,48 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { } } + for crtc in deferred_removals { + let keep_internal = device + .outputs + .get(&crtc) + .is_some_and(|output| output.internal) + && lid_closed + && state.direct_backend.as_ref().is_some_and(|backend| { + device.outputs.get(&crtc).is_some_and(|output| { + backend + .connected_outputs + .iter() + .any(|info| info.connector == output.output.name()) + }) + }) + && !device.outputs.values().any(|output| !output.internal); + if keep_internal { + tracing::warn!( + ?crtc, + "keeping laptop panel enabled: external output activation failed" + ); + if let Some(output) = device.outputs.get(&crtc) + && let Some(backend) = state.direct_backend.as_mut() + && let Some(info) = backend + .connected_outputs + .iter_mut() + .find(|info| info.connector == output.output.name()) + { + info.enabled = true; + info.current_mode = Some(connected_mode_info(output.mode)); + } + continue; + } + if let Some(output) = device.outputs.remove(&crtc) { + state.display_handle.disable_global::(output.global); + state.unregister_output(&output.output); + if let Some(backend) = state.direct_backend.as_mut() { + backend.presentation.remove(&(node, crtc)); + } + } + } restore_device(state, node, device); + state.restore_output_focus(); state.relayout(); render_all(state); } @@ -657,6 +821,11 @@ fn remove_device(state: &mut Ferese, node: DrmNode) { return; }; + let context = device.renderer.context_id().erased(); + state.wallpaper.forget_context(&context); + state + .resize_snapshots + .retain(|_, snapshot| snapshot.context != context); for (crtc, output) in device.outputs { state.display_handle.disable_global::(output.global); state.unregister_output(&output.output); @@ -901,7 +1070,6 @@ fn create_direct_output( settings: &OutputSettings, ) -> Result> { let identity = connector_identity(drm, &connector); - let (output, global) = create_output(state, &connector, mode, identity, settings); let drm_surface = drm.create_surface(crtc, mode, &[connector.handle()])?; let allocator = GbmAllocator::new( gbm.clone(), @@ -913,11 +1081,15 @@ fn create_direct_output( &[Fourcc::Argb8888, Fourcc::Abgr8888], renderer.dmabuf_formats(), )?; + // Publish only after DRM/GBM creation succeeds; failed activation must not + // leave a phantom output/workspace that could receive evacuated windows. + let (output, global) = create_output(state, &connector, mode, identity, settings); let damage_tracker = OutputDamageTracker::from_output(&output); let render_metrics = RenderMetrics::from_environment(output.name()); tracing::info!(?crtc, connector = %connector, "initialized DRM output"); Ok(DirectOutput { + internal: internal_connector(connector.interface()), connector: connector.handle(), mode, settings: settings.clone(), @@ -1090,6 +1262,25 @@ mod tests { use super::*; + #[test] + fn lid_only_hides_panels_with_a_usable_external_output() { + assert!(lid_hides_panel(true, true)); + assert!(!lid_hides_panel(false, true)); + assert!(!lid_hides_panel(true, false)); + assert!(!lid_hides_panel(false, false)); + } + + #[test] + fn panel_detection_uses_connector_type_not_monitor_name() { + assert!(internal_connector( + connector::Interface::EmbeddedDisplayPort + )); + assert!(internal_connector(connector::Interface::LVDS)); + assert!(internal_connector(connector::Interface::DSI)); + assert!(!internal_connector(connector::Interface::HDMIA)); + assert!(!internal_connector(connector::Interface::DisplayPort)); + } + #[test] fn connector_identity_hash_is_stable() { assert_eq!(stable_hash(b"hello"), 0xa430_d846_80aa_bd0b); diff --git a/compositor/src/config.rs b/compositor/src/config.rs index f05b7cd..517e34b 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -167,6 +167,8 @@ impl Default for InactiveDimConfig { #[derive(Debug, Default, Deserialize)] struct ThemeConfig { + #[serde(default)] + background: crate::wallpaper::WallpaperConfig, #[serde(default)] surface: ThemeSurfaceConfig, #[serde(default)] @@ -179,6 +181,12 @@ struct ThemeConfig { material: ThemeMaterialConfig, } +impl Config { + pub(crate) fn wallpaper_settings(&self) -> crate::wallpaper::WallpaperConfig { + self.theme.background.clone() + } +} + #[derive(Debug, Default, Deserialize)] struct ThemeSurfaceConfig { #[serde(default)] diff --git a/compositor/src/handlers/compositor.rs b/compositor/src/handlers/compositor.rs index 51d6d8a..501b009 100644 --- a/compositor/src/handlers/compositor.rs +++ b/compositor/src/handlers/compositor.rs @@ -18,6 +18,30 @@ use smithay::{ use super::{layer_shell, xdg_shell}; use crate::{Ferese, state::ClientState}; +fn layer_affects_backdrop(layer: smithay::wayland::shell::wlr_layer::Layer) -> bool { + matches!( + layer, + smithay::wayland::shell::wlr_layer::Layer::Background + | smithay::wayland::shell::wlr_layer::Layer::Bottom + ) +} + +impl Ferese { + fn surface_affects_backdrop(&self, surface: &WlSurface) -> bool { + let mut root = surface.clone(); + while let Some(parent) = get_parent(&root) { + root = parent; + } + if matches!(&self.cursor_status, smithay::input::pointer::CursorImageStatus::Surface(cursor) if cursor == &root) + { + return false; + } + self.space + .layer_for_surface(&root, smithay::desktop::WindowSurfaceType::ALL) + .is_none_or(|layer| layer_affects_backdrop(layer.cached_state().layer)) + } +} + impl CompositorHandler for Ferese { fn compositor_state(&mut self) -> &mut CompositorState { &mut self.compositor_state @@ -31,7 +55,10 @@ impl CompositorHandler for Ferese { } fn commit(&mut self, surface: &WlSurface) { - self.backdrop_generation = self.backdrop_generation.wrapping_add(1); + self.capture_resize_before_commit(surface); + if self.surface_affects_backdrop(surface) { + self.backdrop_generation = self.backdrop_generation.wrapping_add(1); + } on_commit_buffer_handler::(surface); if !is_sync_subsurface(surface) { let mut root = surface.clone(); @@ -77,6 +104,19 @@ impl CompositorHandler for Ferese { } } +#[cfg(test)] +mod tests { + use super::*; + use smithay::wayland::shell::wlr_layer::Layer; + #[test] + fn panel_commits_do_not_invalidate_their_own_backdrop() { + assert!(!layer_affects_backdrop(Layer::Top)); + assert!(!layer_affects_backdrop(Layer::Overlay)); + assert!(layer_affects_backdrop(Layer::Bottom)); + assert!(layer_affects_backdrop(Layer::Background)); + } +} + impl BufferHandler for Ferese { fn buffer_destroyed(&mut self, _buffer: &wl_buffer::WlBuffer) {} } diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 584acd8..864f2a0 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -4,7 +4,7 @@ use smithay::{ backend::input::{ AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, InputBackend, InputEvent, KeyState, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, - TouchEvent, + Switch, SwitchState, SwitchToggleEvent, TouchEvent, }, input::{ keyboard::{FilterResult, keysyms}, @@ -27,6 +27,9 @@ impl Ferese { self.idle_notifier_state.notify_activity(&seat); match event { + InputEvent::SwitchToggle { event } if event.switch() == Some(Switch::Lid) => { + crate::backends::direct::set_lid_closed(self, event.state() == SwitchState::On); + } InputEvent::Keyboard { event, .. } => { let keyboard = self.seat.get_keyboard().expect("seat has a keyboard"); let keycode = event.key_code(); @@ -339,12 +342,14 @@ impl Ferese { } if let Some(window) = self.window_under_visual(position) { - self.focused_window = self.window_ids.get(&window).copied(); - if let Some(focused) = self.focused_window + let focused = self.window_ids.get(&window).copied(); + if let Some(focused) = focused && let Err(error) = self.workspaces.focus_window(focused) { tracing::error!(%error, ?focused, "failed to update workspace focus"); + return; } + self.focused_window = focused; self.raise_window(&window, true); let surface = window .toplevel() @@ -481,7 +486,12 @@ impl Ferese { BindingAction::Spawn(mut argv) => { let program = argv.remove(0); - match Command::new(&program).args(argv).spawn() { + match Command::new(&program) + .args(argv) + .env("WAYLAND_DISPLAY", &self.socket_name) + .env_remove("WAYLAND_SOCKET") + .spawn() + { Ok(child) => { tracing::info!(%program, pid = child.id(), "spawned binding command") } diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 27835e8..8a1b714 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -11,10 +11,13 @@ mod input; mod ipc; mod metrics; mod overview; +mod presentation; mod private_client; +mod resize_transaction; mod shell_control; mod stacking; mod state; +mod wallpaper; mod window_rules; mod winit; @@ -68,14 +71,11 @@ fn main() -> Result<(), Box> { spring_config: config.spring_config()?, viewport_spring_config: config.viewport_spring_config()?, output_profiles, + wallpaper: config.wallpaper_settings(), }; let mut state = Ferese::new(&mut event_loop, display, runtime)?; backends::init(launch.backend, &mut event_loop, &mut state)?; - // SAFETY: the backend has already read the host display and Ferese is - // single-threaded before the event loop and optional child start. - unsafe { std::env::set_var("WAYLAND_DISPLAY", &state.socket_name) }; - info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients"); let mut child = spawn_client(&mut state, launch.client, launch.client_capabilities); let result = event_loop.run(None, &mut state, |state| { @@ -118,6 +118,12 @@ fn spawn_client( let mut command = Command::new(&program); command.args(args); + // Wallpaper decoding and drivers may already have worker threads. Set + // the child's display explicitly instead of mutating the process env. + command.env("WAYLAND_DISPLAY", &state.socket_name); + if state.wallpaper.owns_background() { + command.env("FERESE_COMPOSITOR_WALLPAPER", "1"); + } let private_connection = if capabilities.is_empty() { None } else { diff --git a/compositor/src/metrics.rs b/compositor/src/metrics.rs index 9364997..1b43706 100644 --- a/compositor/src/metrics.rs +++ b/compositor/src/metrics.rs @@ -16,6 +16,7 @@ pub(crate) struct RenderMetrics { damaged_pixels: u64, total_render_time: Duration, longest_render_time: Duration, + frame_times: Vec, } impl RenderMetrics { @@ -36,6 +37,7 @@ impl RenderMetrics { damaged_pixels: 0, total_render_time: Duration::ZERO, longest_render_time: Duration::ZERO, + frame_times: Vec::new(), } } @@ -54,6 +56,10 @@ impl RenderMetrics { self.damaged_pixels += damage.iter().map(rectangle_area).sum::(); self.total_render_time += render_time; self.longest_render_time = self.longest_render_time.max(render_time); + if self.frame_times.len() == 512 { + self.frame_times.remove(0); + } + self.frame_times.push(render_time); let now = Instant::now(); let elapsed = now.saturating_duration_since(self.interval_started); @@ -66,6 +72,17 @@ impl RenderMetrics { .as_micros() .checked_div(u128::from(self.rendered_frames)) .unwrap_or(0); + self.frame_times.sort_unstable(); + let percentile = |percent: usize| { + let index = (self.frame_times.len() * percent) + .div_ceil(100) + .saturating_sub(1); + self.frame_times + .get(index) + .copied() + .unwrap_or_default() + .as_micros() + }; tracing::info!( target: "ferese::render", output = %self.output, @@ -74,6 +91,8 @@ impl RenderMetrics { damaged_pixels = self.damaged_pixels, average_render_us, longest_render_us = self.longest_render_time.as_micros(), + p95_render_us = percentile(95), + p99_render_us = percentile(99), missed_deadlines, "render performance" ); @@ -83,6 +102,7 @@ impl RenderMetrics { self.damaged_pixels = 0; self.total_render_time = Duration::ZERO; self.longest_render_time = Duration::ZERO; + self.frame_times.clear(); } } @@ -128,5 +148,15 @@ mod tests { assert_eq!(metrics.damaged_pixels, 5_200); assert_eq!(metrics.total_render_time, Duration::from_millis(2)); assert_eq!(metrics.longest_render_time, Duration::from_millis(2)); + assert_eq!(metrics.frame_times, vec![Duration::from_millis(2)]); + } + + #[test] + fn profiling_samples_are_bounded() { + let mut metrics = RenderMetrics::new("test", true, Instant::now()); + for _ in 0..2000 { + metrics.record_frame(Duration::from_millis(1), &[], 0, FrameEffectMetrics); + } + assert_eq!(metrics.frame_times.len(), 512); } } diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index da10b66..f4fab74 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -209,6 +209,9 @@ impl Ferese { y: f64, ) -> Option<(f64, f64)> { let geometry = self.window_geometry.get(&id)?; + if !self.overview.is_presenting() { + return Some((x - geometry.visual.current.x, y - geometry.visual.current.y)); + } let source = geometry.client.committed_size?; let presented = self.overview.presented_rect(id, geometry.visual.current); diff --git a/compositor/src/presentation.rs b/compositor/src/presentation.rs new file mode 100644 index 0000000..c5b97e0 --- /dev/null +++ b/compositor/src/presentation.rs @@ -0,0 +1,309 @@ +//! Presentation geometry is rounded once, in output pixels, never first in +//! logical pixels. Content, clips and decorations share those exact edges. +use std::time::{Duration, Instant}; + +use smithay::{ + backend::renderer::{ + element::{Element, Id, Kind, RenderElement}, + gles::{ + GlesError, GlesFrame, GlesRenderer, GlesTexProgram, GlesTexture, Uniform, + element::PixelShaderElement, + }, + utils::{CommitCounter, DamageSet}, + }, + utils::{Buffer, Logical, Physical, Point, Rectangle, Scale, Transform}, +}; + +pub(crate) const HANDOFF: Duration = Duration::from_millis(80); +pub(crate) const SNAPSHOT_BUDGET: usize = 64 * 1024 * 1024; + +pub(crate) fn frame_delta(last: &mut Instant, now: Instant) -> Duration { + let delta = now.saturating_duration_since(*last); + *last = (*last).max(now); + delta +} + +pub(crate) fn physical_rect( + rect: ferese_layout::Rect, + origin: Point, + scale: f64, +) -> Rectangle { + let left = ((rect.x - f64::from(origin.x)) * scale).round() as i32; + let top = ((rect.y - f64::from(origin.y)) * scale).round() as i32; + let right = ((rect.x + rect.width - f64::from(origin.x)) * scale).round() as i32; + let bottom = ((rect.y + rect.height - f64::from(origin.y)) * scale).round() as i32; + Rectangle::new( + (left, top).into(), + ((right - left).max(1), (bottom - top).max(1)).into(), + ) +} + +pub(crate) fn handoff_alpha(elapsed: Duration) -> f32 { + let progress = (elapsed.as_secs_f64() / HANDOFF.as_secs_f64()).clamp(0.0, 1.0); + (1.0 - progress * progress * (3.0 - 2.0 * progress)) as f32 +} + +pub(crate) fn resize_needs_old_frame( + visual: ferese_layout::Rect, + target: ferese_layout::Rect, + buffer_width: i32, + buffer_height: i32, +) -> bool { + // Some clients round their configured dimensions to a character grid. + // Do not retain a snapshot forever for that permanent size difference. + visual.width > target.width.max(f64::from(buffer_width)) + 0.5 + || visual.height > target.height.max(f64::from(buffer_height)) + 0.5 +} + +pub(crate) fn snapshot_covers_source( + snapshot: smithay::utils::Size, + source: smithay::utils::Size, + snapshot_scale: f64, + scale: f64, +) -> bool { + (snapshot_scale - scale).abs() < 0.001 && snapshot.w >= source.w && snapshot.h >= source.h +} + +pub(crate) fn advance_handoff( + elapsed: &mut Duration, + last: &mut Option, + now: Duration, + blocked: bool, + speed: f64, +) -> bool { + if blocked { + // A retarget must not consume its client's waiting time as fade time. + *last = None; + } else { + if let Some(previous) = last.replace(now) { + *elapsed += now.saturating_sub(previous).mul_f64(speed); + } + } + *elapsed < HANDOFF +} + +/// A shader canvas whose damage/paint destination uses the shared pixel edges. +#[derive(Clone, Debug)] +pub(crate) struct PhysicalShaderElement { + pub inner: PixelShaderElement, + pub geometry: Rectangle, +} + +impl Element for PhysicalShaderElement { + fn id(&self) -> &Id { + self.inner.id() + } + fn current_commit(&self) -> CommitCounter { + self.inner.current_commit() + } + fn src(&self) -> Rectangle { + self.inner.src() + } + fn geometry(&self, _: Scale) -> Rectangle { + self.geometry + } + fn damage_since( + &self, + _: Scale, + commit: Option, + ) -> DamageSet { + if commit == Some(self.current_commit()) { + DamageSet::default() + } else { + DamageSet::from_slice(&[Rectangle::from_size(self.geometry.size)]) + } + } +} +impl RenderElement for PhysicalShaderElement { + fn draw( + &self, + frame: &mut GlesFrame<'_, '_>, + src: Rectangle, + dst: Rectangle, + damage: &[Rectangle], + opaque: &[Rectangle], + ) -> Result<(), GlesError> { + self.inner.draw(frame, src, dst, damage, opaque) + } +} + +/// Stable native-pixel texture element. Unlike a resized client buffer, an old +/// frame is only translated/cropped; it never changes the size of its glyphs. +#[derive(Clone, Debug)] +pub(crate) struct NativeTextureElement { + pub id: Id, + pub commit: CommitCounter, + pub texture: GlesTexture, + pub geometry: Rectangle, + pub source: Rectangle, + pub alpha: f32, + pub program: Option, + pub uniforms: Vec>, +} +impl Element for NativeTextureElement { + fn id(&self) -> &Id { + &self.id + } + fn current_commit(&self) -> CommitCounter { + self.commit + } + fn src(&self) -> Rectangle { + self.source + } + fn geometry(&self, _: Scale) -> Rectangle { + self.geometry + } + fn alpha(&self) -> f32 { + self.alpha + } + fn kind(&self) -> Kind { + Kind::Unspecified + } + fn damage_since( + &self, + _: Scale, + commit: Option, + ) -> DamageSet { + if commit == Some(self.commit) { + DamageSet::default() + } else { + DamageSet::from_slice(&[Rectangle::from_size(self.geometry.size)]) + } + } +} +impl RenderElement for NativeTextureElement { + fn draw( + &self, + frame: &mut GlesFrame<'_, '_>, + src: Rectangle, + dst: Rectangle, + damage: &[Rectangle], + opaque: &[Rectangle], + ) -> Result<(), GlesError> { + frame.render_texture_from_to( + &self.texture, + src, + dst, + damage, + opaque, + Transform::Normal, + self.alpha, + self.program.as_ref(), + &self.uniforms, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reversing_a_grow_replaces_the_smaller_snapshot() { + assert!(!snapshot_covers_source( + (500, 600).into(), + (1000, 600).into(), + 1.0, + 1.0 + )); + assert!(snapshot_covers_source( + (1000, 600).into(), + (500, 600).into(), + 1.0, + 1.0 + )); + assert!(!snapshot_covers_source( + (1000, 600).into(), + (500, 600).into(), + 1.0, + 1.8 + )); + } + + #[test] + fn shrinking_keeps_old_pixels_until_destination_covers_bounds() { + let target = ferese_layout::Rect::new(0.0, 0.0, 500.0, 600.0); + let visual = ferese_layout::Rect::new(0.0, 0.0, 750.0, 600.0); + assert!(resize_needs_old_frame(visual, target, 500, 600)); + assert!(!resize_needs_old_frame(target, target, 500, 600)); + // Character-grid clients must still release the bounded snapshot. + assert!(!resize_needs_old_frame(target, target, 492, 592)); + let growing_target = ferese_layout::Rect::new(0.0, 0.0, 1000.0, 600.0); + assert!(!resize_needs_old_frame(visual, growing_target, 1000, 600)); + } + #[test] + fn fresh_snapshot_and_retarget_wait_do_not_skip_the_handoff() { + let mut elapsed = Duration::ZERO; + let mut last = None; + assert!(advance_handoff( + &mut elapsed, + &mut last, + Duration::from_secs(5), + false, + 1.0 + )); + assert_eq!(elapsed, Duration::ZERO); + assert!(advance_handoff( + &mut elapsed, + &mut last, + Duration::from_millis(5010), + false, + 1.0 + )); + assert_eq!(elapsed, Duration::from_millis(10)); + advance_handoff(&mut elapsed, &mut last, Duration::from_secs(6), true, 1.0); + advance_handoff(&mut elapsed, &mut last, Duration::from_secs(7), false, 1.0); + assert_eq!(elapsed, Duration::from_millis(10)); + assert!(!advance_handoff( + &mut elapsed, + &mut last, + Duration::from_millis(7070), + false, + 1.0 + )); + } + #[test] + fn mixed_refresh_outputs_do_not_double_the_animation_clock() { + let start = Instant::now(); + let mut last = start; + let mut elapsed = Duration::ZERO; + for ms in [8, 16, 16, 24, 32, 32] { + elapsed += frame_delta(&mut last, start + Duration::from_millis(ms)); + } + assert_eq!(elapsed, Duration::from_millis(32)); + assert_eq!(frame_delta(&mut last, start), Duration::ZERO); + assert_eq!(last, start + Duration::from_millis(32)); + } + #[test] + fn fractional_motion_does_not_quantize_to_logical_pixels() { + let rect = ferese_layout::Rect::new(0.3, 0.3, 100.2, 80.2); + assert_eq!(physical_rect(rect, (0, 0).into(), 1.8).loc, (1, 1).into()); + assert_eq!( + physical_rect(rect, (0, 0).into(), 1.8).size, + (180, 144).into() + ); + } + #[test] + fn adjacent_frames_share_edges_at_all_output_scales() { + for scale in [1.0, 1.25, 1.5, 1.8, 2.0] { + let left = physical_rect( + ferese_layout::Rect::new(13.3, 0.0, 500.4, 800.0), + (0, 0).into(), + scale, + ); + let right = physical_rect( + ferese_layout::Rect::new(513.7, 0.0, 500.4, 800.0), + (0, 0).into(), + scale, + ); + assert_eq!(left.loc.x + left.size.w, right.loc.x); + } + } + #[test] + fn handoff_has_bounded_lifetime_and_no_alpha_jump() { + assert_eq!(handoff_alpha(Duration::ZERO), 1.0); + assert_eq!(handoff_alpha(HANDOFF), 0.0); + assert_eq!(handoff_alpha(Duration::from_secs(1)), 0.0); + assert!(handoff_alpha(Duration::from_millis(1)) > 0.99); + } +} diff --git a/compositor/src/resize_transaction.rs b/compositor/src/resize_transaction.rs new file mode 100644 index 0000000..b718ee0 --- /dev/null +++ b/compositor/src/resize_transaction.rs @@ -0,0 +1,78 @@ +//! A presentation barrier, not an event-loop or client-commit blocker. +//! Grouping barriers by workspace keeps viewport and neighboring tiles on the +//! same clock. A serial identifies readiness even for cell-grid clients whose +//! committed size is smaller than the configure, and after rapid reversals. +use std::time::Duration; + +use smithay::utils::{Logical, Rectangle, Serial}; + +const DEADLINE: Duration = Duration::from_millis(300); + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ResizeTransaction { + serial: Serial, + started: Duration, + source_geometry: Option>, +} + +impl ResizeTransaction { + pub(crate) fn new(serial: Serial, started: Duration) -> Self { + Self { + serial, + started, + source_geometry: None, + } + } + + pub(crate) fn with_source_geometry(mut self, geometry: Rectangle) -> Self { + self.source_geometry = Some(geometry); + self + } + pub(crate) fn source_geometry(self) -> Option> { + self.source_geometry + } + + pub(crate) fn accepts(self, committed: Option) -> bool { + committed.is_some_and(|serial| serial >= self.serial) + } + + pub(crate) fn expired(self, now: Duration) -> bool { + now.saturating_sub(self.started) >= DEADLINE + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn acknowledgement_alone_or_stale_commit_does_not_release_resize() { + let transaction = ResizeTransaction::new(12.into(), Duration::ZERO); + assert!(!transaction.accepts(None)); + assert!(!transaction.accepts(Some(11.into()))); + assert!(transaction.accepts(Some(12.into()))); + assert!(transaction.accepts(Some(13.into()))); + } + + #[test] + fn serial_comparison_handles_wraparound() { + let transaction = ResizeTransaction::new(u32::MAX.into(), Duration::ZERO); + assert!(transaction.accepts(Some(0.into()))); + assert!(!transaction.accepts(Some((u32::MAX - 1).into()))); + } + + #[test] + fn unresponsive_client_has_bounded_wait() { + let transaction = ResizeTransaction::new(1.into(), Duration::from_millis(10)); + assert!(!transaction.expired(Duration::from_millis(309))); + assert!(transaction.expired(Duration::from_millis(310))); + } + + #[test] + fn shrinking_commit_retains_the_previous_full_frame_geometry() { + let source = Rectangle::new((4, 8).into(), (1200, 800).into()); + let transaction = + ResizeTransaction::new(1.into(), Duration::ZERO).with_source_geometry(source); + assert_eq!(transaction.source_geometry(), Some(source)); + } +} diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 0ea1cd0..94c4ebd 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -64,7 +64,7 @@ use smithay::{ shell::wlr_layer::Layer, shell::wlr_layer::WlrLayerShellState, shell::xdg::decoration::XdgDecorationState, - shell::xdg::{SurfaceCachedState, XdgShellState}, + shell::xdg::{SurfaceCachedState, XdgShellState, XdgToplevelSurfaceData}, shm::ShmState, single_pixel_buffer::SinglePixelBufferState, socket::ListeningSocketSource, @@ -118,11 +118,16 @@ pub struct Ferese { output_identity_ids: HashMap, pub window_ids: HashMap, pub window_geometry: HashMap, + resize_transactions: HashMap, + pub(crate) resize_snapshots: HashMap, + pub(crate) nested_backend: Option, + pub(crate) wallpaper: crate::wallpaper::WallpaperState, maximized_windows: HashSet, maximized_column_widths: HashMap, window_stack: crate::stacking::WindowStack, pub(crate) window_borders: HashMap, pub(crate) window_dims: HashMap, + pub(crate) window_resize_fills: HashMap, pub(crate) window_dimming: HashMap, pub(crate) window_shadows: HashMap, pub(crate) rounded_clip_programs: HashMap, @@ -203,6 +208,7 @@ pub struct Ferese { } pub struct RuntimeConfig { + pub(crate) wallpaper: crate::wallpaper::WallpaperConfig, pub layout_mode: LayoutMode, pub gap_config: GapConfig, pub input_settings: InputSettings, @@ -321,6 +327,15 @@ impl Ferese { .find_map(|(output, id)| (*id == focused).then_some(output)) } + pub(crate) fn restore_output_focus(&mut self) { + if let Some(output) = self.output_workspaces.focused_output() + && let Some(workspace) = self.output_workspaces.active_workspace(output) + { + self.activate_output_workspace(output, workspace); + self.restore_keyboard_focus(); + } + } + pub(crate) fn output_id(&self, output: &Output) -> Option { self.output_ids.get(output).copied() } @@ -340,12 +355,14 @@ impl Ferese { let Some(output_id) = self.output_ids.get(&output).copied() else { return; }; - if self.output_workspaces.focused_output() == Some(output_id) { - return; - } let Some(workspace) = self.output_workspaces.active_workspace(output_id) else { return; }; + if self.output_workspaces.focused_output() == Some(output_id) + && self.workspaces.active_id() == workspace + { + return; + } self.activate_output_workspace(output_id, workspace); self.relayout(); @@ -453,11 +470,16 @@ impl Ferese { output_identity_ids: HashMap::new(), window_ids: HashMap::new(), window_geometry: HashMap::new(), + resize_transactions: HashMap::new(), + resize_snapshots: HashMap::new(), + nested_backend: None, + wallpaper: crate::wallpaper::WallpaperState::new(config.wallpaper), maximized_windows: HashSet::new(), maximized_column_widths: HashMap::new(), window_stack: crate::stacking::WindowStack::default(), window_borders: HashMap::new(), window_dims: HashMap::new(), + window_resize_fills: HashMap::new(), window_dimming: HashMap::new(), window_shadows: HashMap::new(), rounded_clip_programs: HashMap::new(), @@ -596,8 +618,12 @@ impl Ferese { return None; } + let workspace = self.workspace_under_pointer(position)?; self.space.elements().rev().find_map(|window| { let id = self.window_ids.get(window)?; + if self.workspaces.workspace_for_window(*id) != Some(workspace) { + return None; + } let visual = self.presented_window_rect(*id)?; let inside_visual = position.x >= visual.x && position.y >= visual.y @@ -666,8 +692,12 @@ impl Ferese { } pub fn window_under_visual(&self, position: Point) -> Option { + let workspace = self.workspace_under_pointer(position)?; self.space.elements().rev().find_map(|window| { let id = self.window_ids.get(window)?; + if self.workspaces.workspace_for_window(*id) != Some(workspace) { + return None; + } let visual = self.presented_window_rect(*id)?; (position.x >= visual.x @@ -678,6 +708,19 @@ impl Ferese { }) } + fn workspace_under_pointer(&self, position: Point) -> Option { + let output = self.space.output_under(position).next()?; + self.output_workspaces + .active_workspace(self.output_id(output)?) + } + + pub(crate) fn window_belongs_to_output(&self, window: WindowId, output: &Output) -> bool { + let workspace = self + .output_id(output) + .and_then(|output| self.output_workspaces.active_workspace(output)); + workspace.is_some() && self.workspaces.workspace_for_window(window) == workspace + } + pub(crate) fn raise_window(&mut self, window: &Window, activate: bool) { if let Some(id) = self.window_ids.get(window) { self.window_stack.raise(*id); @@ -704,6 +747,9 @@ impl Ferese { } pub fn visual_scale_for_window(&self, window: &Window) -> Option<(f64, f64)> { + if !self.overview.is_presenting() { + return Some((1.0, 1.0)); + } let id = self.window_ids.get(window)?; let geometry = self.window_geometry.get(id)?; let source = geometry.client.committed_size?; @@ -916,6 +962,9 @@ impl Ferese { self.space.unmap_elem(window); self.window_geometry.remove(&id); + self.resize_transactions.remove(&id); + self.resize_snapshots.remove(&id); + self.window_resize_fills.remove(&id); self.maximized_windows.remove(&id); self.maximized_column_widths.remove(&id); self.window_stack.remove(id); @@ -939,7 +988,9 @@ impl Ferese { } pub fn relayout(&mut self) { - self.backdrop_generation = self.backdrop_generation.wrapping_add(1); + // Consume idle time before setting new targets; it must not become a + // large first animation step after a keypress on an idle desktop. + self.advance_animations(Instant::now()); self.arrange_layers(); let mut previous_scrolling_world_x = std::mem::take(&mut self.scrolling_world_x); let pending_column_width_cycles = std::mem::take(&mut self.pending_column_width_cycles); @@ -1071,6 +1122,9 @@ impl Ferese { .map(|(window, _)| window.clone()) .collect::>(); + let mut layout_changed = hidden + .iter() + .any(|window| self.space.element_location(window).is_some()); for window in hidden { self.space.unmap_elem(&window); } @@ -1100,6 +1154,7 @@ impl Ferese { } else { PresentationMode::Normal }; + layout_changed |= !had_geometry || geometry.logical != rect; let mut requested_size = geometry.set_presentation_mode(rect, mode, now); if !self.animations_enabled { geometry.advance(Duration::ZERO, self.spring_config, false); @@ -1198,11 +1253,24 @@ impl Ferese { }); if requested_size.is_some() || state_changed { - toplevel.send_pending_configure(); + if let Some(serial) = toplevel.send_pending_configure() + && requested_size.is_some() + && had_geometry + && self.animations_enabled + { + self.resize_transactions.insert( + id, + crate::resize_transaction::ResizeTransaction::new(serial, now) + .with_source_geometry(window.geometry()), + ); + } } } } self.scrolling_world_x = scrolling_world_x; + if layout_changed { + self.backdrop_generation = self.backdrop_generation.wrapping_add(1); + } self.sync_window_stacking(); self.retarget_overview(); self.send_shell_snapshots(); @@ -1211,8 +1279,7 @@ impl Ferese { } pub fn advance_animations(&mut self, now: Instant) -> bool { - let delta = now.saturating_duration_since(self.last_animation_tick); - self.last_animation_tick = now; + let delta = crate::presentation::frame_delta(&mut self.last_animation_tick, now); self.advance_animations_by(delta) } @@ -1227,8 +1294,11 @@ impl Ferese { .direct_backend .as_mut() .and_then(|backend| backend.record_presentation(node, crtc, time, sequence)); - if let Some(delta) = delta { - self.advance_animations_by(delta); + if delta.is_some() { + // All outputs animate the same scene. Summing each CRTC's frame + // interval makes motion run faster with two monitors (and uneven + // with mixed 60/120 Hz). Advance once by actual elapsed time. + self.advance_animations(Instant::now()); } } @@ -1287,10 +1357,73 @@ impl Ferese { self.send_window_close(id); } + let now = self.start_time.elapsed(); + self.resize_transactions.retain(|id, transaction| { + if transaction.expired(now) { + tracing::warn!(?id, "resize presentation deadline reached"); + false + } else { + true + } + }); + let blocked_workspaces = self + .resize_transactions + .keys() + .filter_map(|id| self.workspaces.workspace_for_window(*id)) + .collect::>(); + let animations_enabled = self.animations_enabled; + let animation_speed = self.animation_speed; + self.resize_snapshots.retain(|id, snapshot| { + if !animations_enabled { + return false; + } + let waiting_for_client = self + .workspaces + .workspace_for_window(*id) + .is_some_and(|workspace| blocked_workspaces.contains(&workspace)); + // A shrinking client's destination buffer arrives before the + // animated bounds reach it. Keep the old native pixels covering + // that strip rather than fading them into the neutral resize fill. + let uncovered = self.window_geometry.get(id).is_some_and(|geometry| { + geometry.client.committed_size.is_some_and(|size| { + crate::presentation::resize_needs_old_frame( + geometry.visual.current, + geometry.logical, + size.width, + size.height, + ) + }) + }); + let blocked = waiting_for_client || uncovered; + let active = crate::presentation::advance_handoff( + &mut snapshot.elapsed, + &mut snapshot.last_tick, + now, + blocked, + animation_speed, + ); + if !blocked { + snapshot.commit.increment(); + } + if !active { + tracing::debug!( + ?id, + bytes = snapshot.bytes(), + "released resize handoff snapshot" + ); + } + active + }); + active_animation |= !self.resize_snapshots.is_empty(); + // Keep scheduling frames while waiting, so the deadline cannot stall. + active_animation |= !blocked_workspaces.is_empty(); for (workspace, viewport) in &mut self.viewport_animations { if !visible_workspaces.contains(workspace) { continue; } + if blocked_workspaces.contains(workspace) { + continue; + } if self.animations_enabled { active_animation |= viewport.advance(delta, self.viewport_spring_config); } else { @@ -1300,6 +1433,13 @@ impl Ferese { let mut settled_coupled_widths = Vec::new(); for (window, id) in windows { + if self + .workspaces + .workspace_for_window(id) + .is_some_and(|workspace| blocked_workspaces.contains(&workspace)) + { + continue; + } let Some(geometry) = self.window_geometry.get_mut(&id) else { continue; }; @@ -1390,10 +1530,115 @@ impl Ferese { self.animations_enabled } + pub(crate) fn capture_resize_before_commit(&mut self, surface: &WlSurface) { + let Some((window, id)) = self + .window_ids + .iter() + .find(|(window, _)| { + window + .toplevel() + .is_some_and(|toplevel| toplevel.wl_surface() == surface) + }) + .map(|(window, id)| (window.clone(), *id)) + else { + return; + }; + let Some(transaction) = self.resize_transactions.get(&id) else { + return; + }; + let serial = with_states(surface, |states| { + states + .data_map + .get::() + .and_then(|data| data.lock().ok().and_then(|data| data.current_serial)) + }); + if !transaction.accepts(serial) { + return; + } + let Some(output) = self + .space + .outputs() + .find(|output| self.window_belongs_to_output(id, output)) + .cloned() + else { + return; + }; + let source_geometry = transaction + .source_geometry() + .unwrap_or_else(|| window.geometry()); + let scale = output.current_scale().fractional_scale(); + let source_size = source_geometry.size.to_physical_precise_round(scale); + if self.resize_snapshots.get(&id).is_some_and(|snapshot| { + crate::presentation::snapshot_covers_source( + smithay::backend::renderer::Texture::size(&snapshot.texture), + source_size, + snapshot.scale, + scale, + ) + }) { + return; + } + let used: usize = self + .resize_snapshots + .values() + .map(|snapshot| snapshot.bytes()) + .sum(); + let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used); + let result = if let Some(backend) = &self.nested_backend { + // Commit dispatch does not run inside the Winit event callback; + // still never risk reentrant renderer access or a compositor panic. + match backend.try_borrow_mut() { + Ok(mut backend) => crate::winit::capture_resize_snapshot( + backend.renderer(), + &window, + source_geometry, + output.current_scale().fractional_scale(), + remaining, + ), + Err(_) => return, + } + } else if let Some(backend) = &mut self.direct_backend { + backend.capture_resize_snapshot(&window, source_geometry, &output, remaining) + } else { + return; + }; + match result { + Ok(Some(snapshot)) => { + tracing::debug!( + ?id, + bytes = snapshot.bytes(), + "captured native resize handoff" + ); + self.resize_snapshots.insert(id, snapshot); + } + Ok(None) => {} + Err(error) => { + tracing::debug!(%error, ?id, "resize snapshot unavailable; using native crop") + } + } + } + pub fn record_client_commit(&mut self, window: &Window) { let Some(id) = self.window_ids.get(window) else { return; }; + // current_serial is promoted by Smithay on commit, unlike + // configure_serial, which only records an acknowledgement. + let committed_serial = window.toplevel().and_then(|toplevel| { + with_states(toplevel.wl_surface(), |states| { + states + .data_map + .get::() + .and_then(|data| data.lock().ok().and_then(|data| data.current_serial)) + }) + }); + if self + .resize_transactions + .get(id) + .is_some_and(|transaction| transaction.accepts(committed_serial)) + { + self.resize_transactions.remove(id); + } let Some(size) = client_size(window) else { return; }; @@ -1869,6 +2114,8 @@ impl Ferese { // Direct manipulation follows the pointer, including while the client is // still drawing its next buffer. Rendering and hit testing share this rect. + self.resize_transactions.remove(&id); + self.resize_snapshots.remove(&id); let geometry = self .window_geometry .entry(id) diff --git a/compositor/src/wallpaper.rs b/compositor/src/wallpaper.rs new file mode 100644 index 0000000..c540b16 --- /dev/null +++ b/compositor/src/wallpaper.rs @@ -0,0 +1,227 @@ +//! One decoded image and one texture per GPU context, not double-buffered +//! full-screen UI surfaces per monitor. Resize changes sampling only. +use crate::presentation::NativeTextureElement; +use serde::Deserialize; +use smithay::{ + backend::{ + allocator::Fourcc, + renderer::{ + ErasedContextId, ImportMem, Renderer, + element::Id, + gles::{GlesRenderer, GlesTexture}, + utils::CommitCounter, + }, + }, + output::Output, + utils::{Buffer, Physical, Rectangle, Size}, +}; +use std::{collections::HashMap, path::PathBuf, sync::mpsc}; + +#[derive(Clone, Debug, Default, Deserialize)] +pub(crate) struct WallpaperConfig { + pub path: Option, + #[serde(default)] + pub mode: WallpaperMode, +} +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum WallpaperMode { + #[default] + Fill, + Fit, +} + +struct WallpaperTexture { + texture: GlesTexture, + id: Id, +} +pub(crate) struct WallpaperState { + owned: bool, + mode: WallpaperMode, + receiver: Option>>, + pixels: Option, + textures: HashMap, +} + +impl WallpaperState { + pub fn new(config: WallpaperConfig) -> Self { + let (sender, receiver) = mpsc::channel(); + let owned = config.path.as_ref().is_some_and(|path| path.is_file()); + if owned { + let path = config.path.unwrap(); + std::thread::spawn(move || { + let load = || -> Result { + let (width, height) = + image::image_dimensions(&path).map_err(|e| e.to_string())?; + if u64::from(width) * u64::from(height) * 4 > 256 * 1024 * 1024 { + return Err("wallpaper exceeds the 256 MiB decode limit".into()); + } + image::open(path) + .map(|image| image.into_rgba8()) + .map_err(|e| e.to_string()) + }; + let _ = sender.send(load()); + }); + } + Self { + owned, + mode: config.mode, + receiver: owned.then_some(receiver), + pixels: None, + textures: HashMap::new(), + } + } + pub fn owns_background(&self) -> bool { + self.owned + } + pub fn forget_context(&mut self, context: &ErasedContextId) { + self.textures.remove(context); + } + pub fn poll(&mut self) -> bool { + let Some(receiver) = &self.receiver else { + return false; + }; + match receiver.try_recv() { + Ok(result) => { + self.receiver = None; + match result { + Ok(pixels) => { + tracing::info!( + width = pixels.width(), + height = pixels.height(), + bytes = pixels.as_raw().len(), + "decoded compositor wallpaper once" + ); + self.pixels = Some(pixels); + } + Err(error) => { + tracing::warn!(%error, "wallpaper unavailable; using output clear color") + } + } + true + } + Err(mpsc::TryRecvError::Disconnected) => { + self.receiver = None; + false + } + Err(mpsc::TryRecvError::Empty) => false, + } + } + pub fn element( + &mut self, + renderer: &mut GlesRenderer, + output: &Output, + ) -> Option { + let pixels = self.pixels.as_ref()?; + let context = renderer.context_id().erased(); + if !self.textures.contains_key(&context) { + let size = Size::from((pixels.width() as i32, pixels.height() as i32)); + match renderer.import_memory(pixels.as_raw(), Fourcc::Abgr8888, size, false) { + Ok(texture) => { + tracing::debug!( + bytes = pixels.as_raw().len(), + "uploaded shared wallpaper texture" + ); + self.textures.insert( + context.clone(), + WallpaperTexture { + texture, + id: Id::new(), + }, + ); + } + Err(error) => { + tracing::debug!(%error, "wallpaper texture import failed"); + return None; + } + } + } + let cached = self.textures.get(&context)?; + let output_size = output + .current_transform() + .transform_size(output.current_mode()?.size); + let (geometry, source) = image_geometry( + (pixels.width() as i32, pixels.height() as i32).into(), + output_size, + self.mode, + ); + Some(NativeTextureElement { + id: cached.id.clone(), + commit: CommitCounter::default(), + texture: cached.texture.clone(), + geometry, + source, + alpha: 1.0, + program: None, + uniforms: vec![], + }) + } +} + +fn image_geometry( + image: Size, + output: Size, + mode: WallpaperMode, +) -> (Rectangle, Rectangle) { + let sx = f64::from(output.w) / f64::from(image.w); + let sy = f64::from(output.h) / f64::from(image.h); + match mode { + WallpaperMode::Fill => { + let scale = sx.max(sy); + let width = f64::from(output.w) / scale; + let height = f64::from(output.h) / scale; + ( + Rectangle::from_size(output), + Rectangle::new( + ( + (f64::from(image.w) - width) / 2.0, + (f64::from(image.h) - height) / 2.0, + ) + .into(), + (width, height).into(), + ), + ) + } + WallpaperMode::Fit => { + let scale = sx.min(sy); + let size: Size = ( + (f64::from(image.w) * scale).round() as i32, + (f64::from(image.h) * scale).round() as i32, + ) + .into(); + ( + Rectangle::new( + ((output.w - size.w) / 2, (output.h - size.h) / 2).into(), + size, + ), + Rectangle::from_size(image.to_f64()), + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn fill_crops_and_fit_letterboxes_without_reallocating_image() { + let (geometry, source) = image_geometry( + (3840, 2160).into(), + (1600, 1000).into(), + WallpaperMode::Fill, + ); + assert_eq!(geometry.size, (1600, 1000).into()); + assert_eq!(source.size.h, 2160.0); + assert!(source.loc.x > 0.0); + let (geometry, source) = + image_geometry((3840, 2160).into(), (1600, 1000).into(), WallpaperMode::Fit); + assert_eq!(geometry.size, (1600, 900).into()); + assert_eq!(geometry.loc, (0, 50).into()); + assert_eq!(source.size, (3840.0, 2160.0).into()); + } + #[test] + fn missing_wallpaper_preserves_external_shell_fallback() { + let state = WallpaperState::new(WallpaperConfig::default()); + assert!(!state.owns_background()); + } +} diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index ed2263d..d7766a6 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -1,6 +1,8 @@ use std::{ + cell::{Cell, RefCell}, collections::HashMap, error::Error, + rc::Rc, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -12,7 +14,7 @@ use smithay::{ backend::{ allocator::Fourcc, renderer::{ - Color32F, ErasedContextId, Frame, ImportDma, Offscreen, Renderer, Texture, + Bind, Color32F, ErasedContextId, Frame, ImportDma, Offscreen, Renderer, Texture, damage::OutputDamageTracker, element::{ Element, Id, Kind as RenderElementKind, RenderElement, UnderlyingStorage, @@ -43,7 +45,10 @@ use smithay::{ }, input::pointer::{CursorImageStatus, CursorImageSurfaceData}, output::{Mode, Output, PhysicalProperties, Scale, Subpixel}, - reexports::calloop::EventLoop, + reexports::calloop::{ + EventLoop, + timer::{TimeoutAction, Timer}, + }, reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback::Kind as PresentationKind, reexports::wayland_server::Resource, utils::{ @@ -57,6 +62,7 @@ use smithay::{ use crate::{ Ferese, metrics::{FrameEffectMetrics, RenderMetrics}, + presentation::{NativeTextureElement, PhysicalShaderElement, physical_rect}, }; type SurfaceRenderElement = CropRenderElement< @@ -81,6 +87,85 @@ render_elements! { Solid=SolidColorRenderElement, Border=PixelShaderElement, Blur=BlurRenderElement, + Effect=PhysicalShaderElement, + Native=NativeTextureElement, +} + +pub(crate) type NestedBackend = Rc>>; + +#[derive(Debug)] +pub(crate) struct ResizeSnapshot { + pub texture: GlesTexture, + pub context: ErasedContextId, + pub id: Id, + pub commit: CommitCounter, + pub elapsed: Duration, + pub last_tick: Option, + pub scale: f64, +} + +impl ResizeSnapshot { + pub(crate) fn bytes(&self) -> usize { + self.texture.size().w as usize * self.texture.size().h as usize * 4 + } +} + +pub(crate) fn capture_resize_snapshot( + renderer: &mut GlesRenderer, + window: &smithay::desktop::Window, + geometry: Rectangle, + scale: f64, + remaining: usize, +) -> Result, GlesError> { + let Some(toplevel) = window.toplevel() else { + return Ok(None); + }; + let size: Size = geometry.size.to_physical_precise_round(scale); + let bytes = (size.w.max(0) as usize) + .saturating_mul(size.h.max(0) as usize) + .saturating_mul(4); + if bytes == 0 || bytes > remaining { + return Ok(None); + } + // Called before on_commit_buffer_handler: renderer surface state still + // owns the previous buffer. Copy only during an actual resize handoff. + let content = render_elements_from_surface_tree::< + GlesRenderer, + WaylandSurfaceRenderElement, + >( + renderer, + toplevel.wl_surface(), + Point::::from((-geometry.loc.x, -geometry.loc.y)) + .to_physical_precise_round(scale), + scale, + 1.0, + RenderElementKind::Unspecified, + ); + if content.is_empty() { + return Ok(None); + } + let mut texture = Offscreen::::create_buffer( + renderer, + Fourcc::Abgr8888, + (size.w, size.h).into(), + )?; + { + let mut target = renderer.bind(&mut texture)?; + let mut frame = renderer.render(&mut target, size, Transform::Normal)?; + let damage = Rectangle::from_size(size); + frame.clear(Color32F::new(0.0, 0.0, 0.0, 0.0), &[damage])?; + draw_render_elements(&mut frame, scale, &content, &[damage])?; + let _ = frame.finish()?; + } + Ok(Some(ResizeSnapshot { + texture, + context: renderer.context_id().erased(), + id: Id::new(), + commit: CommitCounter::default(), + elapsed: Duration::ZERO, + last_tick: None, + scale, + })) } render_elements! { @@ -664,7 +749,11 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() let initial_scale = normalized_scale(backend.scale_factor()); let mode = Mode { size: backend.window_size(), - refresh: 60_000, + refresh: backend + .window() + .current_monitor() + .and_then(|monitor| monitor.refresh_rate_millihertz()) + .unwrap_or(60_000) as i32, }; let output = Output::new( "ferese-winit".into(), @@ -689,163 +778,192 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() let mut damage_tracker = OutputDamageTracker::from_output(&output); let clock = Clock::::new(); let mut sequence = 0_u64; + let mut missed_deadlines = 0_u64; let mut output_scale = initial_scale; let mut render_metrics = RenderMetrics::from_environment(output.name()); + // Do not request a redraw recursively: a no-damage redraw has no EGL + // submission to pace it and otherwise spins at full CPU. Independently + // scheduled frames also let clients receive callbacks without damage. + let refresh = Rc::new(Cell::new(Duration::from_nanos( + 1_000_000_000_000 / mode.refresh.max(1) as u64, + ))); + let backend = Rc::new(RefCell::new(backend)); + state.nested_backend = Some(backend.clone()); + let redraw_backend = backend.clone(); + let redraw_refresh = refresh.clone(); event_loop .handle() - .insert_source(event_source, move |event, _, state| match event { - WinitEvent::Resized { size, scale_factor } => { - let scale = normalized_scale(scale_factor); + .insert_source(Timer::from_duration(refresh.get()), move |_, _, _| { + redraw_backend.borrow().window().request_redraw(); + TimeoutAction::ToDuration(redraw_refresh.get()) + })?; - output.change_current_state( - Some(Mode { - size, - refresh: 60_000, - }), - None, - Some(Scale::Fractional(scale)), - None, - ); + event_loop + .handle() + .insert_source(event_source, move |event, _, state| { + let mut backend = backend.borrow_mut(); + match event { + WinitEvent::Resized { size, scale_factor } => { + state.backdrop_generation = state.backdrop_generation.wrapping_add(1); + let scale = normalized_scale(scale_factor); + let rate = backend + .window() + .current_monitor() + .and_then(|monitor| monitor.refresh_rate_millihertz()) + .unwrap_or(60_000) as i32; + refresh.set(Duration::from_nanos(1_000_000_000_000 / rate.max(1) as u64)); - if scale != output_scale { - output_scale = scale; - state.update_fractional_scale(scale); + output.change_current_state( + Some(Mode { + size, + refresh: rate, + }), + None, + Some(Scale::Fractional(scale)), + None, + ); + + if scale != output_scale { + output_scale = scale; + state.update_fractional_scale(scale); + } + + state.relayout(); + if let Err(error) = state.display_handle.flush_clients() { + tracing::debug!(%error, "failed to flush output-resize configure"); + } + backend.window().request_redraw(); } - - state.relayout(); - if let Err(error) = state.display_handle.flush_clients() { - tracing::debug!(%error, "failed to flush output-resize configure"); - } - backend.window().request_redraw(); - } - WinitEvent::Input(event) => state.process_input_event(event), - WinitEvent::Redraw => { - state.advance_animations(Instant::now()); - let age = backend.buffer_age().unwrap_or(0); - let render_started = Instant::now(); - let rendered = (|| -> DamageRenderResult { - { - let (renderer, mut framebuffer) = backend.bind()?; - state.process_dmabuf_imports(renderer); - let elements = animated_window_elements(state, renderer, &output); - let effects = frame_effect_metrics( - &elements, - output.current_scale().fractional_scale(), - ); - let result = damage_tracker.render_output( - renderer, - &mut framebuffer, - age, - &elements, - [0.035, 0.04, 0.055, 1.0], - )?; - let cursorless_capture = state.has_pending_screencopy(&output, false); - let captured_with_cursor = - state.process_screencopies(renderer, &framebuffer, &output, true); - - if cursorless_capture { - let cursorless_elements = - output_elements(state, renderer, &output, false); - redraw_output( + WinitEvent::Input(event) => state.process_input_event(event), + WinitEvent::Redraw => { + state.advance_animations(Instant::now()); + let age = backend.buffer_age().unwrap_or(0); + let render_started = Instant::now(); + let rendered = (|| -> DamageRenderResult { + { + let (renderer, mut framebuffer) = backend.bind()?; + state.process_dmabuf_imports(renderer); + let elements = animated_window_elements(state, renderer, &output); + let effects = frame_effect_metrics( + &elements, + output.current_scale().fractional_scale(), + ); + let result = damage_tracker.render_output( renderer, &mut framebuffer, - &output, - &cursorless_elements, + age, + &elements, + [0.035, 0.04, 0.055, 1.0], )?; - state.process_screencopies(renderer, &framebuffer, &output, false); - redraw_output(renderer, &mut framebuffer, &output, &elements)?; - } else if captured_with_cursor { - let _ = renderer - .render( - &mut framebuffer, - output.current_mode().expect("output has a mode").size, - output.current_transform(), - )? - .finish()?; - } + let cursorless_capture = state.has_pending_screencopy(&output, false); + let captured_with_cursor = + state.process_screencopies(renderer, &framebuffer, &output, true); - Ok((result.damage.cloned(), effects)) - } - })(); - let (damage, effects) = match rendered { - Ok((Some(damage), effects)) => (damage, effects), - Ok((None, _)) => { - state.space.refresh(); - state.popups.cleanup(); - layer_map_for_output(&output).cleanup(); - if let Err(error) = state.display_handle.flush_clients() { - tracing::debug!(%error, "failed to flush clients"); + if cursorless_capture { + let cursorless_elements = + output_elements(state, renderer, &output, false); + redraw_output( + renderer, + &mut framebuffer, + &output, + &cursorless_elements, + )?; + state.process_screencopies(renderer, &framebuffer, &output, false); + redraw_output(renderer, &mut framebuffer, &output, &elements)?; + } else if captured_with_cursor { + let _ = renderer + .render( + &mut framebuffer, + output.current_mode().expect("output has a mode").size, + output.current_transform(), + )? + .finish()?; + } + + Ok((result.damage.cloned(), effects)) } - backend.window().request_redraw(); - return; - } - Err(error) => { - tracing::error!(%error, "nested renderer failed"); + })(); + let (damage, effects) = match rendered { + Ok((Some(damage), effects)) => (damage, effects), + Ok((None, _)) => { + // A callback means permission to draw the next client + // frame, not proof of a new compositor presentation. + // No-damage frames must still unblock layer clients. + send_nested_frame_callbacks(state, &output, refresh.get()); + state.space.refresh(); + state.popups.cleanup(); + layer_map_for_output(&output).cleanup(); + if let Err(error) = state.display_handle.flush_clients() { + tracing::debug!(%error, "failed to flush clients"); + } + return; + } + Err(error) => { + tracing::error!(%error, "nested renderer failed"); + state.loop_signal.stop(); + return; + } + }; + if let Err(error) = backend.submit(Some(&damage)) { + tracing::error!(%error, "nested buffer submission failed"); state.loop_signal.stop(); return; } - }; - if let Err(error) = backend.submit(Some(&damage)) { - tracing::error!(%error, "nested buffer submission failed"); - state.loop_signal.stop(); - return; - } - render_metrics.record_frame(render_started.elapsed(), &damage, 0, effects); + let elapsed = render_started.elapsed(); + if elapsed > refresh.get() { + missed_deadlines += (elapsed.as_nanos() / refresh.get().as_nanos()) as u64; + } + render_metrics.record_frame(elapsed, &damage, missed_deadlines, effects); - let mut presentation = OutputPresentationFeedback::new(&output); - state.space.elements().for_each(|window| { - window.take_presentation_feedback( - &mut presentation, - |_, _| Some(output.clone()), - |_, _| PresentationKind::Vsync, + let mut presentation = OutputPresentationFeedback::new(&output); + state.space.elements().for_each(|window| { + window.take_presentation_feedback( + &mut presentation, + |_, _| Some(output.clone()), + |_, _| PresentationKind::Vsync, + ); + }); + layer_surfaces(&output).iter().for_each(|layer| { + layer.take_presentation_feedback( + &mut presentation, + |_, _| Some(output.clone()), + |_, _| PresentationKind::Vsync, + ); + }); + sequence = sequence.wrapping_add(1); + presentation.presented( + clock.now(), + Refresh::fixed(refresh.get()), + sequence, + PresentationKind::Vsync, ); - }); - layer_surfaces(&output).iter().for_each(|layer| { - layer.take_presentation_feedback( - &mut presentation, - |_, _| Some(output.clone()), - |_, _| PresentationKind::Vsync, - ); - }); - sequence = sequence.wrapping_add(1); - presentation.presented( - clock.now(), - Refresh::fixed(Duration::from_nanos(1_000_000_000 / 60)), - sequence, - PresentationKind::Vsync, - ); - state.space.elements().for_each(|window| { - window.send_frame( - &output, - state.start_time.elapsed(), - Some(Duration::ZERO), - |_, _| Some(output.clone()), - ); - }); - layer_surfaces(&output).iter().for_each(|layer| { - layer.send_frame( - &output, - state.start_time.elapsed(), - Some(Duration::ZERO), - |_, _| Some(output.clone()), - ); - }); - state.send_cursor_frame(&output); - state.space.refresh(); - state.popups.cleanup(); - layer_map_for_output(&output).cleanup(); - if let Err(error) = state.display_handle.flush_clients() { - tracing::debug!(%error, "failed to flush clients"); + send_nested_frame_callbacks(state, &output, refresh.get()); + state.space.refresh(); + state.popups.cleanup(); + layer_map_for_output(&output).cleanup(); + if let Err(error) = state.display_handle.flush_clients() { + tracing::debug!(%error, "failed to flush clients"); + } } - backend.window().request_redraw(); + WinitEvent::CloseRequested => state.loop_signal.stop(), + _ => {} } - WinitEvent::CloseRequested => state.loop_signal.stop(), - _ => {} })?; Ok(()) } +fn send_nested_frame_callbacks(state: &mut Ferese, output: &Output, refresh: Duration) { + let time = state.start_time.elapsed(); + for window in state.space.elements() { + window.send_frame(output, time, Some(refresh), |_, _| Some(output.clone())); + } + for layer in layer_surfaces(output) { + layer.send_frame(output, time, Some(refresh), |_, _| Some(output.clone())); + } + state.send_cursor_frame(output); +} + pub(crate) fn animated_window_elements( state: &mut Ferese, renderer: &mut GlesRenderer, @@ -875,6 +993,9 @@ fn output_elements( output: &Output, include_cursor: bool, ) -> Vec { + if state.wallpaper.poll() { + state.backdrop_generation = state.backdrop_generation.wrapping_add(1); + } let Some(output_geometry) = state.space.output_geometry(output) else { return Vec::new(); }; @@ -898,6 +1019,12 @@ fn output_elements( .rev() .filter_map(|window| { let id = *state.window_ids.get(window)?; + // Scrolling columns may sit outside their monitor's rectangle. + // They must not reappear on a neighboring output just because + // their global animated coordinates overlap it. + if !state.window_belongs_to_output(id, output) { + return None; + } let visual = state.presented_window_rect(id)?; let (close_scale, close_alpha) = state.closing_visual(id); let visual = scaled_visual_rect(visual, close_scale); @@ -912,16 +1039,22 @@ fn output_elements( for (window, id, visual, decoration_progress, close_alpha) in windows { let constrain = rounded_visual_rect(visual, output_geometry.loc); + let pixels = physical_rect(visual, output_geometry.loc, scale); + // Only overview/close intentionally scale the complete application. + let scale_content = state.overview.is_presenting() || state.closing_visual(id).0 != 1.0; + let behavior = resize_content_behavior(scale_content); let dim = state.window_dimming.get(&id).map_or(0.0, |dim| dim.current); - if let Some(overlay) = window_dim_element( + if let Some(overlay) = window_tint_element( state, renderer, id, constrain, + pixels, scale, state.theme_settings.window_radius * decoration_progress, - dim as f32 * close_alpha, + [0.0, 0.0, 0.0, dim as f32 * close_alpha], + false, output, ) { // Front-to-back: dim the application and its border, not its shadow @@ -958,6 +1091,7 @@ fn output_elements( renderer, id, constrain, + pixels, scale, window_radius, border_width, @@ -972,6 +1106,7 @@ fn output_elements( renderer, id, constrain, + pixels, scale, window_radius, shadow_offset_y, @@ -981,10 +1116,52 @@ fn output_elements( output, &programs, ); + if !scale_content { + if let Some(snapshot) = state.resize_snapshots.get(&id) + && snapshot.context == renderer.context_id().erased() + && (snapshot.scale - scale).abs() < 0.001 + { + let size = snapshot.texture.size(); + let visible = + Rectangle::new(pixels.loc, (size.w, size.h).into()).intersection(pixels); + if let Some(visible) = visible { + let clip = framebuffer_clip_rect( + pixels, + output.current_mode().unwrap().size, + output.current_transform().invert(), + ); + elements.push( + NativeTextureElement { + id: snapshot.id.clone(), + commit: snapshot.commit, + texture: snapshot.texture.clone(), + geometry: visible, + source: Rectangle::from_size(Size::from(( + f64::from(visible.size.w), + f64::from(visible.size.h), + ))), + alpha: crate::presentation::handoff_alpha(snapshot.elapsed) + * close_alpha, + program: Some(programs.texture.clone()), + uniforms: vec![ + Uniform::new("clip_rect", clip).into_owned(), + Uniform::new( + "radius", + scaled_effect_value(window_radius, constrain, scale), + ) + .into_owned(), + ], + } + .into(), + ); + } + } + } elements.extend(rounded_window_elements( renderer, &window, constrain, + pixels, scale, window_radius, close_alpha, @@ -994,7 +1171,31 @@ fn output_elements( .is_some_and(|geometry| geometry.presentation_changed), output, programs.texture.clone(), + behavior, )); + let source = window.geometry().size; + if !scale_content && (source.w < constrain.size.w || source.h < constrain.size.h) { + let mut color = state.theme_settings.surface_base_color.0; + color[3] = close_alpha; + if let Some(fill) = window_tint_element( + state, + renderer, + id, + constrain, + pixels, + scale, + window_radius, + color, + true, + output, + ) { + // Front-to-back: fill uncovered strips behind the native + // content instead of stretching it or exposing wallpaper. + elements.push(fill.into()); + } + } else { + state.window_resize_fills.remove(&id); + } if let Some(shadow) = shadow { elements.push(shadow.into()); } @@ -1012,7 +1213,7 @@ fn output_elements( constrain, ConstrainBehavior { reference: ConstrainReference::Geometry, - behavior: ConstrainScaleBehavior::Stretch, + behavior, align: ConstrainAlign::TOP | ConstrainAlign::LEFT, }, )); @@ -1029,6 +1230,9 @@ fn output_elements( output, &[Layer::Bottom, Layer::Background], )); + if let Some(wallpaper) = state.wallpaper.element(renderer, output) { + elements.push(wallpaper.into()); + } elements } @@ -1114,13 +1318,14 @@ fn window_border_element( renderer: &GlesRenderer, id: ferese_layout::WindowId, geometry: Rectangle, + physical: Rectangle, scale: f64, requested_radius: f64, requested_width: f64, color: [f32; 4], output: &Output, programs: &RoundedClipPrograms, -) -> Option { +) -> Option { let mode = output.current_mode()?; let width = scaled_effect_value(requested_width, geometry, scale); if width == 0.0 { @@ -1129,11 +1334,7 @@ fn window_border_element( let parameters = BorderParameters { geometry, - clip_rect: framebuffer_clip_rect( - geometry.to_physical_precise_round(scale), - mode.size, - output.current_transform().invert(), - ), + clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()), radius: scaled_effect_value(requested_radius, geometry, scale), width, color, @@ -1167,7 +1368,10 @@ fn window_border_element( cached.parameters = parameters; } - Some(cached.element.clone()) + Some(PhysicalShaderElement { + inner: cached.element.clone(), + geometry: physical, + }) } fn border_uniforms(parameters: &BorderParameters) -> Vec> { @@ -1180,32 +1384,34 @@ fn border_uniforms(parameters: &BorderParameters) -> Vec> { } #[allow(clippy::too_many_arguments)] -fn window_dim_element( +fn window_tint_element( state: &mut Ferese, renderer: &mut GlesRenderer, id: ferese_layout::WindowId, geometry: Rectangle, + physical: Rectangle, scale: f64, radius: f64, - amount: f32, + color: [f32; 4], + resize_fill: bool, output: &Output, -) -> Option { - if amount <= 0.0 { - state.window_dims.remove(&id); +) -> Option { + if color[3] <= 0.0 { + if resize_fill { + state.window_resize_fills.remove(&id); + } else { + state.window_dims.remove(&id); + } return None; } let mode = output.current_mode()?; let program = material_program(state, renderer)?; let parameters = BorderParameters { geometry, - clip_rect: framebuffer_clip_rect( - geometry.to_physical_precise_round(scale), - mode.size, - output.current_transform().invert(), - ), + clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()), radius: scaled_effect_value(radius, geometry, scale), width: 0.0, - color: [0.0, 0.0, 0.0, amount.clamp(0.0, 1.0)], + color, }; let uniforms = |p: &BorderParameters| { vec![ @@ -1218,7 +1424,11 @@ fn window_dim_element( ] }; let context = renderer.context_id().erased(); - let buffers = state.window_dims.entry(id).or_default(); + let buffers = if resize_fill { + state.window_resize_fills.entry(id).or_default() + } else { + state.window_dims.entry(id).or_default() + }; let cached = buffers .contexts .entry(context) @@ -1242,7 +1452,10 @@ fn window_dim_element( cached.element.update_uniforms(uniforms(¶meters)); cached.parameters = parameters; } - Some(cached.element.clone()) + Some(PhysicalShaderElement { + inner: cached.element.clone(), + geometry: physical, + }) } #[allow(clippy::too_many_arguments)] @@ -1251,6 +1464,7 @@ fn window_shadow_element( renderer: &GlesRenderer, id: ferese_layout::WindowId, geometry: Rectangle, + physical: Rectangle, scale: f64, requested_radius: f64, offset_y: f64, @@ -1259,22 +1473,26 @@ fn window_shadow_element( color: [f32; 4], output: &Output, programs: &RoundedClipPrograms, -) -> Option { +) -> Option { let mode = output.current_mode()?; if opacity == 0.0 || color[3] == 0.0 { return None; } let shadow_geometry = Rectangle::new( - (geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(), - geometry.size, + ( + physical.loc.x, + physical.loc.y + (offset_y * scale).round() as i32, + ) + .into(), + physical.size, ); let bounds = shadow_bounds(geometry, offset_y, blur); let parameters = ShadowParameters { blur: (blur * scale) as f32, bounds, shadow_rect: framebuffer_clip_rect( - shadow_geometry.to_physical_precise_round(scale), + shadow_geometry, mode.size, output.current_transform().invert(), ), @@ -1311,7 +1529,22 @@ fn window_shadow_element( cached.parameters = parameters; } - Some(cached.element.clone()) + let extent = (blur * scale * 2.0).ceil() as i32; + Some(PhysicalShaderElement { + inner: cached.element.clone(), + geometry: Rectangle::new( + ( + shadow_geometry.loc.x - extent, + shadow_geometry.loc.y - extent, + ) + .into(), + ( + shadow_geometry.size.w + 2 * extent, + shadow_geometry.size.h + 2 * extent, + ) + .into(), + ), + }) } fn shadow_uniforms(parameters: &ShadowParameters) -> Vec> { @@ -1350,12 +1583,14 @@ fn rounded_window_elements( renderer: &mut GlesRenderer, window: &smithay::desktop::Window, constrain: Rectangle, + physical_constrain: Rectangle, scale: f64, requested_radius: f64, alpha: f32, clip_changed: bool, output: &Output, program: GlesTexProgram, + behavior: ConstrainScaleBehavior, ) -> Vec { let Some(toplevel) = window.toplevel() else { return Vec::new(); @@ -1366,8 +1601,7 @@ fn rounded_window_elements( let geometry = window.geometry(); let reference = geometry.to_physical_precise_round(scale); - let physical_constrain = constrain.to_physical_precise_round(scale); - let location = (constrain.loc - geometry.loc).to_physical_precise_round(scale); + let location = physical_constrain.loc - geometry.loc.to_physical_precise_round(scale); let clip = framebuffer_clip_rect( physical_constrain, mode.size, @@ -1426,7 +1660,7 @@ fn rounded_window_elements( location, physical_constrain, reference, - ConstrainScaleBehavior::Stretch, + behavior, ConstrainAlign::TOP | ConstrainAlign::LEFT, scale, ) @@ -1434,6 +1668,14 @@ fn rounded_window_elements( .collect() } +fn resize_content_behavior(intentional_scale: bool) -> ConstrainScaleBehavior { + if intentional_scale { + ConstrainScaleBehavior::Stretch + } else { + ConstrainScaleBehavior::CutOff + } +} + fn scaled_visual_rect(rect: ferese_layout::Rect, scale: f64) -> ferese_layout::Rect { let scale = scale.clamp(0.0, 1.0); let width = rect.width * scale; @@ -2056,8 +2298,8 @@ mod tests { }; use super::{ - color_with_alpha, framebuffer_clip_rect, normalized_scale, rounded_visual_rect, - scaled_visual_rect, shadow_bounds, + color_with_alpha, framebuffer_clip_rect, normalized_scale, resize_content_behavior, + rounded_visual_rect, scaled_visual_rect, shadow_bounds, }; #[derive(Debug)] @@ -2114,6 +2356,44 @@ mod tests { } } + #[test] + fn resize_keeps_source_pixels_native_when_growing_and_shrinking() { + use smithay::backend::renderer::element::utils::{ + ConstrainAlign, constrain_render_elements, + }; + for scale in [1.0, 1.5, 1.8, 2.0] { + for width in [300, 600, 1200] { + let source = Rectangle::::from_size((600, 800).into()); + let destination = Rectangle::::from_size((width, 800).into()); + let element = constrain_render_elements( + [DamageElement::new(source)], + (0, 0), + destination.to_physical_precise_round(scale), + source.to_physical_precise_round(scale), + resize_content_behavior(false), + ConstrainAlign::TOP | ConstrainAlign::LEFT, + scale, + ) + .next() + .unwrap(); + // Comparing destination pixels to sampled source pixels catches + // stretching, unlike tests of configure timing alone. + let physical = element.geometry(scale.into()); + let sampled = element.src(); + assert!((f64::from(physical.size.w) / sampled.size.w - scale).abs() < 0.01); + assert!((f64::from(physical.size.h) / sampled.size.h - scale).abs() < 0.01); + } + } + } + + #[test] + fn overview_retains_intentional_content_scaling() { + assert!(matches!( + resize_content_behavior(true), + smithay::backend::renderer::element::utils::ConstrainScaleBehavior::Stretch + )); + } + #[test] fn accepts_positive_finite_scale() { assert_eq!(normalized_scale(1.25), 1.25); diff --git a/crates/ferese-animation/src/lib.rs b/crates/ferese-animation/src/lib.rs index cbb8a42..7421cbe 100644 --- a/crates/ferese-animation/src/lib.rs +++ b/crates/ferese-animation/src/lib.rs @@ -380,8 +380,9 @@ impl WindowGeometry { self.presentation_size_request(now) } - /// Configure the destination once. Intermediate sizes make clients reflow - /// and replace buffers on every frame, disrupting an otherwise smooth zoom. + /// Request the destination once, independently of the presentation clock. + /// The compositor coordinates commits; delaying this until settlement + /// forces presentation to use an obsolete buffer for the entire resize. pub fn presentation_size_request(&mut self, now: Duration) -> Option { let size = ClientSize::from_rect(self.logical); self.client.request_size(size, now).then_some(size) @@ -710,7 +711,7 @@ mod tests { } #[test] - fn zoom_requests_destination_once_even_with_a_responsive_client() { + fn zoom_configures_destination_once_before_animating() { let start = Rect::new(0.0, 0.0, 600.0, 800.0); let end = Rect::new(0.0, 0.0, 1920.0, 1080.0); let mut geometry = WindowGeometry::new(start, Some(ClientSize::from_rect(start))); @@ -718,20 +719,72 @@ mod tests { geometry.set_presentation_target(end, true, Duration::ZERO), Some(ClientSize::from_rect(end)) ); - geometry.client.commit(ClientSize::from_rect(end)); for frame in 0..240 { + let now = Duration::from_millis(frame * 8); geometry.advance(Duration::from_millis(8), SpringConfig::default(), true); - assert_eq!( - geometry.presentation_size_request(Duration::from_millis(frame * 8)), - None - ); + assert_eq!(geometry.presentation_size_request(now), None); } + assert_eq!(geometry.visual.current, end); assert_eq!( geometry.set_presentation_target(start, false, Duration::from_secs(2)), Some(ClientSize::from_rect(start)) ); } + #[test] + fn animated_resize_configures_each_new_target_without_framewise_requests() { + let start = Rect::new(10.0, 10.0, 600.0, 800.0); + let middle = Rect::new(10.0, 10.0, 900.0, 800.0); + let end = Rect::new(10.0, 10.0, 450.0, 800.0); + let mut geometry = WindowGeometry::new(start, Some(ClientSize::from_rect(start))); + assert_eq!( + geometry.set_presentation_mode(middle, PresentationMode::Normal, Duration::ZERO), + Some(ClientSize::from_rect(middle)) + ); + geometry.advance(Duration::from_millis(64), SpringConfig::default(), true); + assert_eq!( + geometry.presentation_size_request(Duration::from_millis(64)), + None + ); + assert_eq!( + geometry.set_presentation_mode( + end, + PresentationMode::Normal, + Duration::from_millis(64) + ), + Some(ClientSize::from_rect(end)) + ); + let mut requests = Vec::new(); + for frame in 1..240 { + geometry.advance(Duration::from_millis(8), SpringConfig::default(), true); + if let Some(size) = + geometry.presentation_size_request(Duration::from_millis(64 + frame * 8)) + { + assert_eq!(geometry.visual.current.width, end.width); + requests.push(size); + } + } + assert!(requests.is_empty()); + } + + #[test] + fn pointer_resize_stays_live_and_reduced_motion_configures_immediately() { + let start = Rect::new(0.0, 0.0, 600.0, 800.0); + let end = Rect::new(0.0, 0.0, 900.0, 800.0); + let mut pointer = WindowGeometry::new(start, Some(ClientSize::from_rect(start))); + assert_eq!( + pointer.follow_pointer(end, Duration::ZERO), + Some(ClientSize::from_rect(end)) + ); + let mut reduced = WindowGeometry::new(start, Some(ClientSize::from_rect(start))); + assert_eq!( + reduced.set_presentation_target(end, true, Duration::ZERO), + Some(ClientSize::from_rect(end)) + ); + reduced.advance(Duration::ZERO, SpringConfig::default(), false); + assert_eq!(reduced.presentation_size_request(Duration::ZERO), None); + } + #[test] fn left_and_right_zoom_edges_do_not_reverse_direction() { let fullscreen = Rect::new(0.0, 0.0, 1920.0, 1080.0); diff --git a/crates/ferese-core/src/output.rs b/crates/ferese-core/src/output.rs index baa1cda..14b3ebf 100644 --- a/crates/ferese-core/src/output.rs +++ b/crates/ferese-core/src/output.rs @@ -271,7 +271,7 @@ impl OutputWorkspaceMap { return Ok(WorkspaceSwitch::FocusedExisting(owner)); } - self.assignments.insert(workspace, output); + let reassigned = self.assignments.insert(workspace, output) != Some(output); let state = self .outputs .get_mut(&output) @@ -279,7 +279,9 @@ impl OutputWorkspaceMap { state.workspaces.insert(workspace); state.active = workspace; self.focused = Some(output); - self.bump_revision(workspace); + if reassigned { + self.bump_revision(workspace); + } debug_assert!(self.validate()); Ok(WorkspaceSwitch::Activated(output)) @@ -482,7 +484,7 @@ mod tests { } #[test] - fn explicit_workspace_selection_prevents_automatic_return() { + fn using_an_evacuated_workspace_does_not_prevent_automatic_return() { let mut outputs = OutputWorkspaceMap::default(); outputs .connect(OutputId(1), geometry(0), WorkspaceId(1)) @@ -498,14 +500,63 @@ mod tests { assert_eq!( outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(3)), - Ok(WorkspaceId(3)) + Ok(WorkspaceId(2)) ); assert_eq!( outputs.output_for_workspace(WorkspaceId(2)), - Some(OutputId(1)) + Some(OutputId(2)) ); } + #[test] + fn repeated_lid_and_monitor_handoffs_restore_both_workspace_owners() { + let mut outputs = OutputWorkspaceMap::default(); + outputs + .connect(OutputId(1), geometry(0), WorkspaceId(1)) + .unwrap(); + outputs + .connect(OutputId(2), geometry(1920), WorkspaceId(2)) + .unwrap(); + for removed in [OutputId(1), OutputId(2), OutputId(1), OutputId(2)] { + let remaining = if removed == OutputId(1) { + OutputId(2) + } else { + OutputId(1) + }; + outputs.focus_output(removed).unwrap(); + outputs.disconnect(removed).unwrap(); + assert_eq!( + outputs.output_for_workspace(WorkspaceId(1)), + Some(remaining) + ); + assert_eq!( + outputs.output_for_workspace(WorkspaceId(2)), + Some(remaining) + ); + // Switching between handed-off workspaces is normal use, not an + // explicit change of their home output. + outputs.switch_workspace(remaining, WorkspaceId(1)).unwrap(); + outputs.switch_workspace(remaining, WorkspaceId(2)).unwrap(); + outputs + .connect( + removed, + geometry(if removed == OutputId(1) { 0 } else { 1920 }), + WorkspaceId(3), + ) + .unwrap(); + assert_eq!( + outputs.output_for_workspace(WorkspaceId(1)), + Some(OutputId(1)) + ); + assert_eq!( + outputs.output_for_workspace(WorkspaceId(2)), + Some(OutputId(2)) + ); + assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1))); + assert_eq!(outputs.active_workspace(OutputId(2)), Some(WorkspaceId(2))); + } + } + #[test] fn selecting_a_workspace_owned_by_another_output_focuses_that_output() { let mut outputs = OutputWorkspaceMap::default(); diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index 8aedd7f..30c570b 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -28,6 +28,7 @@ pub(crate) struct ShellSnapshot { #[derive(Clone, Debug)] pub(crate) struct OutputSnapshot { pub(crate) id: u64, + pub(crate) name: String, pub(crate) active_workspace: u64, pub(crate) focused: bool, } @@ -199,12 +200,13 @@ impl Dispatch for ControlState { ferese_shell_v1::Event::Output { output_hi, output_lo, - name: _, + name, active_workspace_hi, active_workspace_lo, focused, } => state.pending.outputs.push(OutputSnapshot { id: join_id(output_hi, output_lo), + name, active_workspace: join_id(active_workspace_hi, active_workspace_lo), focused: focused != 0, }), diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index f6ec45e..8a307f8 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -18,8 +18,8 @@ use cosmic::iced::alignment; use cosmic::iced::event::{self, PlatformSpecific, wayland}; use cosmic::iced::platform_specific::{ runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings}, - shell::commands::layer_surface::set_input_zone, shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer}, + shell::commands::layer_surface::{destroy_layer_surface, set_input_zone}, }; use cosmic::iced::{ Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, window, @@ -34,7 +34,7 @@ use jiff::Zoned; use wayland_client::{ Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop, globals::{GlobalListContents, registry_queue_init}, - protocol::{wl_registry, wl_surface}, + protocol::{wl_output, wl_registry, wl_surface}, }; use crate::config::{ShellConfig, ShellTheme, WallpaperMode}; @@ -102,7 +102,13 @@ fn main() -> cosmic::iced::Result { // toolkit threads are started. unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") }; } - let config = config::load(); + let mut config = config::load(); + let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some(); + if compositor_wallpaper { + // The compositor owns one GPU image, shared across output renderers; + // do not decode another copy or allocate full-screen software buffers. + config.wallpaper.path = None; + } let shell_font = configured_font(config.font_family.as_deref()); let _ = SHELL_FONT.set(shell_font); // Decode alongside toolkit/GPU initialization, never during a UI draw. @@ -141,13 +147,22 @@ struct FereseShell { snapshot: ShellSnapshot, overview_active: bool, clock: String, - effects: Option, + outputs: Vec, status_service: status::Service, status: status::Snapshot, status_error: Option, menu: Option, } +struct OutputSurfaces { + output: wl_output::WlOutput, + name: Option, + bar: window::Id, + wallpaper: Option, + effects: Option, + hidden: bool, +} + #[derive(Clone, Debug)] enum Message { WallpaperLoaded(Result), @@ -163,6 +178,7 @@ enum Message { StatusTick, AnimateMenu, OpenMenu(status_ui::Menu, cosmic::iced::Rectangle), + OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle), Control(status::Action), ConfirmPower(status::Action), CancelPower, @@ -185,9 +201,6 @@ impl cosmic::Application for FereseShell { fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task) { let bar_surface_id = window::Id::unique(); - let wallpaper_surface_id = window::Id::unique(); - let shell_theme = config.theme; - let bar = BarMetrics::from(shell_theme); let app = Self { core, bar_surface_id, @@ -205,58 +218,8 @@ impl cosmic::Application for FereseShell { snapshot: ShellSnapshot::default(), overview_active: false, clock: current_time(), - effects: None, + outputs: Vec::new(), }; - let wallpaper_action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| SctkLayerSurfaceSettings { - id: wallpaper_surface_id, - layer: Layer::Background, - keyboard_interactivity: KeyboardInteractivity::None, - input_zone: Some(Vec::new()), - anchor: Anchor::TOP | Anchor::RIGHT | Anchor::BOTTOM | Anchor::LEFT, - output: IcedOutput::Active, - namespace: "ferese-shell-wallpaper".to_owned(), - // Wallpaper covers the full output, including beneath the bar - // and its floating margins. Zero would use the remaining workspace. - exclusive_zone: -1, - size: Some((None, None)), - size_limits: Limits::NONE, - ..Default::default() - }, - Some(Box::new(Self::view_wallpaper)), - ); - let bar_action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| SctkLayerSurfaceSettings { - id: bar_surface_id, - layer: Layer::Top, - keyboard_interactivity: KeyboardInteractivity::None, - anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT, - output: IcedOutput::Active, - namespace: "ferese-shell-top-bar".to_owned(), - margin: IcedMargin { - top: shell_theme.bar_margin_top, - right: shell_theme.bar_margin_horizontal, - bottom: 0, - left: shell_theme.bar_margin_horizontal, - }, - size: Some((None, Some(bar.height.round() as u32))), - size_limits: Limits::NONE, - // Reserve breathing room below the visible bar; layer-shell - // accounts for the top margin separately. - exclusive_zone: (bar.height.round() as i32) - .saturating_add(shell_theme.bar_window_gap), - ..Default::default() - }, - Some(Box::new(Self::view_layer)), - ); - - // Submit the small interactive surface before the full-screen image. - let tasks = [bar_action, wallpaper_action] - .map(cosmic::Action::Surface) - .map(cosmic::task::message); - let wallpaper_task = match wallpaper { Some(receiver) => cosmic::task::future(async move { cosmic::Action::App(Message::WallpaperLoaded( @@ -267,7 +230,7 @@ impl cosmic::Application for FereseShell { }), None => Task::none(), }; - (app, Task::batch([Task::batch(tasks), wallpaper_task])) + (app, wallpaper_task) } fn subscription(&self) -> Subscription { @@ -281,7 +244,9 @@ impl cosmic::Application for FereseShell { Event::Keyboard(_) | Event::Window(window::Event::Opened { .. } | window::Event::Closed) | Event::PlatformSpecific(PlatformSpecific::Wayland( - wayland::Event::Popup(..) | wayland::Event::Layer(..), + wayland::Event::Popup(..) + | wayland::Event::Layer(..) + | wayland::Event::Output(..), )) => Some(Message::Event(event, id)), _ => None, }), @@ -314,8 +279,10 @@ impl cosmic::Application for FereseShell { } Message::NativeSurface(id, result) => { match result { - Ok((_connection, surface)) if id == self.bar_surface_id => { - self.attach_effects(&surface) + Ok((_connection, surface)) + if self.outputs.iter().any(|entry| entry.bar == id) => + { + self.attach_effects(id, &surface) } Ok((_connection, surface)) => { if let Some(menu) = &mut self.menu @@ -345,6 +312,16 @@ impl cosmic::Application for FereseShell { } Message::AnimateMenu => Task::none(), Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor), + Message::OpenMenuOn(id, kind, anchor) => { + if self.bar_surface_id != id { + let destroy = self.destroy_menu(); + self.bar_surface_id = id; + let open = self.open_menu(kind, anchor); + Task::batch([destroy, open]) + } else { + self.open_menu(kind, anchor) + } + } Message::ConfirmPower(action) => { if let Some(menu) = &mut self.menu { menu.confirm = Some(action); @@ -383,25 +360,36 @@ impl cosmic::Application for FereseShell { self.overview_active = active; } if let Some(snapshot) = poll.snapshot { - let was_hidden = bar_hidden(&self.snapshot); - self.snapshot = snapshot; - - let hidden = bar_hidden(&self.snapshot); - if hidden != was_hidden { - if let Some(effects) = &self.effects - && let Err(error) = effects.set_visible(!hidden) - { - eprintln!("ferese-shell: could not update panel material: {error}"); + let mut tasks = Vec::new(); + for entry in &mut self.outputs { + let output = self + .snapshot + .outputs + .iter() + .find(|output| Some(output.name.as_str()) == entry.name.as_deref()) + .map(|output| output.id); + let hidden = output_bar_hidden(&self.snapshot, output); + if entry.hidden == hidden { + continue; } - - let input_zone = if hidden { Some(Vec::new()) } else { None }; - - return Task::batch([ - set_input_zone(self.bar_surface_id, input_zone), - self.destroy_menu(), - ]); + entry.hidden = hidden; + if let Some(effects) = &entry.effects { + if let Err(error) = effects.set_visible(!hidden) { + eprintln!( + "ferese-shell: could not update panel material: {error}" + ); + } + } + tasks.push(set_input_zone( + entry.bar, + if hidden { Some(Vec::new()) } else { None }, + )); } + if self.bar_hidden(self.bar_surface_id) { + tasks.push(self.destroy_menu()); + } + return Task::batch(tasks); } } Task::none() @@ -444,10 +432,149 @@ impl cosmic::Application for FereseShell { } impl FereseShell { + fn output_event( + &mut self, + event: wayland::OutputEvent, + output: wl_output::WlOutput, + ) -> Task { + if matches!(event, wayland::OutputEvent::Removed) { + if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) { + let entry = self.outputs.remove(index); + let menu = if self.bar_surface_id == entry.bar { + self.destroy_menu() + } else { + Task::none() + }; + let mut tasks = vec![destroy_layer_surface(entry.bar), menu]; + if let Some(wallpaper) = entry.wallpaper { + tasks.push(destroy_layer_surface(wallpaper)); + } + return Task::batch(tasks); + } + return Task::none(); + } + let name = match event { + wayland::OutputEvent::Created(info) => info.and_then(|info| info.name), + wayland::OutputEvent::InfoUpdate(info) => info.name, + _ => None, + }; + if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.output == output) { + if name.is_some() { + entry.name = name; + } + return Task::none(); + } + let bar_surface_id = window::Id::unique(); + let wallpaper_surface_id = window::Id::unique(); + let shell_theme = self.config.theme; + let bar = BarMetrics::from(shell_theme); + let wallpaper_output = output.clone(); + let bar_output = output.clone(); + let hidden = output_bar_hidden( + &self.snapshot, + self.snapshot + .outputs + .iter() + .find(|output| Some(output.name.as_str()) == name.as_deref()) + .map(|output| output.id), + ); + self.outputs.push(OutputSurfaces { + output, + name, + bar: bar_surface_id, + wallpaper: std::env::var_os("FERESE_COMPOSITOR_WALLPAPER") + .is_none() + .then_some(wallpaper_surface_id), + effects: None, + hidden, + }); + let wallpaper_action = cosmic::surface::action::app_layer_shell::( + |_| Default::default(), + move |_| SctkLayerSurfaceSettings { + id: wallpaper_surface_id, + layer: Layer::Background, + keyboard_interactivity: KeyboardInteractivity::None, + input_zone: Some(Vec::new()), + anchor: Anchor::TOP | Anchor::RIGHT | Anchor::BOTTOM | Anchor::LEFT, + output: IcedOutput::Output(wallpaper_output.clone()), + namespace: "ferese-shell-wallpaper".to_owned(), + // Wallpaper covers the full output, including beneath the bar + // and its floating margins. Zero would use the remaining workspace. + exclusive_zone: -1, + size: Some((None, None)), + size_limits: Limits::NONE, + ..Default::default() + }, + Some(Box::new(Self::view_wallpaper)), + ); + let bar_action = cosmic::surface::action::app_layer_shell::( + |_| Default::default(), + move |_| SctkLayerSurfaceSettings { + id: bar_surface_id, + input_zone: hidden.then(Vec::new), + layer: Layer::Top, + keyboard_interactivity: KeyboardInteractivity::None, + anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT, + output: IcedOutput::Output(bar_output.clone()), + namespace: "ferese-shell-top-bar".to_owned(), + margin: IcedMargin { + top: shell_theme.bar_margin_top, + right: shell_theme.bar_margin_horizontal, + bottom: 0, + left: shell_theme.bar_margin_horizontal, + }, + size: Some((None, Some(bar.height.round() as u32))), + size_limits: Limits::NONE, + // Reserve breathing room below the visible bar; layer-shell + // accounts for the top margin separately. + exclusive_zone: (bar.height.round() as i32) + .saturating_add(shell_theme.bar_window_gap), + ..Default::default() + }, + Some(Box::new(move |app| app.view_layer(bar_surface_id))), + ); + + // Submit the small interactive surface before the full-screen image. + let mut surfaces = vec![bar_action]; + if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_none() { + surfaces.push(wallpaper_action); + } + let tasks = surfaces + .into_iter() + .map(cosmic::Action::Surface) + .map(cosmic::task::message); + + Task::batch(tasks) + } + + fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> { + let name = self + .outputs + .iter() + .find(|entry| entry.bar == id)? + .name + .as_deref()?; + self.snapshot + .outputs + .iter() + .find(|output| output.name == name) + } + + fn bar_hidden(&self, id: window::Id) -> bool { + output_bar_hidden( + &self.snapshot, + self.output_for_bar(id).map(|output| output.id), + ) + } + fn handle_event(&mut self, event: Event, id: window::Id) -> Task { match event { + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output( + event, + output, + ))) => self.output_event(event, output), Event::Window(window::Event::Opened { .. }) - if id == self.bar_surface_id + if self.outputs.iter().any(|entry| entry.bar == id) || self.menu.as_ref().is_some_and(|menu| menu.id == id) => { window::run(id, native_wayland_surface) @@ -510,9 +637,7 @@ impl FereseShell { EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); Task::none() } - Event::Window(window::Event::Closed) if id == self.bar_surface_id => { - cosmic::iced::exit() - } + Event::Window(window::Event::Closed) => Task::none(), Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame( _, surface, @@ -535,49 +660,48 @@ impl FereseShell { _, surface, frame_id, - ))) if frame_id == self.bar_surface_id => { - self.attach_effects(&surface); + ))) if self.outputs.iter().any(|entry| entry.bar == frame_id) => { + self.attach_effects(frame_id, &surface); Task::none() } Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer( _, surface, layer_id, - ))) if layer_id == self.bar_surface_id => { - self.attach_effects(&surface); + ))) if self.outputs.iter().any(|entry| entry.bar == layer_id) => { + self.attach_effects(layer_id, &surface); Task::none() } _ => Task::none(), } } - fn attach_effects(&mut self, surface: &wl_surface::WlSurface) { - if self.effects.is_some() { + fn attach_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) { + let hidden = self.bar_hidden(id); + let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id) else { + return; + }; + if entry.effects.is_some() { return; } EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); - match EffectsBinding::attach(surface, !bar_hidden(&self.snapshot)) { - Ok(binding) => self.effects = Some(binding), + match EffectsBinding::attach(surface, !hidden) { + Ok(binding) => entry.effects = Some(binding), Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"), } } - fn view_layer(&self) -> Element<'_, cosmic::Action> { - if bar_hidden(&self.snapshot) { + fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action> { + if self.bar_hidden(id) { return container(text("")) .width(Length::Fill) .height(Length::Fill) .into(); } - let focused_output = self - .snapshot - .outputs - .iter() - .find(|output| output.focused) - .or_else(|| self.snapshot.outputs.first()); + let focused_output = self.output_for_bar(id); let focused_output_id = focused_output.map(|output| output.id); let shell_theme = self.config.theme.for_bar(); let bar = BarMetrics::from(shell_theme); @@ -670,9 +794,16 @@ impl FereseShell { .class(theme::Text::Color(foreground)), ) .padding([0, 6]); - let right = row![self.view_status_bar(), clock] - .spacing(6) - .align_y(cosmic::iced::Alignment::Center); + let right = row![ + self.view_status_bar().map(move |action| match action { + cosmic::Action::App(Message::OpenMenu(kind, anchor)) => + cosmic::Action::App(Message::OpenMenuOn(id, kind, anchor)), + other => other, + }), + clock + ] + .spacing(6) + .align_y(cosmic::iced::Alignment::Center); let content = row![ container(left).width(Length::Fill), container(right).width(Length::Shrink), @@ -681,7 +812,10 @@ impl FereseShell { .align_y(cosmic::iced::Alignment::Center) .height(Length::Fill); - let compositor_material = self.effects.is_some(); + let compositor_material = self + .outputs + .iter() + .any(|entry| entry.bar == id && entry.effects.is_some()); container(content) .width(Length::Fill) .height(Length::Fill) @@ -777,12 +911,22 @@ fn overview_control( .into() } +#[cfg(test)] fn bar_hidden(snapshot: &ShellSnapshot) -> bool { - let active_workspace = snapshot + let output = snapshot .outputs .iter() .find(|output| output.focused) .or_else(|| snapshot.outputs.first()) + .map(|output| output.id); + output_bar_hidden(snapshot, output) +} + +fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option) -> bool { + let active_workspace = snapshot + .outputs + .iter() + .find(|candidate| Some(candidate.id) == output) .map(|output| output.active_workspace); active_workspace.is_some_and(|workspace| { @@ -1250,10 +1394,29 @@ mod tests { assert!(!bar_hidden(&snapshot)); } + #[test] + fn fullscreen_visibility_is_local_to_each_monitor() { + let mut snapshot = snapshot_with_fullscreen_window(7); + snapshot.outputs.push(OutputSnapshot { + id: 2, + name: "external-test".to_owned(), + active_workspace: 9, + focused: false, + }); + assert!(output_bar_hidden(&snapshot, Some(1))); + assert!(!output_bar_hidden(&snapshot, Some(2))); + assert!(!output_bar_hidden(&snapshot, None)); + snapshot.outputs[0].focused = false; + snapshot.outputs[1].focused = true; + assert!(output_bar_hidden(&snapshot, Some(1))); + assert!(!output_bar_hidden(&snapshot, Some(2))); + } + fn snapshot_with_fullscreen_window(workspace: u64) -> ShellSnapshot { ShellSnapshot { outputs: vec![OutputSnapshot { id: 1, + name: "eDP-1".to_owned(), active_workspace: 7, focused: true, }], diff --git a/tools/ferese-effects-probe/src/main.rs b/tools/ferese-effects-probe/src/main.rs index 5f93bca..4dde1ae 100644 --- a/tools/ferese-effects-probe/src/main.rs +++ b/tools/ferese-effects-probe/src/main.rs @@ -9,7 +9,9 @@ use ferese_protocols::effects::v1::client::{ }; use wayland_client::{ Connection, Dispatch, Proxy, QueueHandle, delegate_noop, - protocol::{wl_buffer, wl_compositor, wl_registry, wl_shm, wl_shm_pool, wl_surface}, + protocol::{ + wl_buffer, wl_callback, wl_compositor, wl_registry, wl_shm, wl_shm_pool, wl_surface, + }, }; use wayland_protocols_wlr::layer_shell::v1::client::{ zwlr_layer_shell_v1::{self, ZwlrLayerShellV1}, @@ -25,12 +27,16 @@ fn main() -> Result<(), Box> { .iter() .any(|argument| argument == "--expect-hidden"); let preview = arguments.iter().any(|argument| argument == "--preview"); + let callbacks_only = arguments + .iter() + .any(|argument| argument == "--frame-callbacks"); let connection = Connection::connect_to_env()?; let mut queue = connection.new_event_queue(); let qh = queue.handle(); connection.display().get_registry(&qh, ()); let mut state = ProbeState { preview, + callbacks_only, ..ProbeState::default() }; @@ -43,7 +49,7 @@ fn main() -> Result<(), Box> { return Ok(()); } - while state.effects.is_none() || !state.configured { + while (!callbacks_only && state.effects.is_none()) || !state.configured { queue.blocking_dispatch(&mut state)?; state.try_initialize(&qh)?; if state.closed { @@ -51,6 +57,25 @@ fn main() -> Result<(), Box> { } } + if callbacks_only { + // Request frames without attaching a new buffer or damaging content. + // A compositor which ties callbacks to damage deadlocks this sequence. + for expected in 1..=8 { + state.surface.as_ref().unwrap().frame(&qh, ()); + state.surface.as_ref().unwrap().commit(); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while state.frame_count < expected { + queue.roundtrip(&mut state)?; + if std::time::Instant::now() >= deadline { + return Err("frame callback stalled on an unchanged layer buffer".into()); + } + std::thread::sleep(Duration::from_millis(2)); + } + } + println!("PASS eight frame callbacks delivered for an unchanged layer buffer"); + return Ok(()); + } + let effects = state.effects.clone().expect("effects initialized"); let initial_configures = state.configure_count; for role in [ @@ -130,6 +155,8 @@ struct ProbeState { preview_background_layer: Option, preview_background_buffer: Option, preview: bool, + callbacks_only: bool, + frame_count: u32, configured: bool, closed: bool, configure_count: u32, @@ -142,14 +169,16 @@ impl ProbeState { if self.surface.is_some() { return Ok(()); } - let (Some(compositor), Some(_shm), Some(layer_shell), Some(effects_manager)) = ( + let (Some(compositor), Some(_shm), Some(layer_shell)) = ( self.compositor.as_ref(), self.shm.as_ref(), self.layer_shell.as_ref(), - self.effects_manager.as_ref(), ) else { return Ok(()); }; + if !self.callbacks_only && self.effects_manager.is_none() { + return Ok(()); + } let surface = compositor.create_surface(qh, ()); let layer = if self.preview { @@ -182,8 +211,15 @@ impl ProbeState { layer_surface.set_exclusive_zone(HEIGHT as i32); } - let effects = effects_manager.get_surface_effects(&surface, qh, ()); - effects.set_role(ferese_surface_effects_v1::Role::Panel); + let effects = (!self.callbacks_only).then(|| { + let effects = + self.effects_manager + .as_ref() + .unwrap() + .get_surface_effects(&surface, qh, ()); + effects.set_role(ferese_surface_effects_v1::Role::Panel); + effects + }); surface.commit(); @@ -211,7 +247,7 @@ impl ProbeState { self.surface = Some(surface); self.layer_surface = Some(layer_surface); - self.effects = Some(effects); + self.effects = effects; Ok(()) } } @@ -380,6 +416,21 @@ impl Dispatch for ProbeState { } } +impl Dispatch for ProbeState { + fn event( + state: &mut Self, + _: &wl_callback::WlCallback, + event: wl_callback::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if matches!(event, wl_callback::Event::Done { .. }) { + state.frame_count += 1; + } + } +} + delegate_noop!(ProbeState: ignore wl_compositor::WlCompositor); delegate_noop!(ProbeState: ignore wl_shm::WlShm); delegate_noop!(ProbeState: ignore wl_shm_pool::WlShmPool);