srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-07-23 22:56:00 +0200
committersrdusr <[email protected]>2026-07-23 22:56:00 +0200
commit8ed79c21a53e10c29ea35b9a605250ea18f191cf (patch)
tree22fbfc5a9348c7449a0b831e8066020a5faa68a4 /crates
parentc3a34348b39c9518ad2e5bb28569fb9d78ce5dc1 (diff)
downloadsrdwm-8ed79c21a53e10c29ea35b9a605250ea18f191cf.tar.gz
srdwm-8ed79c21a53e10c29ea35b9a605250ea18f191cf.zip
Do not draw a window before its client has painted anything
Reported as "before a window spawns, the border corners look funny". A toplevel is placed, sized and decorated the moment its role is created, which is well before the client draws. srdwm was rendering it from that moment, so what appeared first was an empty frame: border, titlebar and shadow standing around bare desktop, at the guessed 800x600 placeholder size, with nothing inside. When the real buffer arrived the frame snapped to the real size. Measured in a nested session, capturing a cold terminal's spawn with grim: four consecutive captured frames spanning 540ms showed a complete red border with zero client content inside it, at 642px outer height, which then settled at 610 - a jump of exactly one TITLEBAR_HEIGHT. After this change the same capture has no such frame at all: every frame that shows a border shows content in it, and the height does not change afterward. Two parts: - Nothing is drawn for a window that has never committed a buffer. All five paths that draw a frame agree on this - both udev render loops (Pixman and GPU), the winit render loop, and both screencopy paths, so a screenshot cannot show a frame the screen does not. - The open-slide starts at the first commit that carries a buffer rather than at role creation. A cold terminal took ~800ms to paint, long enough for the whole tween to finish against the empty frame, so the window simply appeared, already at rest, with no animation at all. It now animates where it can actually be seen. The answer latches once true (windows_shown_once), so a window that has legitimately shown something is never hidden again by this however its buffer state changes. A window that cannot be resolved to a surface counts as drawable, deliberately: this hides a window only on positive evidence that it has never drawn, so nothing whose surface plumbing works differently - an XWayland window - can be hidden by a lookup that did not apply to it. Same shape, and the same reason, as sync_layer_visibility's own has_buffer branch, which layer surfaces have had all along. 533 tests pass, clippy clean.
Diffstat (limited to 'crates')
-rw-r--r--crates/wayland/src/protocols/compositor.rs18
-rw-r--r--crates/wayland/src/state/geometry.rs35
-rw-r--r--crates/wayland/src/state/lifecycle.rs20
-rw-r--r--crates/wayland/src/state/mod.rs23
-rw-r--r--crates/wayland/src/udev/capture.rs5
-rw-r--r--crates/wayland/src/udev/platform.rs1
-rw-r--r--crates/wayland/src/udev/render.rs13
-rw-r--r--crates/wayland/src/winit/capture.rs5
-rw-r--r--crates/wayland/src/winit/connect.rs1
-rw-r--r--crates/wayland/src/winit/render.rs4
10 files changed, 112 insertions, 13 deletions
diff --git a/crates/wayland/src/protocols/compositor.rs b/crates/wayland/src/protocols/compositor.rs
index e2868e4..7be5d7e 100644
--- a/crates/wayland/src/protocols/compositor.rs
+++ b/crates/wayland/src/protocols/compositor.rs
@@ -89,6 +89,24 @@ impl CompositorHandler for CompState {
// own first real size a chance to adopt it from what `on_commit`
// just recomputed, rather than keep rendering/configuring
// against the guessed placeholder for one more round-trip.
+ // The first commit that actually carries a buffer is when this
+ // window becomes visible, so it is also when the open-slide
+ // should start - see `windows_shown_once`. Registered here
+ // rather than in `new_managed_window` because a role is created
+ // well before a client paints (measured at ~800ms for a cold
+ // terminal), which is long enough for the whole tween to finish
+ // against an empty frame and for the window to simply appear,
+ // already at rest, with no animation at all.
+ if !self.windows_shown_once.contains(&id) && self.window_has_content(id) {
+ self.windows_shown_once.insert(id);
+ let mut wm = self.wm.borrow_mut();
+ if wm.animations_enabled {
+ if let Some(win) = wm.window_mut(id) {
+ let g = win.geometry;
+ win.anim_from = Some(srdwm_core::Rect { y: g.y + crate::state::OPEN_SLIDE_OFFSET, ..g });
+ }
+ }
+ }
self.adopt_provisional_size(id);
// See `content_epoch`'s doc comment: this is the only per-commit
// signal the udev backend's rounded-corner mask cache has to
diff --git a/crates/wayland/src/state/geometry.rs b/crates/wayland/src/state/geometry.rs
index 5cbece9..d3c7ab8 100644
--- a/crates/wayland/src/state/geometry.rs
+++ b/crates/wayland/src/state/geometry.rs
@@ -314,6 +314,41 @@ impl CompState {
(x, y)
}
+ /// True when this window has something on screen to draw a frame
+ /// around - it has committed a buffer at least once.
+ ///
+ /// A toplevel is placed and decorated the moment its role is created,
+ /// which is well before the client paints. Drawing it then puts a
+ /// border, a titlebar and a shadow around bare desktop, at the guessed
+ /// placeholder size (`Window::size_is_provisional`), and that empty
+ /// frame then jumps when the real buffer arrives at the real size.
+ ///
+ /// A window that cannot be resolved to a surface at all counts as
+ /// drawable, deliberately: this hides a window only on positive
+ /// evidence that it has never drawn, so nothing whose surface plumbing
+ /// works differently (an XWayland window, say) can be hidden by a
+ /// lookup that simply did not apply to it.
+ ///
+ /// See `windows_shown_once` for why the answer latches once true.
+ ///
+ /// Takes the two maps rather than `&self` so a render loop can call it
+ /// while it already holds `self.udev` mutably borrowed.
+ pub(crate) fn has_content(shown_once: &HashSet<WindowId>, id_to_window: &HashMap<WindowId, DWindow>, id: WindowId) -> bool {
+ if shown_once.contains(&id) {
+ return true;
+ }
+ let Some(surface) = id_to_window.get(&id).and_then(crate::elements::window_wl_surface) else { return true };
+ smithay::backend::renderer::utils::with_renderer_surface_state(
+ &surface,
+ |state: &mut smithay::backend::renderer::utils::RendererSurfaceState| state.buffer().is_some(),
+ )
+ .unwrap_or(false)
+ }
+
+ pub(crate) fn window_has_content(&self, id: WindowId) -> bool {
+ Self::has_content(&self.windows_shown_once, &self.id_to_window, id)
+ }
+
pub(crate) fn effective_frame(&self, id: WindowId, geom: srdwm_core::Rect) -> srdwm_core::Rect {
Self::effective_frame_of(&self.wm, &self.id_to_window, &self.pending_size_configure, id, geom)
}
diff --git a/crates/wayland/src/state/lifecycle.rs b/crates/wayland/src/state/lifecycle.rs
index 163064e..b6c7f09 100644
--- a/crates/wayland/src/state/lifecycle.rs
+++ b/crates/wayland/src/state/lifecycle.rs
@@ -19,18 +19,13 @@ impl CompState {
if wm.window(id).is_some_and(|w| w.size_is_provisional) {
self.provisional_size.insert(id);
}
- // Starts the open-slide tween (see `WindowAnim`'s doc comment):
- // the window's first `sync_geometry` call below will see this,
- // register the tween, and place it here - a few pixels below
- // its resting position - rather than jumping straight to
- // `geometry`. Same size throughout, so no extra client configure
- // is needed for the tween itself.
- if wm.animations_enabled {
- if let Some(win) = wm.window_mut(id) {
- let g = win.geometry;
- win.anim_from = Some(srdwm_core::Rect { y: g.y + OPEN_SLIDE_OFFSET, ..g });
- }
- }
+ // The open-slide tween is NOT started here, deliberately.
+ // A toplevel role exists well before its client paints
+ // anything, so starting it here ran the animation against an
+ // empty frame and left the window simply appearing, already at
+ // rest. `CompositorHandler::commit` starts it at the first
+ // commit that carries a buffer instead - see
+ // `windows_shown_once`.
id
};
@@ -383,6 +378,7 @@ impl CompState {
self.shadow_buffers.remove(&id);
self.border_side_buffers.remove(&id);
self.decoration_signatures.remove(&id);
+ self.windows_shown_once.remove(&id);
self.last_synced_size.remove(&id);
self.content_epoch.remove(&id);
self.rounded_content_buffers.remove(&id);
diff --git a/crates/wayland/src/state/mod.rs b/crates/wayland/src/state/mod.rs
index 5c1b02e..44ef772 100644
--- a/crates/wayland/src/state/mod.rs
+++ b/crates/wayland/src/state/mod.rs
@@ -461,6 +461,27 @@ pub(crate) struct CompState {
/// this set is ever consulted, so a surface only reaches the unmap path
/// once it has legitimately shown something.
pub(crate) layer_surfaces_shown_once: HashSet<WlSurface>,
+ /// Windows whose surface has committed a buffer at least once.
+ ///
+ /// A toplevel exists, and is placed and decorated, from the moment its
+ /// role is created - which is well before the client has drawn
+ /// anything. Rendering it at that point paints a border and a titlebar
+ /// around empty desktop: an empty frame stands there on its own, then
+ /// snaps to a different size once the real buffer arrives and the
+ /// guessed `800x600` placeholder (`Window::size_is_provisional`) is
+ /// replaced. Measured in a nested session: the frame was drawn ~800ms
+ /// before any content, one full `TITLEBAR_HEIGHT` too tall, which is
+ /// what "the border corners look funny before a window spawns" is.
+ ///
+ /// So this gates two things: nothing is drawn for a window that has
+ /// never had a buffer, and the open-slide starts at the first buffer
+ /// rather than at role creation, so the animation plays where it can
+ /// actually be seen instead of finishing against an empty frame.
+ ///
+ /// Same shape, and the same reason, as `layer_surfaces_shown_once`
+ /// above: a window that has legitimately shown something once is never
+ /// hidden again by this, however its buffer state changes afterward.
+ pub(crate) windows_shown_once: HashSet<WindowId>,
pub(crate) decorations: HashMap<WindowId, MemoryRenderBuffer>,
/// The top border strip's rounded-corner bitmap, cached the same way
/// and at the same trigger points as `decorations` (built in
@@ -787,7 +808,7 @@ impl WindowAnim {
/// resize tween is reserved for maximize/fullscreen, where the client is
/// already live and redrawing, not for a window whose first paint may not
/// have arrived yet).
-const OPEN_SLIDE_OFFSET: i32 = 24;
+pub(crate) const OPEN_SLIDE_OFFSET: i32 = 24;
/// A held keybinding that is firing repeatedly.
///
diff --git a/crates/wayland/src/udev/capture.rs b/crates/wayland/src/udev/capture.rs
index a64a73c..287c003 100644
--- a/crates/wayland/src/udev/capture.rs
+++ b/crates/wayland/src/udev/capture.rs
@@ -69,6 +69,11 @@ impl CompState {
let Some(udev) = self.udev.as_mut() else { return Err("no udev backend".to_string()) };
let mut elements: Vec<crate::elements::OverlayElement<PixmanRenderer>> = Vec::new();
for id in ids {
+ // Matches the render loops: a capture must not show a frame the
+ // screen does not (see `window_has_content`).
+ if !Self::has_content(&self.windows_shown_once, &self.id_to_window, id) {
+ continue;
+ }
let Some(w) = self.id_to_window.get(&id) else { continue };
let Some(surface) = crate::input::dwindow_wl_surface(w) else { continue };
let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { continue };
diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs
index 8dad7c3..86307dc 100644
--- a/crates/wayland/src/udev/platform.rs
+++ b/crates/wayland/src/udev/platform.rs
@@ -256,6 +256,7 @@ impl UdevPlatform {
dead_layer_surfaces: HashSet::new(),
hidden_layer_surfaces: HashMap::new(),
layer_surfaces_shown_once: HashSet::new(),
+ windows_shown_once: HashSet::new(),
decorations: HashMap::new(),
border_top_decorations: HashMap::new(),
border_bottom_decorations: HashMap::new(),
diff --git a/crates/wayland/src/udev/render.rs b/crates/wayland/src/udev/render.rs
index 9992b2c..c2899ae 100644
--- a/crates/wayland/src/udev/render.rs
+++ b/crates/wayland/src/udev/render.rs
@@ -303,6 +303,14 @@ impl CompState {
let mut elements: Vec<crate::elements::OverlayElement<smithay::backend::renderer::gles::GlesRenderer>> =
crate::cursor::render_elements(&cursor_status, &cursor_buffers, &mut gpu.renderer, udev.pointer_pos, origin, udev.heads[index].size);
for &id in &ids {
+ // Nothing is drawn for a window that has never committed a
+ // buffer - otherwise its border, titlebar and shadow
+ // stand around empty desktop until the client paints,
+ // then jump when the placeholder size is replaced. See
+ // `window_has_content`.
+ if !Self::has_content(&self.windows_shown_once, &self.id_to_window, id) {
+ continue;
+ }
let Some(w) = self.wm.borrow().window(id).cloned() else { continue };
let Some(dwindow) = self.id_to_window.get(&id) else { continue };
let Some(surface) = crate::elements::window_wl_surface(dwindow) else { continue };
@@ -541,6 +549,11 @@ impl CompState {
// window is stacked in front.
let mut occluders: Vec<srdwm_core::Rect> = Vec::with_capacity(ids.len());
for &id in &ids {
+ // See the GPU loop above: a window with no buffer yet has
+ // nothing for a frame to go around.
+ if !Self::has_content(&self.windows_shown_once, &self.id_to_window, id) {
+ continue;
+ }
let Some(w) = self.wm.borrow().window(id).cloned() else { continue };
// `w.geometry` is the animation's *target*, not
// necessarily where the window is actually drawn this
diff --git a/crates/wayland/src/winit/capture.rs b/crates/wayland/src/winit/capture.rs
index 3813559..6e03dd8 100644
--- a/crates/wayland/src/winit/capture.rs
+++ b/crates/wayland/src/winit/capture.rs
@@ -98,6 +98,11 @@ impl WaylandPlatform {
let mut occluders: Vec<srdwm_core::Rect> = Vec::new();
let focused = self.wm.borrow().focused_id();
for id in self.wm.borrow().visible_windows_front_to_back().map(|w| w.id).collect::<Vec<_>>() {
+ // Matches the render loops: a capture must not show a frame the
+ // screen does not (see `window_has_content`).
+ if !crate::state::CompState::has_content(&self.state.windows_shown_once, &self.state.id_to_window, id) {
+ continue;
+ }
let Some(w) = self.wm.borrow().window(id).cloned() else { continue };
if let Some(deco) = self.state.decorations.get(&id) {
if let Ok(elem) = MemoryRenderBufferRenderElement::from_buffer(renderer, (w.geometry.x as f64, w.geometry.y as f64), deco, None, None, None, Kind::Unspecified) {
diff --git a/crates/wayland/src/winit/connect.rs b/crates/wayland/src/winit/connect.rs
index aa59d9d..cca991c 100644
--- a/crates/wayland/src/winit/connect.rs
+++ b/crates/wayland/src/winit/connect.rs
@@ -168,6 +168,7 @@ impl WaylandPlatform {
dead_layer_surfaces: HashSet::new(),
hidden_layer_surfaces: HashMap::new(),
layer_surfaces_shown_once: HashSet::new(),
+ windows_shown_once: HashSet::new(),
decorations: HashMap::new(),
border_top_decorations: HashMap::new(),
border_bottom_decorations: HashMap::new(),
diff --git a/crates/wayland/src/winit/render.rs b/crates/wayland/src/winit/render.rs
index 432ebba..d49351f 100644
--- a/crates/wayland/src/winit/render.rs
+++ b/crates/wayland/src/winit/render.rs
@@ -236,6 +236,10 @@ impl WaylandPlatform {
let monitor_bounds: Vec<srdwm_core::Rect> = self.wm.borrow().monitors().iter().map(|m| m.full_geometry).collect();
let mut occluders: Vec<srdwm_core::Rect> = Vec::with_capacity(ids.len());
for id in ids {
+ // See `window_has_content`: no buffer yet means no frame yet.
+ if !crate::state::CompState::has_content(&self.state.windows_shown_once, &self.state.id_to_window, id) {
+ continue;
+ }
let Some(w) = self.wm.borrow().window(id).cloned() else { continue };
// `w.geometry` is the animation's target, not necessarily where
// the window is actually drawn this frame - see the matching