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.
This commit is contained in:
Samuel Onoja committed 2026-09-26 14:50:14 +01:00
1 parent 48ac963ffe
commit 0709484efb
20 files changed
+1976 -202

No files matched your search

Generated
+2
View File
@@ -1576,6 +1576,7 @@ dependencies = [
"ferese-ipc", "ferese-ipc",
"ferese-layout", "ferese-layout",
"ferese-protocols", "ferese-protocols",
"image",
"libc", "libc",
"serde", "serde",
"serde_json", "serde_json",
@@ -2674,6 +2675,7 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder-lite", "byteorder-lite",
"image-webp",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png 0.18.1", "png 0.18.1",
+23 -1
View File
@@ -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 `[theme.geometry]` adds optional extra space (default 0). Solid and translucent
shell surfaces also omit outlines. The dark startup fallback matches the material palette. 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 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 rendering for shell content. Use release builds when evaluating startup and
animation performance; debug image processing is considerably slower. 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: Keyboard and direct-session touchpad settings are applied at startup:
```toml ```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 their rotated flipped variants. Output configuration is applied at startup
and on hotplug; live configuration reload remains deferred. 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 Commands are argv arrays and are started directly, without a shell. The
default `Super+Enter` binding runs the built-in `terminal = ["foot"]` command: default `Super+Enter` binding runs the built-in `terminal = ["foot"]` command:
+1
View File
@@ -13,6 +13,7 @@ ferese-ipc = { path = "../crates/ferese-ipc" }
ferese-layout = { path = "../crates/ferese-layout" } ferese-layout = { path = "../crates/ferese-layout" }
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] } ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
libc.workspace = true libc.workspace = true
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
smithay.workspace = true smithay.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
+197 -6
View File
@@ -12,7 +12,9 @@ use smithay::{
Fourcc, Fourcc,
gbm::{GbmAllocator, GbmBufferFlags, GbmDevice}, gbm::{GbmAllocator, GbmBufferFlags, GbmDevice},
}, },
drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, GbmBufferedSurface}, drm::{
DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, GbmBufferedSurface, NodeType,
},
egl::{EGLContext, EGLDisplay}, egl::{EGLContext, EGLDisplay},
input::InputEvent, input::InputEvent,
libinput::{LibinputInputBackend, LibinputSessionInterface}, libinput::{LibinputInputBackend, LibinputSessionInterface},
@@ -36,7 +38,7 @@ use smithay::{
wayland_server::backend::GlobalId, wayland_server::backend::GlobalId,
}, },
utils::{DeviceFd, Monotonic, Time, Transform}, utils::{DeviceFd, Monotonic, Time, Transform},
wayland::presentation::Refresh, wayland::{dmabuf::DmabufFeedbackBuilder, presentation::Refresh},
}; };
use crate::{ use crate::{
@@ -51,11 +53,39 @@ use crate::{
pub struct DirectBackendState { pub struct DirectBackendState {
pub session: LibSeatSession, pub session: LibSeatSession,
pub active: bool, pub active: bool,
lid_closed: bool,
devices: HashMap<DrmNode, DirectDevice>, devices: HashMap<DrmNode, DirectDevice>,
presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>, presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>,
pub(crate) connected_outputs: Vec<ConnectedOutputInfo>, pub(crate) connected_outputs: Vec<ConnectedOutputInfo>,
} }
impl DirectBackendState {
pub(crate) fn capture_resize_snapshot(
&mut self,
window: &smithay::desktop::Window,
geometry: smithay::utils::Rectangle<i32, smithay::utils::Logical>,
output: &Output,
remaining: usize,
) -> Result<Option<crate::winit::ResizeSnapshot>, 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)] #[derive(Clone, Debug)]
pub(crate) struct ConnectedOutputInfo { pub(crate) struct ConnectedOutputInfo {
pub connector: String, pub connector: String,
@@ -94,6 +124,7 @@ struct DirectDevice {
} }
struct DirectOutput { struct DirectOutput {
internal: bool,
connector: connector::Handle, connector: connector::Handle,
mode: DrmMode, mode: DrmMode,
settings: OutputSettings, settings: OutputSettings,
@@ -143,6 +174,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
state.direct_backend = Some(DirectBackendState { state.direct_backend = Some(DirectBackendState {
session, session,
active: session_active, active: session_active,
lid_closed: initial_lid_closed(),
devices: HashMap::new(), devices: HashMap::new(),
presentation: HashMap::new(), presentation: HashMap::new(),
connected_outputs: Vec::new(), connected_outputs: Vec::new(),
@@ -279,10 +311,25 @@ fn open_primary_device(
state.shm_state.update_formats(renderer.shm_formats()); state.shm_state.update_formats(renderer.shm_formats());
let dmabuf_formats = renderer.dmabuf_formats(); let dmabuf_formats = renderer.dmabuf_formats();
let display_handle = state.display_handle.clone(); 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 state
.dmabuf_state .dmabuf_state
.create_global::<Ferese>(&display_handle, dmabuf_formats); .create_global_with_default_feedback::<Ferese>(&display_handle, &feedback);
let scan = select_outputs(&drm, &state.output_profiles)?; 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() { if scan.selections.is_empty() {
return Err(io::Error::new( return Err(io::Error::new(
io::ErrorKind::NotFound, 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::<Vec<_>>();
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::<HashSet<_>>();
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) { fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
let missed_deadlines = state let missed_deadlines = state
.direct_backend .direct_backend
@@ -570,7 +679,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
return; 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, Ok(scan) => scan,
Err(error) => { Err(error) => {
tracing::error!(?node, %error, "failed to scan DRM connectors"); tracing::error!(?node, %error, "failed to scan DRM connectors");
@@ -578,6 +687,11 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
return; 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() { if let Some(backend) = state.direct_backend.as_mut() {
backend.connected_outputs = scan.connected_outputs; backend.connected_outputs = scan.connected_outputs;
} }
@@ -587,6 +701,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
.map(|selection| (selection.crtc, selection)) .map(|selection| (selection.crtc, selection))
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
let existing = device.outputs.keys().copied().collect::<Vec<_>>(); let existing = device.outputs.keys().copied().collect::<Vec<_>>();
let mut deferred_removals = Vec::new();
for crtc in existing { for crtc in existing {
let unchanged = device.outputs.get(&crtc).is_some_and(|output| { let unchanged = device.outputs.get(&crtc).is_some_and(|output| {
@@ -601,6 +716,14 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
continue; 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) { if let Some(output) = device.outputs.remove(&crtc) {
state.display_handle.disable_global::<Ferese>(output.global); state.display_handle.disable_global::<Ferese>(output.global);
state.unregister_output(&output.output); 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::<Ferese>(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); restore_device(state, node, device);
state.restore_output_focus();
state.relayout(); state.relayout();
render_all(state); render_all(state);
} }
@@ -657,6 +821,11 @@ fn remove_device(state: &mut Ferese, node: DrmNode) {
return; 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 { for (crtc, output) in device.outputs {
state.display_handle.disable_global::<Ferese>(output.global); state.display_handle.disable_global::<Ferese>(output.global);
state.unregister_output(&output.output); state.unregister_output(&output.output);
@@ -901,7 +1070,6 @@ fn create_direct_output(
settings: &OutputSettings, settings: &OutputSettings,
) -> Result<DirectOutput, Box<dyn Error>> { ) -> Result<DirectOutput, Box<dyn Error>> {
let identity = connector_identity(drm, &connector); 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 drm_surface = drm.create_surface(crtc, mode, &[connector.handle()])?;
let allocator = GbmAllocator::new( let allocator = GbmAllocator::new(
gbm.clone(), gbm.clone(),
@@ -913,11 +1081,15 @@ fn create_direct_output(
&[Fourcc::Argb8888, Fourcc::Abgr8888], &[Fourcc::Argb8888, Fourcc::Abgr8888],
renderer.dmabuf_formats(), 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 damage_tracker = OutputDamageTracker::from_output(&output);
let render_metrics = RenderMetrics::from_environment(output.name()); let render_metrics = RenderMetrics::from_environment(output.name());
tracing::info!(?crtc, connector = %connector, "initialized DRM output"); tracing::info!(?crtc, connector = %connector, "initialized DRM output");
Ok(DirectOutput { Ok(DirectOutput {
internal: internal_connector(connector.interface()),
connector: connector.handle(), connector: connector.handle(),
mode, mode,
settings: settings.clone(), settings: settings.clone(),
@@ -1090,6 +1262,25 @@ mod tests {
use super::*; 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] #[test]
fn connector_identity_hash_is_stable() { fn connector_identity_hash_is_stable() {
assert_eq!(stable_hash(b"hello"), 0xa430_d846_80aa_bd0b); assert_eq!(stable_hash(b"hello"), 0xa430_d846_80aa_bd0b);
+8
View File
@@ -167,6 +167,8 @@ impl Default for InactiveDimConfig {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct ThemeConfig { struct ThemeConfig {
#[serde(default)]
background: crate::wallpaper::WallpaperConfig,
#[serde(default)] #[serde(default)]
surface: ThemeSurfaceConfig, surface: ThemeSurfaceConfig,
#[serde(default)] #[serde(default)]
@@ -179,6 +181,12 @@ struct ThemeConfig {
material: ThemeMaterialConfig, material: ThemeMaterialConfig,
} }
impl Config {
pub(crate) fn wallpaper_settings(&self) -> crate::wallpaper::WallpaperConfig {
self.theme.background.clone()
}
}
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct ThemeSurfaceConfig { struct ThemeSurfaceConfig {
#[serde(default)] #[serde(default)]
+40
View File
@@ -18,6 +18,30 @@ use smithay::{
use super::{layer_shell, xdg_shell}; use super::{layer_shell, xdg_shell};
use crate::{Ferese, state::ClientState}; 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 { impl CompositorHandler for Ferese {
fn compositor_state(&mut self) -> &mut CompositorState { fn compositor_state(&mut self) -> &mut CompositorState {
&mut self.compositor_state &mut self.compositor_state
@@ -31,7 +55,10 @@ impl CompositorHandler for Ferese {
} }
fn commit(&mut self, surface: &WlSurface) { fn commit(&mut self, surface: &WlSurface) {
self.capture_resize_before_commit(surface);
if self.surface_affects_backdrop(surface) {
self.backdrop_generation = self.backdrop_generation.wrapping_add(1); self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
}
on_commit_buffer_handler::<Self>(surface); on_commit_buffer_handler::<Self>(surface);
if !is_sync_subsurface(surface) { if !is_sync_subsurface(surface) {
let mut root = surface.clone(); 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 { impl BufferHandler for Ferese {
fn buffer_destroyed(&mut self, _buffer: &wl_buffer::WlBuffer) {} fn buffer_destroyed(&mut self, _buffer: &wl_buffer::WlBuffer) {}
} }
+14 -4
View File
@@ -4,7 +4,7 @@ use smithay::{
backend::input::{ backend::input::{
AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, InputBackend, InputEvent, AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, InputBackend, InputEvent,
KeyState, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, KeyState, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent,
TouchEvent, Switch, SwitchState, SwitchToggleEvent, TouchEvent,
}, },
input::{ input::{
keyboard::{FilterResult, keysyms}, keyboard::{FilterResult, keysyms},
@@ -27,6 +27,9 @@ impl Ferese {
self.idle_notifier_state.notify_activity(&seat); self.idle_notifier_state.notify_activity(&seat);
match event { 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, .. } => { InputEvent::Keyboard { event, .. } => {
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard"); let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
let keycode = event.key_code(); let keycode = event.key_code();
@@ -339,12 +342,14 @@ impl Ferese {
} }
if let Some(window) = self.window_under_visual(position) { if let Some(window) = self.window_under_visual(position) {
self.focused_window = self.window_ids.get(&window).copied(); let focused = self.window_ids.get(&window).copied();
if let Some(focused) = self.focused_window if let Some(focused) = focused
&& let Err(error) = self.workspaces.focus_window(focused) && let Err(error) = self.workspaces.focus_window(focused)
{ {
tracing::error!(%error, ?focused, "failed to update workspace focus"); tracing::error!(%error, ?focused, "failed to update workspace focus");
return;
} }
self.focused_window = focused;
self.raise_window(&window, true); self.raise_window(&window, true);
let surface = window let surface = window
.toplevel() .toplevel()
@@ -481,7 +486,12 @@ impl Ferese {
BindingAction::Spawn(mut argv) => { BindingAction::Spawn(mut argv) => {
let program = argv.remove(0); 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) => { Ok(child) => {
tracing::info!(%program, pid = child.id(), "spawned binding command") tracing::info!(%program, pid = child.id(), "spawned binding command")
} }
+10 -4
View File
@@ -11,10 +11,13 @@ mod input;
mod ipc; mod ipc;
mod metrics; mod metrics;
mod overview; mod overview;
mod presentation;
mod private_client; mod private_client;
mod resize_transaction;
mod shell_control; mod shell_control;
mod stacking; mod stacking;
mod state; mod state;
mod wallpaper;
mod window_rules; mod window_rules;
mod winit; mod winit;
@@ -68,14 +71,11 @@ fn main() -> Result<(), Box<dyn Error>> {
spring_config: config.spring_config()?, spring_config: config.spring_config()?,
viewport_spring_config: config.viewport_spring_config()?, viewport_spring_config: config.viewport_spring_config()?,
output_profiles, output_profiles,
wallpaper: config.wallpaper_settings(),
}; };
let mut state = Ferese::new(&mut event_loop, display, runtime)?; let mut state = Ferese::new(&mut event_loop, display, runtime)?;
backends::init(launch.backend, &mut event_loop, &mut state)?; 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"); 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 mut child = spawn_client(&mut state, launch.client, launch.client_capabilities);
let result = event_loop.run(None, &mut state, |state| { let result = event_loop.run(None, &mut state, |state| {
@@ -118,6 +118,12 @@ fn spawn_client(
let mut command = Command::new(&program); let mut command = Command::new(&program);
command.args(args); 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() { let private_connection = if capabilities.is_empty() {
None None
} else { } else {
+30
View File
@@ -16,6 +16,7 @@ pub(crate) struct RenderMetrics {
damaged_pixels: u64, damaged_pixels: u64,
total_render_time: Duration, total_render_time: Duration,
longest_render_time: Duration, longest_render_time: Duration,
frame_times: Vec<Duration>,
} }
impl RenderMetrics { impl RenderMetrics {
@@ -36,6 +37,7 @@ impl RenderMetrics {
damaged_pixels: 0, damaged_pixels: 0,
total_render_time: Duration::ZERO, total_render_time: Duration::ZERO,
longest_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::<u64>(); self.damaged_pixels += damage.iter().map(rectangle_area).sum::<u64>();
self.total_render_time += render_time; self.total_render_time += render_time;
self.longest_render_time = self.longest_render_time.max(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 now = Instant::now();
let elapsed = now.saturating_duration_since(self.interval_started); let elapsed = now.saturating_duration_since(self.interval_started);
@@ -66,6 +72,17 @@ impl RenderMetrics {
.as_micros() .as_micros()
.checked_div(u128::from(self.rendered_frames)) .checked_div(u128::from(self.rendered_frames))
.unwrap_or(0); .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!( tracing::info!(
target: "ferese::render", target: "ferese::render",
output = %self.output, output = %self.output,
@@ -74,6 +91,8 @@ impl RenderMetrics {
damaged_pixels = self.damaged_pixels, damaged_pixels = self.damaged_pixels,
average_render_us, average_render_us,
longest_render_us = self.longest_render_time.as_micros(), longest_render_us = self.longest_render_time.as_micros(),
p95_render_us = percentile(95),
p99_render_us = percentile(99),
missed_deadlines, missed_deadlines,
"render performance" "render performance"
); );
@@ -83,6 +102,7 @@ impl RenderMetrics {
self.damaged_pixels = 0; self.damaged_pixels = 0;
self.total_render_time = Duration::ZERO; self.total_render_time = Duration::ZERO;
self.longest_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.damaged_pixels, 5_200);
assert_eq!(metrics.total_render_time, Duration::from_millis(2)); assert_eq!(metrics.total_render_time, Duration::from_millis(2));
assert_eq!(metrics.longest_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);
} }
} }
+3
View File
@@ -209,6 +209,9 @@ impl Ferese {
y: f64, y: f64,
) -> Option<(f64, f64)> { ) -> Option<(f64, f64)> {
let geometry = self.window_geometry.get(&id)?; 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 source = geometry.client.committed_size?;
let presented = self.overview.presented_rect(id, geometry.visual.current); let presented = self.overview.presented_rect(id, geometry.visual.current);
+309
View File
@@ -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<i32, Logical>,
scale: f64,
) -> Rectangle<i32, Physical> {
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<i32, Buffer>,
source: smithay::utils::Size<i32, Physical>,
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<Duration>,
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<i32, Physical>,
}
impl Element for PhysicalShaderElement {
fn id(&self) -> &Id {
self.inner.id()
}
fn current_commit(&self) -> CommitCounter {
self.inner.current_commit()
}
fn src(&self) -> Rectangle<f64, Buffer> {
self.inner.src()
}
fn geometry(&self, _: Scale<f64>) -> Rectangle<i32, Physical> {
self.geometry
}
fn damage_since(
&self,
_: Scale<f64>,
commit: Option<CommitCounter>,
) -> DamageSet<i32, Physical> {
if commit == Some(self.current_commit()) {
DamageSet::default()
} else {
DamageSet::from_slice(&[Rectangle::from_size(self.geometry.size)])
}
}
}
impl RenderElement<GlesRenderer> for PhysicalShaderElement {
fn draw(
&self,
frame: &mut GlesFrame<'_, '_>,
src: Rectangle<f64, Buffer>,
dst: Rectangle<i32, Physical>,
damage: &[Rectangle<i32, Physical>],
opaque: &[Rectangle<i32, Physical>],
) -> 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<i32, Physical>,
pub source: Rectangle<f64, Buffer>,
pub alpha: f32,
pub program: Option<GlesTexProgram>,
pub uniforms: Vec<Uniform<'static>>,
}
impl Element for NativeTextureElement {
fn id(&self) -> &Id {
&self.id
}
fn current_commit(&self) -> CommitCounter {
self.commit
}
fn src(&self) -> Rectangle<f64, Buffer> {
self.source
}
fn geometry(&self, _: Scale<f64>) -> Rectangle<i32, Physical> {
self.geometry
}
fn alpha(&self) -> f32 {
self.alpha
}
fn kind(&self) -> Kind {
Kind::Unspecified
}
fn damage_since(
&self,
_: Scale<f64>,
commit: Option<CommitCounter>,
) -> DamageSet<i32, Physical> {
if commit == Some(self.commit) {
DamageSet::default()
} else {
DamageSet::from_slice(&[Rectangle::from_size(self.geometry.size)])
}
}
}
impl RenderElement<GlesRenderer> for NativeTextureElement {
fn draw(
&self,
frame: &mut GlesFrame<'_, '_>,
src: Rectangle<f64, Buffer>,
dst: Rectangle<i32, Physical>,
damage: &[Rectangle<i32, Physical>],
opaque: &[Rectangle<i32, Physical>],
) -> 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);
}
}
+78
View File
@@ -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<Rectangle<i32, Logical>>,
}
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<i32, Logical>) -> Self {
self.source_geometry = Some(geometry);
self
}
pub(crate) fn source_geometry(self) -> Option<Rectangle<i32, Logical>> {
self.source_geometry
}
pub(crate) fn accepts(self, committed: Option<Serial>) -> 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));
}
}
+257 -10
View File
@@ -64,7 +64,7 @@ use smithay::{
shell::wlr_layer::Layer, shell::wlr_layer::Layer,
shell::wlr_layer::WlrLayerShellState, shell::wlr_layer::WlrLayerShellState,
shell::xdg::decoration::XdgDecorationState, shell::xdg::decoration::XdgDecorationState,
shell::xdg::{SurfaceCachedState, XdgShellState}, shell::xdg::{SurfaceCachedState, XdgShellState, XdgToplevelSurfaceData},
shm::ShmState, shm::ShmState,
single_pixel_buffer::SinglePixelBufferState, single_pixel_buffer::SinglePixelBufferState,
socket::ListeningSocketSource, socket::ListeningSocketSource,
@@ -118,11 +118,16 @@ pub struct Ferese {
output_identity_ids: HashMap<String, OutputId>, output_identity_ids: HashMap<String, OutputId>,
pub window_ids: HashMap<Window, WindowId>, pub window_ids: HashMap<Window, WindowId>,
pub window_geometry: HashMap<WindowId, WindowGeometry>, pub window_geometry: HashMap<WindowId, WindowGeometry>,
resize_transactions: HashMap<WindowId, crate::resize_transaction::ResizeTransaction>,
pub(crate) resize_snapshots: HashMap<WindowId, crate::winit::ResizeSnapshot>,
pub(crate) nested_backend: Option<crate::winit::NestedBackend>,
pub(crate) wallpaper: crate::wallpaper::WallpaperState,
maximized_windows: HashSet<WindowId>, maximized_windows: HashSet<WindowId>,
maximized_column_widths: HashMap<WindowId, ColumnWidth>, maximized_column_widths: HashMap<WindowId, ColumnWidth>,
window_stack: crate::stacking::WindowStack, window_stack: crate::stacking::WindowStack,
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>, pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_dims: HashMap<WindowId, crate::winit::WindowBorderBuffers>, pub(crate) window_dims: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_resize_fills: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_dimming: HashMap<WindowId, crate::dimming::DimAnimation>, pub(crate) window_dimming: HashMap<WindowId, crate::dimming::DimAnimation>,
pub(crate) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>, pub(crate) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>,
pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>, pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>,
@@ -203,6 +208,7 @@ pub struct Ferese {
} }
pub struct RuntimeConfig { pub struct RuntimeConfig {
pub(crate) wallpaper: crate::wallpaper::WallpaperConfig,
pub layout_mode: LayoutMode, pub layout_mode: LayoutMode,
pub gap_config: GapConfig, pub gap_config: GapConfig,
pub input_settings: InputSettings, pub input_settings: InputSettings,
@@ -321,6 +327,15 @@ impl Ferese {
.find_map(|(output, id)| (*id == focused).then_some(output)) .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<OutputId> { pub(crate) fn output_id(&self, output: &Output) -> Option<OutputId> {
self.output_ids.get(output).copied() self.output_ids.get(output).copied()
} }
@@ -340,12 +355,14 @@ impl Ferese {
let Some(output_id) = self.output_ids.get(&output).copied() else { let Some(output_id) = self.output_ids.get(&output).copied() else {
return; return;
}; };
if self.output_workspaces.focused_output() == Some(output_id) {
return;
}
let Some(workspace) = self.output_workspaces.active_workspace(output_id) else { let Some(workspace) = self.output_workspaces.active_workspace(output_id) else {
return; return;
}; };
if self.output_workspaces.focused_output() == Some(output_id)
&& self.workspaces.active_id() == workspace
{
return;
}
self.activate_output_workspace(output_id, workspace); self.activate_output_workspace(output_id, workspace);
self.relayout(); self.relayout();
@@ -453,11 +470,16 @@ impl Ferese {
output_identity_ids: HashMap::new(), output_identity_ids: HashMap::new(),
window_ids: HashMap::new(), window_ids: HashMap::new(),
window_geometry: 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_windows: HashSet::new(),
maximized_column_widths: HashMap::new(), maximized_column_widths: HashMap::new(),
window_stack: crate::stacking::WindowStack::default(), window_stack: crate::stacking::WindowStack::default(),
window_borders: HashMap::new(), window_borders: HashMap::new(),
window_dims: HashMap::new(), window_dims: HashMap::new(),
window_resize_fills: HashMap::new(),
window_dimming: HashMap::new(), window_dimming: HashMap::new(),
window_shadows: HashMap::new(), window_shadows: HashMap::new(),
rounded_clip_programs: HashMap::new(), rounded_clip_programs: HashMap::new(),
@@ -596,8 +618,12 @@ impl Ferese {
return None; return None;
} }
let workspace = self.workspace_under_pointer(position)?;
self.space.elements().rev().find_map(|window| { self.space.elements().rev().find_map(|window| {
let id = self.window_ids.get(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 visual = self.presented_window_rect(*id)?;
let inside_visual = position.x >= visual.x let inside_visual = position.x >= visual.x
&& position.y >= visual.y && position.y >= visual.y
@@ -666,8 +692,12 @@ impl Ferese {
} }
pub fn window_under_visual(&self, position: Point<f64, Logical>) -> Option<Window> { pub fn window_under_visual(&self, position: Point<f64, Logical>) -> Option<Window> {
let workspace = self.workspace_under_pointer(position)?;
self.space.elements().rev().find_map(|window| { self.space.elements().rev().find_map(|window| {
let id = self.window_ids.get(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 visual = self.presented_window_rect(*id)?;
(position.x >= visual.x (position.x >= visual.x
@@ -678,6 +708,19 @@ impl Ferese {
}) })
} }
fn workspace_under_pointer(&self, position: Point<f64, Logical>) -> Option<WorkspaceId> {
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) { pub(crate) fn raise_window(&mut self, window: &Window, activate: bool) {
if let Some(id) = self.window_ids.get(window) { if let Some(id) = self.window_ids.get(window) {
self.window_stack.raise(*id); self.window_stack.raise(*id);
@@ -704,6 +747,9 @@ impl Ferese {
} }
pub fn visual_scale_for_window(&self, window: &Window) -> Option<(f64, f64)> { 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 id = self.window_ids.get(window)?;
let geometry = self.window_geometry.get(id)?; let geometry = self.window_geometry.get(id)?;
let source = geometry.client.committed_size?; let source = geometry.client.committed_size?;
@@ -916,6 +962,9 @@ impl Ferese {
self.space.unmap_elem(window); self.space.unmap_elem(window);
self.window_geometry.remove(&id); 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_windows.remove(&id);
self.maximized_column_widths.remove(&id); self.maximized_column_widths.remove(&id);
self.window_stack.remove(id); self.window_stack.remove(id);
@@ -939,7 +988,9 @@ impl Ferese {
} }
pub fn relayout(&mut self) { 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(); self.arrange_layers();
let mut previous_scrolling_world_x = std::mem::take(&mut self.scrolling_world_x); 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); let pending_column_width_cycles = std::mem::take(&mut self.pending_column_width_cycles);
@@ -1071,6 +1122,9 @@ impl Ferese {
.map(|(window, _)| window.clone()) .map(|(window, _)| window.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut layout_changed = hidden
.iter()
.any(|window| self.space.element_location(window).is_some());
for window in hidden { for window in hidden {
self.space.unmap_elem(&window); self.space.unmap_elem(&window);
} }
@@ -1100,6 +1154,7 @@ impl Ferese {
} else { } else {
PresentationMode::Normal PresentationMode::Normal
}; };
layout_changed |= !had_geometry || geometry.logical != rect;
let mut requested_size = geometry.set_presentation_mode(rect, mode, now); let mut requested_size = geometry.set_presentation_mode(rect, mode, now);
if !self.animations_enabled { if !self.animations_enabled {
geometry.advance(Duration::ZERO, self.spring_config, false); geometry.advance(Duration::ZERO, self.spring_config, false);
@@ -1198,11 +1253,24 @@ impl Ferese {
}); });
if requested_size.is_some() || state_changed { 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; self.scrolling_world_x = scrolling_world_x;
if layout_changed {
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
}
self.sync_window_stacking(); self.sync_window_stacking();
self.retarget_overview(); self.retarget_overview();
self.send_shell_snapshots(); self.send_shell_snapshots();
@@ -1211,8 +1279,7 @@ impl Ferese {
} }
pub fn advance_animations(&mut self, now: Instant) -> bool { pub fn advance_animations(&mut self, now: Instant) -> bool {
let delta = now.saturating_duration_since(self.last_animation_tick); let delta = crate::presentation::frame_delta(&mut self.last_animation_tick, now);
self.last_animation_tick = now;
self.advance_animations_by(delta) self.advance_animations_by(delta)
} }
@@ -1227,8 +1294,11 @@ impl Ferese {
.direct_backend .direct_backend
.as_mut() .as_mut()
.and_then(|backend| backend.record_presentation(node, crtc, time, sequence)); .and_then(|backend| backend.record_presentation(node, crtc, time, sequence));
if let Some(delta) = delta { if delta.is_some() {
self.advance_animations_by(delta); // 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); 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::<HashSet<_>>();
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 { for (workspace, viewport) in &mut self.viewport_animations {
if !visible_workspaces.contains(workspace) { if !visible_workspaces.contains(workspace) {
continue; continue;
} }
if blocked_workspaces.contains(workspace) {
continue;
}
if self.animations_enabled { if self.animations_enabled {
active_animation |= viewport.advance(delta, self.viewport_spring_config); active_animation |= viewport.advance(delta, self.viewport_spring_config);
} else { } else {
@@ -1300,6 +1433,13 @@ impl Ferese {
let mut settled_coupled_widths = Vec::new(); let mut settled_coupled_widths = Vec::new();
for (window, id) in windows { 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 { let Some(geometry) = self.window_geometry.get_mut(&id) else {
continue; continue;
}; };
@@ -1390,10 +1530,115 @@ impl Ferese {
self.animations_enabled 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::<XdgToplevelSurfaceData>()
.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) { pub fn record_client_commit(&mut self, window: &Window) {
let Some(id) = self.window_ids.get(window) else { let Some(id) = self.window_ids.get(window) else {
return; 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::<XdgToplevelSurfaceData>()
.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 { let Some(size) = client_size(window) else {
return; return;
}; };
@@ -1869,6 +2114,8 @@ impl Ferese {
// Direct manipulation follows the pointer, including while the client is // Direct manipulation follows the pointer, including while the client is
// still drawing its next buffer. Rendering and hit testing share this rect. // 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 let geometry = self
.window_geometry .window_geometry
.entry(id) .entry(id)
+227
View File
@@ -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<PathBuf>,
#[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<mpsc::Receiver<Result<image::RgbaImage, String>>>,
pixels: Option<image::RgbaImage>,
textures: HashMap<ErasedContextId, WallpaperTexture>,
}
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<image::RgbaImage, String> {
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<NativeTextureElement> {
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<i32, Buffer>,
output: Size<i32, Physical>,
mode: WallpaperMode,
) -> (Rectangle<i32, Physical>, Rectangle<f64, Buffer>) {
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<i32, Physical> = (
(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());
}
}
+338 -58
View File
@@ -1,6 +1,8 @@
use std::{ use std::{
cell::{Cell, RefCell},
collections::HashMap, collections::HashMap,
error::Error, error::Error,
rc::Rc,
sync::{ sync::{
Arc, Arc,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
@@ -12,7 +14,7 @@ use smithay::{
backend::{ backend::{
allocator::Fourcc, allocator::Fourcc,
renderer::{ renderer::{
Color32F, ErasedContextId, Frame, ImportDma, Offscreen, Renderer, Texture, Bind, Color32F, ErasedContextId, Frame, ImportDma, Offscreen, Renderer, Texture,
damage::OutputDamageTracker, damage::OutputDamageTracker,
element::{ element::{
Element, Id, Kind as RenderElementKind, RenderElement, UnderlyingStorage, Element, Id, Kind as RenderElementKind, RenderElement, UnderlyingStorage,
@@ -43,7 +45,10 @@ use smithay::{
}, },
input::pointer::{CursorImageStatus, CursorImageSurfaceData}, input::pointer::{CursorImageStatus, CursorImageSurfaceData},
output::{Mode, Output, PhysicalProperties, Scale, Subpixel}, 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_protocols::wp::presentation_time::server::wp_presentation_feedback::Kind as PresentationKind,
reexports::wayland_server::Resource, reexports::wayland_server::Resource,
utils::{ utils::{
@@ -57,6 +62,7 @@ use smithay::{
use crate::{ use crate::{
Ferese, Ferese,
metrics::{FrameEffectMetrics, RenderMetrics}, metrics::{FrameEffectMetrics, RenderMetrics},
presentation::{NativeTextureElement, PhysicalShaderElement, physical_rect},
}; };
type SurfaceRenderElement = CropRenderElement< type SurfaceRenderElement = CropRenderElement<
@@ -81,6 +87,85 @@ render_elements! {
Solid=SolidColorRenderElement, Solid=SolidColorRenderElement,
Border=PixelShaderElement, Border=PixelShaderElement,
Blur=BlurRenderElement, Blur=BlurRenderElement,
Effect=PhysicalShaderElement,
Native=NativeTextureElement,
}
pub(crate) type NestedBackend = Rc<RefCell<winit::WinitGraphicsBackend<GlesRenderer>>>;
#[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<Duration>,
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<i32, Logical>,
scale: f64,
remaining: usize,
) -> Result<Option<ResizeSnapshot>, GlesError> {
let Some(toplevel) = window.toplevel() else {
return Ok(None);
};
let size: Size<i32, Physical> = 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<GlesRenderer>,
>(
renderer,
toplevel.wl_surface(),
Point::<i32, Logical>::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::<GlesTexture>::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! { render_elements! {
@@ -664,7 +749,11 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
let initial_scale = normalized_scale(backend.scale_factor()); let initial_scale = normalized_scale(backend.scale_factor());
let mode = Mode { let mode = Mode {
size: backend.window_size(), 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( let output = Output::new(
"ferese-winit".into(), "ferese-winit".into(),
@@ -689,19 +778,46 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
let mut damage_tracker = OutputDamageTracker::from_output(&output); let mut damage_tracker = OutputDamageTracker::from_output(&output);
let clock = Clock::<Monotonic>::new(); let clock = Clock::<Monotonic>::new();
let mut sequence = 0_u64; let mut sequence = 0_u64;
let mut missed_deadlines = 0_u64;
let mut output_scale = initial_scale; let mut output_scale = initial_scale;
let mut render_metrics = RenderMetrics::from_environment(output.name()); 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 event_loop
.handle() .handle()
.insert_source(event_source, move |event, _, state| match event { .insert_source(Timer::from_duration(refresh.get()), move |_, _, _| {
redraw_backend.borrow().window().request_redraw();
TimeoutAction::ToDuration(redraw_refresh.get())
})?;
event_loop
.handle()
.insert_source(event_source, move |event, _, state| {
let mut backend = backend.borrow_mut();
match event {
WinitEvent::Resized { size, scale_factor } => { WinitEvent::Resized { size, scale_factor } => {
state.backdrop_generation = state.backdrop_generation.wrapping_add(1);
let scale = normalized_scale(scale_factor); 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));
output.change_current_state( output.change_current_state(
Some(Mode { Some(Mode {
size, size,
refresh: 60_000, refresh: rate,
}), }),
None, None,
Some(Scale::Fractional(scale)), Some(Scale::Fractional(scale)),
@@ -771,13 +887,16 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
let (damage, effects) = match rendered { let (damage, effects) = match rendered {
Ok((Some(damage), effects)) => (damage, effects), Ok((Some(damage), effects)) => (damage, effects),
Ok((None, _)) => { 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.space.refresh();
state.popups.cleanup(); state.popups.cleanup();
layer_map_for_output(&output).cleanup(); layer_map_for_output(&output).cleanup();
if let Err(error) = state.display_handle.flush_clients() { if let Err(error) = state.display_handle.flush_clients() {
tracing::debug!(%error, "failed to flush clients"); tracing::debug!(%error, "failed to flush clients");
} }
backend.window().request_redraw();
return; return;
} }
Err(error) => { Err(error) => {
@@ -791,7 +910,11 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
state.loop_signal.stop(); state.loop_signal.stop();
return; 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); let mut presentation = OutputPresentationFeedback::new(&output);
state.space.elements().for_each(|window| { state.space.elements().for_each(|window| {
@@ -811,41 +934,36 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
sequence = sequence.wrapping_add(1); sequence = sequence.wrapping_add(1);
presentation.presented( presentation.presented(
clock.now(), clock.now(),
Refresh::fixed(Duration::from_nanos(1_000_000_000 / 60)), Refresh::fixed(refresh.get()),
sequence, sequence,
PresentationKind::Vsync, PresentationKind::Vsync,
); );
state.space.elements().for_each(|window| { send_nested_frame_callbacks(state, &output, refresh.get());
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.space.refresh();
state.popups.cleanup(); state.popups.cleanup();
layer_map_for_output(&output).cleanup(); layer_map_for_output(&output).cleanup();
if let Err(error) = state.display_handle.flush_clients() { if let Err(error) = state.display_handle.flush_clients() {
tracing::debug!(%error, "failed to 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(()) 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( pub(crate) fn animated_window_elements(
state: &mut Ferese, state: &mut Ferese,
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
@@ -875,6 +993,9 @@ fn output_elements(
output: &Output, output: &Output,
include_cursor: bool, include_cursor: bool,
) -> Vec<AnimatedWindowRenderElement> { ) -> Vec<AnimatedWindowRenderElement> {
if state.wallpaper.poll() {
state.backdrop_generation = state.backdrop_generation.wrapping_add(1);
}
let Some(output_geometry) = state.space.output_geometry(output) else { let Some(output_geometry) = state.space.output_geometry(output) else {
return Vec::new(); return Vec::new();
}; };
@@ -898,6 +1019,12 @@ fn output_elements(
.rev() .rev()
.filter_map(|window| { .filter_map(|window| {
let id = *state.window_ids.get(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 visual = state.presented_window_rect(id)?;
let (close_scale, close_alpha) = state.closing_visual(id); let (close_scale, close_alpha) = state.closing_visual(id);
let visual = scaled_visual_rect(visual, close_scale); 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 { for (window, id, visual, decoration_progress, close_alpha) in windows {
let constrain = rounded_visual_rect(visual, output_geometry.loc); 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); 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, state,
renderer, renderer,
id, id,
constrain, constrain,
pixels,
scale, scale,
state.theme_settings.window_radius * decoration_progress, 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, output,
) { ) {
// Front-to-back: dim the application and its border, not its shadow // Front-to-back: dim the application and its border, not its shadow
@@ -958,6 +1091,7 @@ fn output_elements(
renderer, renderer,
id, id,
constrain, constrain,
pixels,
scale, scale,
window_radius, window_radius,
border_width, border_width,
@@ -972,6 +1106,7 @@ fn output_elements(
renderer, renderer,
id, id,
constrain, constrain,
pixels,
scale, scale,
window_radius, window_radius,
shadow_offset_y, shadow_offset_y,
@@ -981,10 +1116,52 @@ fn output_elements(
output, output,
&programs, &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( elements.extend(rounded_window_elements(
renderer, renderer,
&window, &window,
constrain, constrain,
pixels,
scale, scale,
window_radius, window_radius,
close_alpha, close_alpha,
@@ -994,7 +1171,31 @@ fn output_elements(
.is_some_and(|geometry| geometry.presentation_changed), .is_some_and(|geometry| geometry.presentation_changed),
output, output,
programs.texture.clone(), 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 { if let Some(shadow) = shadow {
elements.push(shadow.into()); elements.push(shadow.into());
} }
@@ -1012,7 +1213,7 @@ fn output_elements(
constrain, constrain,
ConstrainBehavior { ConstrainBehavior {
reference: ConstrainReference::Geometry, reference: ConstrainReference::Geometry,
behavior: ConstrainScaleBehavior::Stretch, behavior,
align: ConstrainAlign::TOP | ConstrainAlign::LEFT, align: ConstrainAlign::TOP | ConstrainAlign::LEFT,
}, },
)); ));
@@ -1029,6 +1230,9 @@ fn output_elements(
output, output,
&[Layer::Bottom, Layer::Background], &[Layer::Bottom, Layer::Background],
)); ));
if let Some(wallpaper) = state.wallpaper.element(renderer, output) {
elements.push(wallpaper.into());
}
elements elements
} }
@@ -1114,13 +1318,14 @@ fn window_border_element(
renderer: &GlesRenderer, renderer: &GlesRenderer,
id: ferese_layout::WindowId, id: ferese_layout::WindowId,
geometry: Rectangle<i32, Logical>, geometry: Rectangle<i32, Logical>,
physical: Rectangle<i32, Physical>,
scale: f64, scale: f64,
requested_radius: f64, requested_radius: f64,
requested_width: f64, requested_width: f64,
color: [f32; 4], color: [f32; 4],
output: &Output, output: &Output,
programs: &RoundedClipPrograms, programs: &RoundedClipPrograms,
) -> Option<PixelShaderElement> { ) -> Option<PhysicalShaderElement> {
let mode = output.current_mode()?; let mode = output.current_mode()?;
let width = scaled_effect_value(requested_width, geometry, scale); let width = scaled_effect_value(requested_width, geometry, scale);
if width == 0.0 { if width == 0.0 {
@@ -1129,11 +1334,7 @@ fn window_border_element(
let parameters = BorderParameters { let parameters = BorderParameters {
geometry, geometry,
clip_rect: framebuffer_clip_rect( clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()),
geometry.to_physical_precise_round(scale),
mode.size,
output.current_transform().invert(),
),
radius: scaled_effect_value(requested_radius, geometry, scale), radius: scaled_effect_value(requested_radius, geometry, scale),
width, width,
color, color,
@@ -1167,7 +1368,10 @@ fn window_border_element(
cached.parameters = parameters; cached.parameters = parameters;
} }
Some(cached.element.clone()) Some(PhysicalShaderElement {
inner: cached.element.clone(),
geometry: physical,
})
} }
fn border_uniforms(parameters: &BorderParameters) -> Vec<Uniform<'static>> { fn border_uniforms(parameters: &BorderParameters) -> Vec<Uniform<'static>> {
@@ -1180,32 +1384,34 @@ fn border_uniforms(parameters: &BorderParameters) -> Vec<Uniform<'static>> {
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn window_dim_element( fn window_tint_element(
state: &mut Ferese, state: &mut Ferese,
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
id: ferese_layout::WindowId, id: ferese_layout::WindowId,
geometry: Rectangle<i32, Logical>, geometry: Rectangle<i32, Logical>,
physical: Rectangle<i32, Physical>,
scale: f64, scale: f64,
radius: f64, radius: f64,
amount: f32, color: [f32; 4],
resize_fill: bool,
output: &Output, output: &Output,
) -> Option<PixelShaderElement> { ) -> Option<PhysicalShaderElement> {
if amount <= 0.0 { if color[3] <= 0.0 {
if resize_fill {
state.window_resize_fills.remove(&id);
} else {
state.window_dims.remove(&id); state.window_dims.remove(&id);
}
return None; return None;
} }
let mode = output.current_mode()?; let mode = output.current_mode()?;
let program = material_program(state, renderer)?; let program = material_program(state, renderer)?;
let parameters = BorderParameters { let parameters = BorderParameters {
geometry, geometry,
clip_rect: framebuffer_clip_rect( clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()),
geometry.to_physical_precise_round(scale),
mode.size,
output.current_transform().invert(),
),
radius: scaled_effect_value(radius, geometry, scale), radius: scaled_effect_value(radius, geometry, scale),
width: 0.0, width: 0.0,
color: [0.0, 0.0, 0.0, amount.clamp(0.0, 1.0)], color,
}; };
let uniforms = |p: &BorderParameters| { let uniforms = |p: &BorderParameters| {
vec![ vec![
@@ -1218,7 +1424,11 @@ fn window_dim_element(
] ]
}; };
let context = renderer.context_id().erased(); 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 let cached = buffers
.contexts .contexts
.entry(context) .entry(context)
@@ -1242,7 +1452,10 @@ fn window_dim_element(
cached.element.update_uniforms(uniforms(&parameters)); cached.element.update_uniforms(uniforms(&parameters));
cached.parameters = parameters; cached.parameters = parameters;
} }
Some(cached.element.clone()) Some(PhysicalShaderElement {
inner: cached.element.clone(),
geometry: physical,
})
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -1251,6 +1464,7 @@ fn window_shadow_element(
renderer: &GlesRenderer, renderer: &GlesRenderer,
id: ferese_layout::WindowId, id: ferese_layout::WindowId,
geometry: Rectangle<i32, Logical>, geometry: Rectangle<i32, Logical>,
physical: Rectangle<i32, Physical>,
scale: f64, scale: f64,
requested_radius: f64, requested_radius: f64,
offset_y: f64, offset_y: f64,
@@ -1259,22 +1473,26 @@ fn window_shadow_element(
color: [f32; 4], color: [f32; 4],
output: &Output, output: &Output,
programs: &RoundedClipPrograms, programs: &RoundedClipPrograms,
) -> Option<PixelShaderElement> { ) -> Option<PhysicalShaderElement> {
let mode = output.current_mode()?; let mode = output.current_mode()?;
if opacity == 0.0 || color[3] == 0.0 { if opacity == 0.0 || color[3] == 0.0 {
return None; return None;
} }
let shadow_geometry = Rectangle::new( 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 bounds = shadow_bounds(geometry, offset_y, blur);
let parameters = ShadowParameters { let parameters = ShadowParameters {
blur: (blur * scale) as f32, blur: (blur * scale) as f32,
bounds, bounds,
shadow_rect: framebuffer_clip_rect( shadow_rect: framebuffer_clip_rect(
shadow_geometry.to_physical_precise_round(scale), shadow_geometry,
mode.size, mode.size,
output.current_transform().invert(), output.current_transform().invert(),
), ),
@@ -1311,7 +1529,22 @@ fn window_shadow_element(
cached.parameters = parameters; 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<Uniform<'static>> { fn shadow_uniforms(parameters: &ShadowParameters) -> Vec<Uniform<'static>> {
@@ -1350,12 +1583,14 @@ fn rounded_window_elements(
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
window: &smithay::desktop::Window, window: &smithay::desktop::Window,
constrain: Rectangle<i32, Logical>, constrain: Rectangle<i32, Logical>,
physical_constrain: Rectangle<i32, Physical>,
scale: f64, scale: f64,
requested_radius: f64, requested_radius: f64,
alpha: f32, alpha: f32,
clip_changed: bool, clip_changed: bool,
output: &Output, output: &Output,
program: GlesTexProgram, program: GlesTexProgram,
behavior: ConstrainScaleBehavior,
) -> Vec<AnimatedWindowRenderElement> { ) -> Vec<AnimatedWindowRenderElement> {
let Some(toplevel) = window.toplevel() else { let Some(toplevel) = window.toplevel() else {
return Vec::new(); return Vec::new();
@@ -1366,8 +1601,7 @@ fn rounded_window_elements(
let geometry = window.geometry(); let geometry = window.geometry();
let reference = geometry.to_physical_precise_round(scale); let reference = geometry.to_physical_precise_round(scale);
let physical_constrain = constrain.to_physical_precise_round(scale); let location = physical_constrain.loc - geometry.loc.to_physical_precise_round(scale);
let location = (constrain.loc - geometry.loc).to_physical_precise_round(scale);
let clip = framebuffer_clip_rect( let clip = framebuffer_clip_rect(
physical_constrain, physical_constrain,
mode.size, mode.size,
@@ -1426,7 +1660,7 @@ fn rounded_window_elements(
location, location,
physical_constrain, physical_constrain,
reference, reference,
ConstrainScaleBehavior::Stretch, behavior,
ConstrainAlign::TOP | ConstrainAlign::LEFT, ConstrainAlign::TOP | ConstrainAlign::LEFT,
scale, scale,
) )
@@ -1434,6 +1668,14 @@ fn rounded_window_elements(
.collect() .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 { fn scaled_visual_rect(rect: ferese_layout::Rect, scale: f64) -> ferese_layout::Rect {
let scale = scale.clamp(0.0, 1.0); let scale = scale.clamp(0.0, 1.0);
let width = rect.width * scale; let width = rect.width * scale;
@@ -2056,8 +2298,8 @@ mod tests {
}; };
use super::{ use super::{
color_with_alpha, framebuffer_clip_rect, normalized_scale, rounded_visual_rect, color_with_alpha, framebuffer_clip_rect, normalized_scale, resize_content_behavior,
scaled_visual_rect, shadow_bounds, rounded_visual_rect, scaled_visual_rect, shadow_bounds,
}; };
#[derive(Debug)] #[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::<i32, Logical>::from_size((600, 800).into());
let destination = Rectangle::<i32, Logical>::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] #[test]
fn accepts_positive_finite_scale() { fn accepts_positive_finite_scale() {
assert_eq!(normalized_scale(1.25), 1.25); assert_eq!(normalized_scale(1.25), 1.25);
+61 -8
View File
@@ -380,8 +380,9 @@ impl WindowGeometry {
self.presentation_size_request(now) self.presentation_size_request(now)
} }
/// Configure the destination once. Intermediate sizes make clients reflow /// Request the destination once, independently of the presentation clock.
/// and replace buffers on every frame, disrupting an otherwise smooth zoom. /// 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<ClientSize> { pub fn presentation_size_request(&mut self, now: Duration) -> Option<ClientSize> {
let size = ClientSize::from_rect(self.logical); let size = ClientSize::from_rect(self.logical);
self.client.request_size(size, now).then_some(size) self.client.request_size(size, now).then_some(size)
@@ -710,7 +711,7 @@ mod tests {
} }
#[test] #[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 start = Rect::new(0.0, 0.0, 600.0, 800.0);
let end = Rect::new(0.0, 0.0, 1920.0, 1080.0); let end = Rect::new(0.0, 0.0, 1920.0, 1080.0);
let mut geometry = WindowGeometry::new(start, Some(ClientSize::from_rect(start))); 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), geometry.set_presentation_target(end, true, Duration::ZERO),
Some(ClientSize::from_rect(end)) Some(ClientSize::from_rect(end))
); );
geometry.client.commit(ClientSize::from_rect(end));
for frame in 0..240 { for frame in 0..240 {
let now = Duration::from_millis(frame * 8);
geometry.advance(Duration::from_millis(8), SpringConfig::default(), true); geometry.advance(Duration::from_millis(8), SpringConfig::default(), true);
assert_eq!( assert_eq!(geometry.presentation_size_request(now), None);
geometry.presentation_size_request(Duration::from_millis(frame * 8)),
None
);
} }
assert_eq!(geometry.visual.current, end);
assert_eq!( assert_eq!(
geometry.set_presentation_target(start, false, Duration::from_secs(2)), geometry.set_presentation_target(start, false, Duration::from_secs(2)),
Some(ClientSize::from_rect(start)) 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] #[test]
fn left_and_right_zoom_edges_do_not_reverse_direction() { fn left_and_right_zoom_edges_do_not_reverse_direction() {
let fullscreen = Rect::new(0.0, 0.0, 1920.0, 1080.0); let fullscreen = Rect::new(0.0, 0.0, 1920.0, 1080.0);
+54 -3
View File
@@ -271,7 +271,7 @@ impl OutputWorkspaceMap {
return Ok(WorkspaceSwitch::FocusedExisting(owner)); return Ok(WorkspaceSwitch::FocusedExisting(owner));
} }
self.assignments.insert(workspace, output); let reassigned = self.assignments.insert(workspace, output) != Some(output);
let state = self let state = self
.outputs .outputs
.get_mut(&output) .get_mut(&output)
@@ -279,7 +279,9 @@ impl OutputWorkspaceMap {
state.workspaces.insert(workspace); state.workspaces.insert(workspace);
state.active = workspace; state.active = workspace;
self.focused = Some(output); self.focused = Some(output);
if reassigned {
self.bump_revision(workspace); self.bump_revision(workspace);
}
debug_assert!(self.validate()); debug_assert!(self.validate());
Ok(WorkspaceSwitch::Activated(output)) Ok(WorkspaceSwitch::Activated(output))
@@ -482,7 +484,7 @@ mod tests {
} }
#[test] #[test]
fn explicit_workspace_selection_prevents_automatic_return() { fn using_an_evacuated_workspace_does_not_prevent_automatic_return() {
let mut outputs = OutputWorkspaceMap::default(); let mut outputs = OutputWorkspaceMap::default();
outputs outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1)) .connect(OutputId(1), geometry(0), WorkspaceId(1))
@@ -498,12 +500,61 @@ mod tests {
assert_eq!( assert_eq!(
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(3)), outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(3)),
Ok(WorkspaceId(3)) Ok(WorkspaceId(2))
); );
assert_eq!( assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)), outputs.output_for_workspace(WorkspaceId(2)),
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)) 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] #[test]
+3 -1
View File
@@ -28,6 +28,7 @@ pub(crate) struct ShellSnapshot {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct OutputSnapshot { pub(crate) struct OutputSnapshot {
pub(crate) id: u64, pub(crate) id: u64,
pub(crate) name: String,
pub(crate) active_workspace: u64, pub(crate) active_workspace: u64,
pub(crate) focused: bool, pub(crate) focused: bool,
} }
@@ -199,12 +200,13 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
ferese_shell_v1::Event::Output { ferese_shell_v1::Event::Output {
output_hi, output_hi,
output_lo, output_lo,
name: _, name,
active_workspace_hi, active_workspace_hi,
active_workspace_lo, active_workspace_lo,
focused, focused,
} => state.pending.outputs.push(OutputSnapshot { } => state.pending.outputs.push(OutputSnapshot {
id: join_id(output_hi, output_lo), id: join_id(output_hi, output_lo),
name,
active_workspace: join_id(active_workspace_hi, active_workspace_lo), active_workspace: join_id(active_workspace_hi, active_workspace_lo),
focused: focused != 0, focused: focused != 0,
}), }),
+264 -101
View File
@@ -18,8 +18,8 @@ use cosmic::iced::alignment;
use cosmic::iced::event::{self, PlatformSpecific, wayland}; use cosmic::iced::event::{self, PlatformSpecific, wayland};
use cosmic::iced::platform_specific::{ use cosmic::iced::platform_specific::{
runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings}, 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::{Anchor, KeyboardInteractivity, Layer},
shell::commands::layer_surface::{destroy_layer_surface, set_input_zone},
}; };
use cosmic::iced::{ use cosmic::iced::{
Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, window, Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, window,
@@ -34,7 +34,7 @@ use jiff::Zoned;
use wayland_client::{ use wayland_client::{
Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop, Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop,
globals::{GlobalListContents, registry_queue_init}, globals::{GlobalListContents, registry_queue_init},
protocol::{wl_registry, wl_surface}, protocol::{wl_output, wl_registry, wl_surface},
}; };
use crate::config::{ShellConfig, ShellTheme, WallpaperMode}; use crate::config::{ShellConfig, ShellTheme, WallpaperMode};
@@ -102,7 +102,13 @@ fn main() -> cosmic::iced::Result {
// toolkit threads are started. // toolkit threads are started.
unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") }; 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 = configured_font(config.font_family.as_deref());
let _ = SHELL_FONT.set(shell_font); let _ = SHELL_FONT.set(shell_font);
// Decode alongside toolkit/GPU initialization, never during a UI draw. // Decode alongside toolkit/GPU initialization, never during a UI draw.
@@ -141,13 +147,22 @@ struct FereseShell {
snapshot: ShellSnapshot, snapshot: ShellSnapshot,
overview_active: bool, overview_active: bool,
clock: String, clock: String,
effects: Option<EffectsBinding>, outputs: Vec<OutputSurfaces>,
status_service: status::Service, status_service: status::Service,
status: status::Snapshot, status: status::Snapshot,
status_error: Option<String>, status_error: Option<String>,
menu: Option<status_ui::OpenMenu>, menu: Option<status_ui::OpenMenu>,
} }
struct OutputSurfaces {
output: wl_output::WlOutput,
name: Option<String>,
bar: window::Id,
wallpaper: Option<window::Id>,
effects: Option<EffectsBinding>,
hidden: bool,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { enum Message {
WallpaperLoaded(Result<image::Handle, String>), WallpaperLoaded(Result<image::Handle, String>),
@@ -163,6 +178,7 @@ enum Message {
StatusTick, StatusTick,
AnimateMenu, AnimateMenu,
OpenMenu(status_ui::Menu, cosmic::iced::Rectangle<i32>), OpenMenu(status_ui::Menu, cosmic::iced::Rectangle<i32>),
OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle<i32>),
Control(status::Action), Control(status::Action),
ConfirmPower(status::Action), ConfirmPower(status::Action),
CancelPower, CancelPower,
@@ -185,9 +201,6 @@ impl cosmic::Application for FereseShell {
fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task<Self::Message>) { fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task<Self::Message>) {
let bar_surface_id = window::Id::unique(); 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 { let app = Self {
core, core,
bar_surface_id, bar_surface_id,
@@ -205,58 +218,8 @@ impl cosmic::Application for FereseShell {
snapshot: ShellSnapshot::default(), snapshot: ShellSnapshot::default(),
overview_active: false, overview_active: false,
clock: current_time(), clock: current_time(),
effects: None, outputs: Vec::new(),
}; };
let wallpaper_action = cosmic::surface::action::app_layer_shell::<Self>(
|_| 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::<Self>(
|_| 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 { let wallpaper_task = match wallpaper {
Some(receiver) => cosmic::task::future(async move { Some(receiver) => cosmic::task::future(async move {
cosmic::Action::App(Message::WallpaperLoaded( cosmic::Action::App(Message::WallpaperLoaded(
@@ -267,7 +230,7 @@ impl cosmic::Application for FereseShell {
}), }),
None => Task::none(), None => Task::none(),
}; };
(app, Task::batch([Task::batch(tasks), wallpaper_task])) (app, wallpaper_task)
} }
fn subscription(&self) -> Subscription<Self::Message> { fn subscription(&self) -> Subscription<Self::Message> {
@@ -281,7 +244,9 @@ impl cosmic::Application for FereseShell {
Event::Keyboard(_) Event::Keyboard(_)
| Event::Window(window::Event::Opened { .. } | window::Event::Closed) | Event::Window(window::Event::Opened { .. } | window::Event::Closed)
| Event::PlatformSpecific(PlatformSpecific::Wayland( | Event::PlatformSpecific(PlatformSpecific::Wayland(
wayland::Event::Popup(..) | wayland::Event::Layer(..), wayland::Event::Popup(..)
| wayland::Event::Layer(..)
| wayland::Event::Output(..),
)) => Some(Message::Event(event, id)), )) => Some(Message::Event(event, id)),
_ => None, _ => None,
}), }),
@@ -314,8 +279,10 @@ impl cosmic::Application for FereseShell {
} }
Message::NativeSurface(id, result) => { Message::NativeSurface(id, result) => {
match result { match result {
Ok((_connection, surface)) if id == self.bar_surface_id => { Ok((_connection, surface))
self.attach_effects(&surface) if self.outputs.iter().any(|entry| entry.bar == id) =>
{
self.attach_effects(id, &surface)
} }
Ok((_connection, surface)) => { Ok((_connection, surface)) => {
if let Some(menu) = &mut self.menu if let Some(menu) = &mut self.menu
@@ -345,6 +312,16 @@ impl cosmic::Application for FereseShell {
} }
Message::AnimateMenu => Task::none(), Message::AnimateMenu => Task::none(),
Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor), 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) => { Message::ConfirmPower(action) => {
if let Some(menu) = &mut self.menu { if let Some(menu) = &mut self.menu {
menu.confirm = Some(action); menu.confirm = Some(action);
@@ -383,26 +360,37 @@ impl cosmic::Application for FereseShell {
self.overview_active = active; self.overview_active = active;
} }
if let Some(snapshot) = poll.snapshot { if let Some(snapshot) = poll.snapshot {
let was_hidden = bar_hidden(&self.snapshot);
self.snapshot = snapshot; self.snapshot = snapshot;
let mut tasks = Vec::new();
let hidden = bar_hidden(&self.snapshot); for entry in &mut self.outputs {
if hidden != was_hidden { let output = self
if let Some(effects) = &self.effects .snapshot
&& let Err(error) = effects.set_visible(!hidden) .outputs
{ .iter()
eprintln!("ferese-shell: could not update panel material: {error}"); .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;
} }
entry.hidden = hidden;
let input_zone = if hidden { Some(Vec::new()) } else { None }; if let Some(effects) = &entry.effects {
if let Err(error) = effects.set_visible(!hidden) {
return Task::batch([ eprintln!(
set_input_zone(self.bar_surface_id, input_zone), "ferese-shell: could not update panel material: {error}"
self.destroy_menu(), );
]);
} }
} }
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() Task::none()
} }
@@ -444,10 +432,149 @@ impl cosmic::Application for FereseShell {
} }
impl FereseShell { impl FereseShell {
fn output_event(
&mut self,
event: wayland::OutputEvent,
output: wl_output::WlOutput,
) -> Task<Message> {
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::<Self>(
|_| 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::<Self>(
|_| 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<Message> { fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
match event { match event {
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(
event,
output,
))) => self.output_event(event, output),
Event::Window(window::Event::Opened { .. }) 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) => || self.menu.as_ref().is_some_and(|menu| menu.id == id) =>
{ {
window::run(id, native_wayland_surface) window::run(id, native_wayland_surface)
@@ -510,9 +637,7 @@ impl FereseShell {
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
Task::none() Task::none()
} }
Event::Window(window::Event::Closed) if id == self.bar_surface_id => { Event::Window(window::Event::Closed) => Task::none(),
cosmic::iced::exit()
}
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame( Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(
_, _,
surface, surface,
@@ -535,49 +660,48 @@ impl FereseShell {
_, _,
surface, surface,
frame_id, frame_id,
))) if frame_id == self.bar_surface_id => { ))) if self.outputs.iter().any(|entry| entry.bar == frame_id) => {
self.attach_effects(&surface); self.attach_effects(frame_id, &surface);
Task::none() Task::none()
} }
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer( Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(
_, _,
surface, surface,
layer_id, layer_id,
))) if layer_id == self.bar_surface_id => { ))) if self.outputs.iter().any(|entry| entry.bar == layer_id) => {
self.attach_effects(&surface); self.attach_effects(layer_id, &surface);
Task::none() Task::none()
} }
_ => Task::none(), _ => Task::none(),
} }
} }
fn attach_effects(&mut self, surface: &wl_surface::WlSurface) { fn attach_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) {
if self.effects.is_some() { 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; return;
} }
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
match EffectsBinding::attach(surface, !bar_hidden(&self.snapshot)) { match EffectsBinding::attach(surface, !hidden) {
Ok(binding) => self.effects = Some(binding), Ok(binding) => entry.effects = Some(binding),
Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"), Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"),
} }
} }
fn view_layer(&self) -> Element<'_, cosmic::Action<Message>> { fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
if bar_hidden(&self.snapshot) { if self.bar_hidden(id) {
return container(text("")) return container(text(""))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into(); .into();
} }
let focused_output = self let focused_output = self.output_for_bar(id);
.snapshot
.outputs
.iter()
.find(|output| output.focused)
.or_else(|| self.snapshot.outputs.first());
let focused_output_id = focused_output.map(|output| output.id); let focused_output_id = focused_output.map(|output| output.id);
let shell_theme = self.config.theme.for_bar(); let shell_theme = self.config.theme.for_bar();
let bar = BarMetrics::from(shell_theme); let bar = BarMetrics::from(shell_theme);
@@ -670,7 +794,14 @@ impl FereseShell {
.class(theme::Text::Color(foreground)), .class(theme::Text::Color(foreground)),
) )
.padding([0, 6]); .padding([0, 6]);
let right = row![self.view_status_bar(), clock] 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) .spacing(6)
.align_y(cosmic::iced::Alignment::Center); .align_y(cosmic::iced::Alignment::Center);
let content = row![ let content = row![
@@ -681,7 +812,10 @@ impl FereseShell {
.align_y(cosmic::iced::Alignment::Center) .align_y(cosmic::iced::Alignment::Center)
.height(Length::Fill); .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) container(content)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
@@ -777,12 +911,22 @@ fn overview_control(
.into() .into()
} }
#[cfg(test)]
fn bar_hidden(snapshot: &ShellSnapshot) -> bool { fn bar_hidden(snapshot: &ShellSnapshot) -> bool {
let active_workspace = snapshot let output = snapshot
.outputs .outputs
.iter() .iter()
.find(|output| output.focused) .find(|output| output.focused)
.or_else(|| snapshot.outputs.first()) .or_else(|| snapshot.outputs.first())
.map(|output| output.id);
output_bar_hidden(snapshot, output)
}
fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option<u64>) -> bool {
let active_workspace = snapshot
.outputs
.iter()
.find(|candidate| Some(candidate.id) == output)
.map(|output| output.active_workspace); .map(|output| output.active_workspace);
active_workspace.is_some_and(|workspace| { active_workspace.is_some_and(|workspace| {
@@ -1250,10 +1394,29 @@ mod tests {
assert!(!bar_hidden(&snapshot)); 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 { fn snapshot_with_fullscreen_window(workspace: u64) -> ShellSnapshot {
ShellSnapshot { ShellSnapshot {
outputs: vec![OutputSnapshot { outputs: vec![OutputSnapshot {
id: 1, id: 1,
name: "eDP-1".to_owned(),
active_workspace: 7, active_workspace: 7,
focused: true, focused: true,
}], }],
+57 -6
View File
@@ -9,7 +9,9 @@ use ferese_protocols::effects::v1::client::{
}; };
use wayland_client::{ use wayland_client::{
Connection, Dispatch, Proxy, QueueHandle, delegate_noop, 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::{ use wayland_protocols_wlr::layer_shell::v1::client::{
zwlr_layer_shell_v1::{self, ZwlrLayerShellV1}, zwlr_layer_shell_v1::{self, ZwlrLayerShellV1},
@@ -25,12 +27,16 @@ fn main() -> Result<(), Box<dyn Error>> {
.iter() .iter()
.any(|argument| argument == "--expect-hidden"); .any(|argument| argument == "--expect-hidden");
let preview = arguments.iter().any(|argument| argument == "--preview"); 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 connection = Connection::connect_to_env()?;
let mut queue = connection.new_event_queue(); let mut queue = connection.new_event_queue();
let qh = queue.handle(); let qh = queue.handle();
connection.display().get_registry(&qh, ()); connection.display().get_registry(&qh, ());
let mut state = ProbeState { let mut state = ProbeState {
preview, preview,
callbacks_only,
..ProbeState::default() ..ProbeState::default()
}; };
@@ -43,7 +49,7 @@ fn main() -> Result<(), Box<dyn Error>> {
return Ok(()); return Ok(());
} }
while state.effects.is_none() || !state.configured { while (!callbacks_only && state.effects.is_none()) || !state.configured {
queue.blocking_dispatch(&mut state)?; queue.blocking_dispatch(&mut state)?;
state.try_initialize(&qh)?; state.try_initialize(&qh)?;
if state.closed { if state.closed {
@@ -51,6 +57,25 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
} }
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 effects = state.effects.clone().expect("effects initialized");
let initial_configures = state.configure_count; let initial_configures = state.configure_count;
for role in [ for role in [
@@ -130,6 +155,8 @@ struct ProbeState {
preview_background_layer: Option<ZwlrLayerSurfaceV1>, preview_background_layer: Option<ZwlrLayerSurfaceV1>,
preview_background_buffer: Option<wl_buffer::WlBuffer>, preview_background_buffer: Option<wl_buffer::WlBuffer>,
preview: bool, preview: bool,
callbacks_only: bool,
frame_count: u32,
configured: bool, configured: bool,
closed: bool, closed: bool,
configure_count: u32, configure_count: u32,
@@ -142,14 +169,16 @@ impl ProbeState {
if self.surface.is_some() { if self.surface.is_some() {
return Ok(()); 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.compositor.as_ref(),
self.shm.as_ref(), self.shm.as_ref(),
self.layer_shell.as_ref(), self.layer_shell.as_ref(),
self.effects_manager.as_ref(),
) else { ) else {
return Ok(()); return Ok(());
}; };
if !self.callbacks_only && self.effects_manager.is_none() {
return Ok(());
}
let surface = compositor.create_surface(qh, ()); let surface = compositor.create_surface(qh, ());
let layer = if self.preview { let layer = if self.preview {
@@ -182,8 +211,15 @@ impl ProbeState {
layer_surface.set_exclusive_zone(HEIGHT as i32); layer_surface.set_exclusive_zone(HEIGHT as i32);
} }
let effects = effects_manager.get_surface_effects(&surface, qh, ()); 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.set_role(ferese_surface_effects_v1::Role::Panel);
effects
});
surface.commit(); surface.commit();
@@ -211,7 +247,7 @@ impl ProbeState {
self.surface = Some(surface); self.surface = Some(surface);
self.layer_surface = Some(layer_surface); self.layer_surface = Some(layer_surface);
self.effects = Some(effects); self.effects = effects;
Ok(()) Ok(())
} }
} }
@@ -380,6 +416,21 @@ impl Dispatch<ZwlrLayerSurfaceV1, ()> for ProbeState {
} }
} }
impl Dispatch<wl_callback::WlCallback, ()> for ProbeState {
fn event(
state: &mut Self,
_: &wl_callback::WlCallback,
event: wl_callback::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if matches!(event, wl_callback::Event::Done { .. }) {
state.frame_count += 1;
}
}
}
delegate_noop!(ProbeState: ignore wl_compositor::WlCompositor); delegate_noop!(ProbeState: ignore wl_compositor::WlCompositor);
delegate_noop!(ProbeState: ignore wl_shm::WlShm); delegate_noop!(ProbeState: ignore wl_shm::WlShm);
delegate_noop!(ProbeState: ignore wl_shm_pool::WlShmPool); delegate_noop!(ProbeState: ignore wl_shm_pool::WlShmPool);