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
+2093 -319

No files matched your search

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