From 4f95130158aadae606f4003198a698337a0346d8 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:24:48 +0200 Subject: [PATCH 1/2] fix(wayland): retain shm slots until buffer release --- src/about_window/render/mod.rs | 6 +++++- src/backend/wayland/state/render/mod.rs | 10 +++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/about_window/render/mod.rs b/src/about_window/render/mod.rs index aaf5251c..85a4a4ed 100644 --- a/src/about_window/render/mod.rs +++ b/src/about_window/render/mod.rs @@ -74,7 +74,11 @@ impl AboutWindowState { let wl_surface = self.window.wl_surface(); wl_surface.set_buffer_scale(self.scale); - wl_surface.attach(Some(buffer.wl_buffer()), 0, 0); + // Hold the slot until the compositor releases it; a raw attach frees it + // immediately and the next frame repaints the buffer still on screen. + buffer + .attach_to(wl_surface) + .map_err(|err| anyhow::anyhow!("failed to attach the about-window buffer: {err}"))?; wl_surface.damage_buffer(0, 0, phys_w as i32, phys_h as i32); wl_surface.commit(); diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs index f6fc40a1..804f437d 100644 --- a/src/backend/wayland/state/render/mod.rs +++ b/src/backend/wayland/state/render/mod.rs @@ -362,7 +362,15 @@ impl WaylandState { .cloned() .context("Surface not created")?; wl_surface.set_buffer_scale(scale); - wl_surface.attach(Some(buffer.wl_buffer()), 0, 0); + // `attach_to` marks the slot active until the compositor releases the + // buffer. Attaching the raw `wl_buffer()` instead leaves the slot free, + // so the pool hands the same memory back on the next frame and the next + // paint lands in the buffer the compositor is still reading - the whole + // swapchain collapses to one slot and partial damage resurfaces stale or + // half-drawn pixels. + buffer + .attach_to(&wl_surface) + .map_err(|err| anyhow::anyhow!("failed to attach the overlay buffer: {err}"))?; // Damage logic moved to top of function (add_regions and take_buffer_damage). // We now use the computed screen-space damage for clipping and compositor hints. From 25a526913fa37f2108b2385c4055954d6ea35436 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:06:16 +0200 Subject: [PATCH 2/2] fix(wayland): bound in-flight overlay buffers Keep the configured swapchain slots alive and defer renders while all buffers remain compositor-owned. Track those deferrals separately, scope inline hover damage, and preserve dialog entry/restoration semantics. --- .../wayland/backend/event_loop/render.rs | 14 +- src/backend/wayland/state.rs | 3 +- src/backend/wayland/state/perf.rs | 7 +- .../wayland/state/perf_modules/metrics.rs | 6 + .../state/perf_modules/metrics/tests.rs | 31 ++++ src/backend/wayland/state/render/mod.rs | 91 +++++++---- .../wayland/state/toolbar/events/session.rs | 125 ++++++++++++++- .../wayland/state/toolbar/inline/input.rs | 12 +- .../wayland/state/toolbar/inline/mod.rs | 21 +++ src/backend/wayland/surface.rs | 149 +++++++++++++----- 10 files changed, 376 insertions(+), 83 deletions(-) diff --git a/src/backend/wayland/backend/event_loop/render.rs b/src/backend/wayland/backend/event_loop/render.rs index 65b19d15..324c6bbb 100644 --- a/src/backend/wayland/backend/event_loop/render.rs +++ b/src/backend/wayland/backend/event_loop/render.rs @@ -2,7 +2,7 @@ use std::time::{Duration, Instant}; use log::{debug, warn}; -use super::super::super::state::{PerfRenderSkipReason, WaylandState}; +use super::super::super::state::{PerfRenderSkipReason, RenderOutcome, WaylandState}; const MAX_RENDER_FAILURES: u32 = 10; @@ -92,7 +92,17 @@ pub(super) fn maybe_render( state.input_state.zoom_chip_hover, ); match state.render(qh) { - Ok(keep_rendering) => { + Ok(RenderOutcome::BuffersInFlight) => { + // Nothing was painted or committed: the compositor still owns + // every slot. Leave `needs_redraw` set so the next pass retries, + // and pace that retry off `last_render_time` like any other + // attempt so the FPS cap throttles the wait instead of spinning. + // The frame counters stay untouched - this was not a frame. + *consecutive_render_failures = 0; + *last_render_time = Some(Instant::now()); + debug!("Main loop: render deferred - all buffers still held by the compositor"); + } + Ok(RenderOutcome::Committed { keep_rendering }) => { let render_end = Instant::now(); let render_duration = render_end.saturating_duration_since(render_start); if render_duration > Duration::from_millis(5) { diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index d5c3744f..66b23a20 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -27,7 +27,7 @@ use std::{ }; use wayland_client::{ Proxy, QueueHandle, - protocol::{wl_output, wl_pointer, wl_seat, wl_shm, wl_surface, wl_touch}, + protocol::{wl_output, wl_pointer, wl_seat, wl_surface, wl_touch}, }; #[cfg(feature = "tablet-input")] use wayland_protocols::wp::tablet::zv2::client::{ @@ -131,6 +131,7 @@ pub(in crate::backend::wayland) use self::perf::{ PerfDamageDiagnostics, PerfFrameDamageContext, PerfInputSource, PerfRenderBreakdown, PerfRenderProfileKind, PerfRenderSkipReason, damage_covers_logical_surface, }; +pub(in crate::backend::wayland) use self::render::RenderOutcome; pub(super) use helpers::{ damage_summary, debug_damage_logging_enabled, debug_toolbar_drag_logging_enabled, drag_log, force_inline_toolbars_requested, scale_damage_regions, surface_id, diff --git a/src/backend/wayland/state/perf.rs b/src/backend/wayland/state/perf.rs index 2dca98cc..195de498 100644 --- a/src/backend/wayland/state/perf.rs +++ b/src/backend/wayland/state/perf.rs @@ -71,6 +71,7 @@ pub(in crate::backend::wayland) enum PerfRenderSkipReason { FpsCap, SurfaceUnconfigured, NoRedraw, + BuffersInFlight, } impl fmt::Display for PerfRenderSkipReason { @@ -80,6 +81,7 @@ impl fmt::Display for PerfRenderSkipReason { Self::FpsCap => f.write_str("fps_cap"), Self::SurfaceUnconfigured => f.write_str("surface_unconfigured"), Self::NoRedraw => f.write_str("no_redraw"), + Self::BuffersInFlight => f.write_str("buffers_in_flight"), } } } @@ -218,6 +220,7 @@ struct PerfFramePacingSummary { skipped_fps_cap: u64, skipped_surface_unconfigured: u64, skipped_no_redraw: u64, + skipped_buffers_in_flight: u64, } #[derive(Debug)] @@ -244,6 +247,7 @@ pub(super) struct PerfMetrics { skipped_fps_cap: u64, skipped_surface_unconfigured: u64, skipped_no_redraw: u64, + skipped_buffers_in_flight: u64, dropped_input_samples: u64, last_summary_at: Option, last_frame_pacing_summary_at: Option, @@ -386,7 +390,7 @@ fn log_input_summary(summary: &PerfSummary, final_summary: bool) { fn log_frame_pacing_summary(summary: &PerfFramePacingSummary, final_summary: bool) { info!( - "perf.frame_pacing frames={} window_frames={} render_p50_ms={} render_p95_ms={} render_p99_ms={} render_max_ms={} render_over_8ms={} render_over_16ms={} render_over_33ms={} render_over_50ms={} full_damage_count={} full_damage_pct={} force_full_reason={} force_full_reasons={} skipped_frame_callback_pending={} skipped_fps_cap={} skipped_surface_unconfigured={} skipped_no_redraw={} final={}", + "perf.frame_pacing frames={} window_frames={} render_p50_ms={} render_p95_ms={} render_p99_ms={} render_max_ms={} render_over_8ms={} render_over_16ms={} render_over_33ms={} render_over_50ms={} full_damage_count={} full_damage_pct={} force_full_reason={} force_full_reasons={} skipped_frame_callback_pending={} skipped_fps_cap={} skipped_surface_unconfigured={} skipped_no_redraw={} skipped_buffers_in_flight={} final={}", summary.frames, summary.window_frames, summary.render_p50_ms, @@ -405,6 +409,7 @@ fn log_frame_pacing_summary(summary: &PerfFramePacingSummary, final_summary: boo summary.skipped_fps_cap, summary.skipped_surface_unconfigured, summary.skipped_no_redraw, + summary.skipped_buffers_in_flight, final_summary ); } diff --git a/src/backend/wayland/state/perf_modules/metrics.rs b/src/backend/wayland/state/perf_modules/metrics.rs index 5e63defe..76854232 100644 --- a/src/backend/wayland/state/perf_modules/metrics.rs +++ b/src/backend/wayland/state/perf_modules/metrics.rs @@ -33,6 +33,7 @@ impl PerfMetrics { skipped_fps_cap: 0, skipped_surface_unconfigured: 0, skipped_no_redraw: 0, + skipped_buffers_in_flight: 0, dropped_input_samples: 0, last_summary_at: None, last_frame_pacing_summary_at: None, @@ -75,6 +76,9 @@ impl PerfMetrics { PerfRenderSkipReason::NoRedraw => { self.skipped_no_redraw += 1; } + PerfRenderSkipReason::BuffersInFlight => { + self.skipped_buffers_in_flight += 1; + } } } @@ -424,6 +428,7 @@ impl PerfMetrics { skipped_fps_cap: self.skipped_fps_cap, skipped_surface_unconfigured: self.skipped_surface_unconfigured, skipped_no_redraw: self.skipped_no_redraw, + skipped_buffers_in_flight: self.skipped_buffers_in_flight, } } @@ -445,6 +450,7 @@ impl PerfMetrics { self.skipped_fps_cap = 0; self.skipped_surface_unconfigured = 0; self.skipped_no_redraw = 0; + self.skipped_buffers_in_flight = 0; self.last_frame_pacing_summary_at = Some(now); } diff --git a/src/backend/wayland/state/perf_modules/metrics/tests.rs b/src/backend/wayland/state/perf_modules/metrics/tests.rs index 91aa4633..9915103c 100644 --- a/src/backend/wayland/state/perf_modules/metrics/tests.rs +++ b/src/backend/wayland/state/perf_modules/metrics/tests.rs @@ -249,6 +249,7 @@ fn frame_pacing_summary_reports_render_percentiles_and_skips() { metrics.record_render_skip(PerfRenderSkipReason::FpsCap); metrics.record_render_skip(PerfRenderSkipReason::SurfaceUnconfigured); metrics.record_render_skip(PerfRenderSkipReason::NoRedraw); + metrics.record_render_skip(PerfRenderSkipReason::BuffersInFlight); for frame in 0..SUMMARY_FRAME_INTERVAL { let started_at = base + Duration::from_millis(frame * 2); @@ -282,6 +283,7 @@ fn frame_pacing_summary_reports_render_percentiles_and_skips() { assert_eq!(summary.skipped_fps_cap, 1); assert_eq!(summary.skipped_surface_unconfigured, 1); assert_eq!(summary.skipped_no_redraw, 1); + assert_eq!(summary.skipped_buffers_in_flight, 1); assert_eq!(summary.render_over_50ms, 70); assert_eq!(summary.full_damage_count, 3); assert_eq!(summary.full_damage_pct, "2.50"); @@ -426,3 +428,32 @@ fn damage_percentage_clamps_to_surface_bounds() { 100 )); } + +/// Skip counters are per-window: a deferral counted in one summary must not +/// be carried into the next, or a running total hides whether the swapchain +/// bound is still engaging. +#[test] +fn frame_pacing_summary_resets_skip_counters_between_windows() { + let base = Instant::now(); + let mut metrics = PerfMetrics::new(true); + metrics.record_render_skip(PerfRenderSkipReason::BuffersInFlight); + + let mut summaries = Vec::new(); + for frame in 0..(SUMMARY_FRAME_INTERVAL * 2) { + let started_at = base + Duration::from_millis(frame * 2); + let report = metrics.record_render_complete( + started_at, + started_at + Duration::from_millis(1), + true, + 120, + false, + ); + if let Some(summary) = report.and_then(|report| report.summary) { + summaries.push(summary); + } + } + + assert_eq!(summaries.len(), 2); + assert_eq!(summaries[0].skipped_buffers_in_flight, 1); + assert_eq!(summaries[1].skipped_buffers_in_flight, 0); +} diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs index 804f437d..a93d428b 100644 --- a/src/backend/wayland/state/render/mod.rs +++ b/src/backend/wayland/state/render/mod.rs @@ -5,8 +5,24 @@ mod tool_preview; mod ui; mod ui_effect_damage; +/// What a render pass actually did. +/// +/// `BuffersInFlight` is not a frame: nothing was painted or committed, so the +/// caller must keep the redraw pending and leave the frame counters alone. +/// Discarding this silently would let a caller treat an uncommitted frame as +/// on-screen, so it is `#[must_use]`. +#[must_use] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::backend::wayland) enum RenderOutcome { + Committed { keep_rendering: bool }, + BuffersInFlight, +} + impl WaylandState { - pub(in crate::backend::wayland) fn render(&mut self, qh: &QueueHandle) -> Result { + pub(in crate::backend::wayland) fn render( + &mut self, + qh: &QueueHandle, + ) -> Result { debug!("=== RENDER START ==="); let board_is_transparent = self.input_state.board_is_transparent(); let suppression = self @@ -44,6 +60,40 @@ impl WaylandState { }}; } + // Acquire the buffer before anything mutates render state. Animation + // ticks and `collect_ui_effect_damage` both advance clocks and record + // previous bounds exactly once per rendered frame, so running them for + // a frame that is then deferred would let the next real commit miss an + // effect's on-screen footprint. + let acquired = record_stage!(buffer_acquire, { + self.surface.acquire_buffer( + &self.shm, + buffer_count, + phys_width as i32, + phys_height as i32, + (phys_width * 4) as i32, + )? + }); + let Some(acquired) = acquired else { + // Every slot is still owned by the compositor. Keep the redraw + // pending and retry on the next pass rather than painting over a + // buffer that is still on screen. + debug!("All {buffer_count} buffers in flight - deferring this frame"); + self.record_perf_render_skip(PerfRenderSkipReason::BuffersInFlight); + return Ok(RenderOutcome::BuffersInFlight); + }; + // The canvas pointer doubles as the slot identifier for damage + // tracking: a slot keeps its memory for the pool's lifetime, so the + // same pointer means the same slot. + let super::super::surface::AcquiredBuffer { + buffer, + canvas_ptr, + pool_generation: pool_gen, + pool_size, + } = acquired; + debug!("Buffer acquired from pool (slot ptr: 0x{:x})", canvas_ptr); + self.surface.update_pool_size(pool_size); + let now = Instant::now(); let ( highlight_active, @@ -71,9 +121,10 @@ impl WaylandState { self.update_ui_animation_tick(now, ui_animation_active); let keep_rendering = ui_animation_active && self.ui_animation_interval.is_none(); - // Add new dirty regions from input state to the per-buffer damage tracker. - // We do this BEFORE acquiring the buffer/damage so the current frame's changes - // are included in the damage for the current buffer. + // Add new dirty regions from input state to the per-buffer damage + // tracker. This runs after the buffer is acquired but before its damage + // is drained below, so the current frame's changes are included in the + // damage reported for this slot. let logical_width = width.min(i32::MAX as u32) as i32; let logical_height = height.min(i32::MAX as u32) as i32; let mut damage_diagnostics = PerfDamageDiagnostics::default(); @@ -121,36 +172,6 @@ impl WaylandState { } }); - // Get a buffer from the pool for rendering - let (buffer, canvas_ptr, pool_gen, pool_size) = record_stage!(buffer_acquire, { - let (pool, generation) = self.surface.ensure_pool(&self.shm, buffer_count)?; - debug!( - "Requesting buffer from pool (gen {}, size {})", - generation, - pool.len() - ); - let (buf, cvs) = pool - .create_buffer( - phys_width as i32, - phys_height as i32, - (phys_width * 4) as i32, - wl_shm::Format::Argb8888, - ) - .context("Failed to create buffer")?; - // Capture canvas pointer as stable slot identifier for damage tracking. - // SlotPool reuses the same memory regions, so this pointer identifies the slot. - let ptr = cvs.as_mut_ptr(); - let key = ptr as usize; - // Drop the slice borrow so we can query pool metadata; keep raw pointer for Cairo. - let _ = cvs; - let pool_size = pool.len(); - debug!("Buffer acquired from pool (slot ptr: 0x{:x})", key); - (buf, key, generation, pool_size) - }); - - // Record pool size after create_buffer to detect growth. - self.surface.update_pool_size(pool_size); - // Take damage for this buffer slot (identified by canvas memory address). // Pool identity (generation + size) is passed to detect pool recreation/growth. // SlotPool reuses the same memory regions for released buffers, so the @@ -436,7 +457,7 @@ impl WaylandState { if self.capture_suppressed() { self.capture.mark_preflight_rendered(); } - Ok(keep_rendering) + Ok(RenderOutcome::Committed { keep_rendering }) } fn render_force_full_damage_reason(&self) -> Option { diff --git a/src/backend/wayland/state/toolbar/events/session.rs b/src/backend/wayland/state/toolbar/events/session.rs index 0abcc7c9..57b82ba8 100644 --- a/src/backend/wayland/state/toolbar/events/session.rs +++ b/src/backend/wayland/state/toolbar/events/session.rs @@ -193,7 +193,23 @@ impl WaylandState { "another overlay operation is already active; try again after it finishes" )); } - if let Err(err) = self.flush_overlay_dialog_frame(conn, qh) { + let hidden = self + .flush_overlay_dialog_frame(conn, qh) + .and_then(|outcome| { + if dialog_frame_accepted(DialogFramePhase::Entry, outcome) { + Ok(()) + } else { + // The transparent frame was deferred, so the overlay's old + // pixels are still on screen. Starting the chooser now would + // let them sit over it - on Niri the overlay layer can cover + // the chooser outright. Roll back and make the user retry + // instead: nothing has been chosen yet, so nothing is lost. + Err(anyhow!( + "overlay buffers were still in flight; try again in a moment" + )) + } + }); + if let Err(err) = hidden { self.exit_overlay_suppression(OverlaySuppression::ExternalDialog); let _ = self.flush_overlay_dialog_frame(conn, qh); return Err(err).context("failed to hide overlay before opening session dialog"); @@ -207,25 +223,56 @@ impl WaylandState { qh: Option<&QueueHandle>, ) -> Result<()> { self.exit_overlay_suppression(OverlaySuppression::ExternalDialog); + // Restoration takes the opposite policy to entry: a deferred frame is + // fine here. The redraw stays pending and the event loop paints it, + // whereas failing would discard the file the user just chose. self.flush_overlay_dialog_frame(conn, qh) + .and_then(|outcome| { + if dialog_frame_accepted(DialogFramePhase::Restoration, outcome) { + Ok(()) + } else { + Err(anyhow!( + "overlay restoration frame was rejected by dialog policy" + )) + } + }) .context("failed to restore overlay after session dialog") } + /// Renders and flushes the overlay's dialog-suppression frame. + /// + /// Returns what the render actually did so each caller can apply its own + /// policy; with no surface or queue there is nothing to commit, which + /// counts as committed. fn flush_overlay_dialog_frame( &mut self, conn: Option<&Connection>, qh: Option<&QueueHandle>, - ) -> Result<()> { + ) -> Result { + let mut outcome = RenderOutcome::Committed { + keep_rendering: false, + }; if self.surface.is_configured() && let Some(qh) = qh { - self.render(qh)?; + // This frame makes the overlay's pixels transparent before a file + // dialog opens, and opaque again afterwards. + // + // Never block waiting for a slot here: a `Connection::roundtrip` + // only confirms the server processed our requests - it says + // nothing about `wl_buffer.release`, which arrives when the + // compositor stops using the buffer - and it has no timeout. + // Report the outcome instead and let the caller decide. + outcome = self.render(qh)?; + if let RenderOutcome::BuffersInFlight = outcome { + debug!("Overlay dialog frame deferred - all buffers still in flight"); + } } if let Some(conn) = conn { conn.flush() .map_err(|err| anyhow!("Wayland flush failed: {err}"))?; } - Ok(()) + Ok(outcome) } fn handle_toolbar_open_session( @@ -470,3 +517,73 @@ fn missing_session_error_matches_path(path: &Path, err: &AnyhowError) -> bool { .downcast_ref::() .is_some_and(|missing| catalog::session_paths_match(missing.path(), path)) } + +#[derive(Clone, Copy)] +enum DialogFramePhase { + Entry, + Restoration, +} + +/// Whether a dialog-suppression frame is acceptable at the given phase. +/// +/// Entry and restoration take deliberately opposite policies. Entry needs the +/// transparent frame on screen first: starting the chooser while the overlay's +/// old pixels are still up leaves them sitting over it, and on compositors +/// where the overlay maps to the overlay layer (Niri, Sway) they can cover the +/// chooser outright. Nothing is lost by refusing - the user has not chosen a +/// file yet. Restoration is the reverse: the file has been chosen, so a +/// deferred frame is accepted and the event loop repaints. +fn dialog_frame_accepted(phase: DialogFramePhase, outcome: RenderOutcome) -> bool { + match (phase, outcome) { + (DialogFramePhase::Entry, RenderOutcome::Committed { .. }) + | (DialogFramePhase::Restoration, RenderOutcome::Committed { .. }) + | (DialogFramePhase::Restoration, RenderOutcome::BuffersInFlight) => true, + (DialogFramePhase::Entry, RenderOutcome::BuffersInFlight) => false, + } +} + +#[cfg(test)] +mod tests { + use super::{DialogFramePhase, dialog_frame_accepted}; + use crate::backend::wayland::state::RenderOutcome; + + #[test] + fn dialog_entry_requires_a_committed_frame() { + assert!(dialog_frame_accepted( + DialogFramePhase::Entry, + RenderOutcome::Committed { + keep_rendering: false + } + )); + assert!(dialog_frame_accepted( + DialogFramePhase::Entry, + RenderOutcome::Committed { + keep_rendering: true + } + )); + assert!(!dialog_frame_accepted( + DialogFramePhase::Entry, + RenderOutcome::BuffersInFlight + )); + } + + #[test] + fn dialog_restoration_accepts_a_deferred_frame() { + assert!(dialog_frame_accepted( + DialogFramePhase::Restoration, + RenderOutcome::Committed { + keep_rendering: false + } + )); + assert!(dialog_frame_accepted( + DialogFramePhase::Restoration, + RenderOutcome::Committed { + keep_rendering: true + } + )); + assert!(dialog_frame_accepted( + DialogFramePhase::Restoration, + RenderOutcome::BuffersInFlight + )); + } +} diff --git a/src/backend/wayland/state/toolbar/inline/input.rs b/src/backend/wayland/state/toolbar/inline/input.rs index 4a7e1462..41021e5a 100644 --- a/src/backend/wayland/state/toolbar/inline/input.rs +++ b/src/backend/wayland/state/toolbar/inline/input.rs @@ -150,10 +150,14 @@ impl WaylandState { // annotation pixels during rapid pointer motion. self.mark_inline_toolbar_full_damage(); } else if was_top_hover != self.data.inline_top_hover { - // Preserve motion-driven tooltip timing without paying for a full - // swapchain refresh while the pointer stays on the same control. - self.toolbar.mark_dirty(); - self.input_state.needs_redraw = true; + // Same hit region, new pointer position - which still changes what + // is painted: hit regions are inflated to MIN_HIT_TARGET, so + // moving from that inflated margin onto the control itself keeps + // the same target while flipping the hover highlight, which paints + // against the visual rect. Keep the repaint scoped to the strip: + // setting needs_redraw with no damage rect would repaint the whole + // surface through the empty-damage fallback on every motion event. + self.mark_inline_toolbar_rect_damage(); } if over_toolbar { diff --git a/src/backend/wayland/state/toolbar/inline/mod.rs b/src/backend/wayland/state/toolbar/inline/mod.rs index 0a764caa..94a5850a 100644 --- a/src/backend/wayland/state/toolbar/inline/mod.rs +++ b/src/backend/wayland/state/toolbar/inline/mod.rs @@ -7,6 +7,27 @@ mod input; mod render; impl WaylandState { + /// Repaints just the inline top strip. + /// + /// Setting `needs_redraw` without any damage rect looks cheap but is the + /// opposite: the render pass falls back to `EmptyDamageFallback` and + /// repaints the whole surface. Damage the strip's own rect instead and + /// leave the canvas alone. + pub(in crate::backend::wayland) fn mark_inline_toolbar_rect_damage(&mut self) { + if let Some((x, y, w, h)) = self.data.inline_top_rect + && let Some(rect) = crate::util::Rect::new( + x.floor() as i32 - 1, + y.floor() as i32 - 1, + w.ceil() as i32 + 2, + h.ceil() as i32 + 2, + ) + { + self.input_state.dirty_tracker.mark_rect(rect); + } + self.toolbar.mark_dirty(); + self.input_state.needs_redraw = true; + } + pub(in crate::backend::wayland) fn mark_inline_toolbar_full_damage(&mut self) { self.input_state .dirty_tracker diff --git a/src/backend/wayland/surface.rs b/src/backend/wayland/surface.rs index 8e710934..84a7095e 100644 --- a/src/backend/wayland/surface.rs +++ b/src/backend/wayland/surface.rs @@ -8,13 +8,25 @@ use anyhow::{Context, Result}; use log::info; use smithay_client_toolkit::{ shell::{WaylandSurface, wlr_layer::LayerSurface, xdg::window::Window}, - shm::{Shm, slot::SlotPool}, + shm::{ + Shm, + slot::{Buffer, Slot, SlotPool}, + }, }; use wayland_client::{ Proxy, - protocol::{wl_output, wl_surface}, + protocol::{wl_output, wl_shm, wl_surface}, }; +/// A buffer handed out for one frame, plus the pool identity the damage +/// tracker needs to tell slot reuse from pool reallocation. +pub struct AcquiredBuffer { + pub buffer: Buffer, + pub canvas_ptr: usize, + pub pool_generation: u64, + pub pool_size: usize, +} + /// The active shell role for the surface. pub enum SurfaceKind { Layer(LayerSurface), @@ -66,6 +78,10 @@ pub struct SurfaceState { kind: Option, wl_surface: Option, pool: Option, + /// The `buffer_count` slots backing the swapchain, held for the pool's + /// lifetime so it stays bounded: a slot's memory only returns to the + /// pool's free list when the pool itself is dropped, never mid-frame. + slots: Vec, /// Generation counter incremented when pool is recreated. /// Used by damage tracker to detect pool reallocation. pool_generation: u64, @@ -86,6 +102,7 @@ impl SurfaceState { kind: None, wl_surface: None, pool: None, + slots: Vec::new(), pool_generation: 0, pool_size: 0, current_output: None, @@ -102,8 +119,7 @@ impl SurfaceState { self.wl_surface = Some(surface.wl_surface().clone()); self.kind = Some(SurfaceKind::Layer(surface)); // A new shell surface invalidates current buffer resources/state. - self.pool = None; - self.pool_size = 0; + self.drop_pool(); self.current_output = None; self.configured = false; self.frame_callbacks.clear(); @@ -114,8 +130,7 @@ impl SurfaceState { self.wl_surface = Some(window.wl_surface().clone()); self.kind = Some(SurfaceKind::Xdg { window }); // A new shell surface invalidates current buffer resources/state. - self.pool = None; - self.pool_size = 0; + self.drop_pool(); self.current_output = None; self.configured = false; self.frame_callbacks.clear(); @@ -180,8 +195,7 @@ impl SurfaceState { self.width = width; self.height = height; if changed { - self.pool = None; - self.pool_size = 0; + self.drop_pool(); } changed } @@ -191,8 +205,7 @@ impl SurfaceState { let scale = scale.max(1); if self.scale != scale { self.scale = scale; - self.pool = None; - self.pool_size = 0; + self.drop_pool(); if let Some(layer_surface) = self.layer_surface_mut() { let _ = layer_surface.set_buffer_scale(scale as u32); } else if let Some(wl_surface) = self.wl_surface() { @@ -277,37 +290,101 @@ impl SurfaceState { grew } + /// Releases the pool and every slot held for it. + /// + /// Dropping the slots is what returns their memory to the pool's free + /// list, so this must run whenever the pool itself is replaced. + fn drop_pool(&mut self) { + self.pool = None; + self.pool_size = 0; + self.slots.clear(); + } + /// Ensures a shared memory pool of the appropriate size exists. /// - /// Returns the pool and the current generation counter. The generation is - /// incremented when a new pool is created, which can be used to detect when - /// damage tracking should be reset (all previous canvas pointers become invalid). - pub fn ensure_pool(&mut self, shm: &Shm, buffer_count: usize) -> Result<(&mut SlotPool, u64)> { - if self.pool.is_none() { - let (phys_w, phys_h) = self.physical_dimensions(); - let buffer_size = (phys_w * phys_h * 4) as usize; - let initial_pool_size = buffer_size * buffer_count; - info!( - "Creating new SlotPool ({}x{} @ scale {}, {} bytes, {} buffers, gen {})", - phys_w, - phys_h, - self.scale, - initial_pool_size, - buffer_count, - self.pool_generation + 1 - ); - let pool = - SlotPool::new(initial_pool_size, shm).context("Failed to create slot pool")?; - self.pool_size = pool.len(); - self.pool = Some(pool); - self.pool_generation += 1; + /// The generation counter is incremented when a new pool is created, which + /// lets the damage tracker detect pool reallocation (all previous canvas + /// pointers become invalid). + fn ensure_pool(&mut self, shm: &Shm, buffer_count: usize, slot_len: usize) -> Result<()> { + if self.pool.is_some() { + return Ok(()); } + let (phys_w, phys_h) = self.physical_dimensions(); + let initial_pool_size = slot_len * buffer_count; + info!( + "Creating new SlotPool ({}x{} @ scale {}, {} bytes, {} buffers, gen {})", + phys_w, + phys_h, + self.scale, + initial_pool_size, + buffer_count, + self.pool_generation + 1 + ); + let pool = SlotPool::new(initial_pool_size, shm).context("Failed to create slot pool")?; + self.pool_size = pool.len(); + self.pool = Some(pool); + self.pool_generation += 1; + self.slots.clear(); + Ok(()) + } - let generation = self.pool_generation; - self.pool + /// Hands out a buffer for this frame, or `None` while the compositor still + /// owns every slot. + /// + /// The pool holds exactly `buffer_count` slots for its whole lifetime, and + /// a slot is only drawn into while it has no active buffers - that is, + /// after the compositor sent `wl_buffer.release` for the frame that used + /// it last. Allocating a fresh slot per frame instead would let sctk grow + /// the pool without bound whenever rendering outruns the compositor, which + /// no-vsync rendering (especially `max_fps_no_vsync = 0`) does easily. + pub fn acquire_buffer( + &mut self, + shm: &Shm, + buffer_count: usize, + width: i32, + height: i32, + stride: i32, + ) -> Result> { + let buffer_count = buffer_count.max(1); + // sctk rounds slot lengths up to 64 bytes; size the pool the same way + // so the last slot does not trigger a growth on the first frame. + let slot_len = ((height as usize) * (stride as usize)).next_multiple_of(64); + // Slots are never dropped individually: an in-flight buffer still + // references its slot, so clearing them piecemeal would strand that + // memory and let the next allocation grow the pool. Outgrowing the + // slots rebuilds the pool wholesale instead, which resets the damage + // tracker through the generation counter. + if self.slots.iter().any(|slot| slot.len() < slot_len) { + self.drop_pool(); + } + self.ensure_pool(shm, buffer_count, slot_len)?; + + let pool_generation = self.pool_generation; + let Self { pool, slots, .. } = self; + let pool = pool .as_mut() - .map(|p| (p, generation)) - .context("Buffer pool not initialized despite previous check") + .context("Buffer pool not initialized despite previous check")?; + + for _ in slots.len()..buffer_count { + slots.push(pool.new_slot(slot_len).context("Failed to allocate slot")?); + } + + let Some(slot) = slots.iter().find(|slot| !slot.has_active_buffers()) else { + return Ok(None); + }; + + let buffer = pool + .create_buffer_in(slot, width, height, stride, wl_shm::Format::Argb8888) + .context("Failed to create buffer")?; + let canvas_ptr = pool.raw_data_mut(slot).as_mut_ptr() as usize; + let pool_size = pool.len(); + + Ok(Some(AcquiredBuffer { + buffer, + canvas_ptr, + pool_generation, + pool_size, + })) } }