Skip to content

Fix Wayland layout shift from wl-copy dummy windows#243

Open
paperbenni wants to merge 1 commit intomainfrom
fix-wl-copy-layout-shift-757461423317328864
Open

Fix Wayland layout shift from wl-copy dummy windows#243
paperbenni wants to merge 1 commit intomainfrom
fix-wl-copy-layout-shift-757461423317328864

Conversation

@paperbenni
Copy link
Copy Markdown
Member

@paperbenni paperbenni commented Mar 21, 2026

The previous GTK window visibility fix caused wl-copy to break the layout by spawning a tiny tiled dummy window.

When an initial send_configure is fired, wl-copy (and potentially other clipboard utilities) responds by attaching a 1x1 transparent buffer to an XDG toplevel. It does this solely to gain keyboard focus. Because instantWM previously treated this as a standard tiled window, it disrupted the layout momentarily before the tool finished and exited.

This pull request resolves the problem in two ways:

  1. Adds a hardcoded rule in src/client/rules.rs to immediately set any window with class/title "wl-clipboard" to floating.
  2. Adds a dynamic fallback in src/backend/wayland/compositor/handlers.rs that checks the size of the initial committed buffer via with_renderer_surface_state. If the buffer dimensions are <= 1x1, it marks the window as floating.

Floating windows bypass the tiling arrangement algorithm, so wl-copy can now gain focus and work correctly without causing any layout flicker.


PR created automatically by Jules for task 757461423317328864 started by @paperbenni

Summary by Sourcery

Prevent tiny Wayland clipboard helper windows from disrupting tiling layouts by treating them as floating.

Bug Fixes:

  • Avoid layout shifts caused by wl-clipboard (wl-copy) dummy windows being tiled as normal toplevels.

Enhancements:

  • Automatically float new toplevels whose initial committed buffer is 1x1 or smaller to avoid transient dummy windows affecting the layout.

Summary by CodeRabbit

  • Improvements
    • Minimal-sized windows are now automatically treated as floating to improve workspace organization and prevent layout disruption.
    • Clipboard utility windows are now treated as floating by default for better usability and seamless clipboard operations.

The recent fix for GTK windows (sending an initial configure unconditionally) caused clipboard utilities like `wl-copy` to respond by committing a tiny 1x1 dummy buffer to gain keyboard focus. Because this dummy buffer was treated as a normal tiled window, it momentarily participated in the layout and caused a spurious layout shift before exiting.

This patch fixes the issue by explicitly floating `wl-clipboard` windows in `apply_rules`, which prevents them from being tiled. It also adds a fallback check in `handlers.rs` to automatically float any window that commits a dummy buffer size of 1x1 or smaller, ensuring similar utilities don't cause layout shifts in the future.

Co-authored-by: paperbenni <15818888+paperbenni@users.noreply.github.com>
@google-labs-jules
Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai bot commented Mar 21, 2026

Reviewer's Guide

Marks wl-clipboard dummy Wayland toplevels as floating to prevent tiny 1x1 focus windows from disrupting the tiling layout, using both a static client rule and a dynamic size-based fallback when mapping new toplevels.

Sequence diagram for Wayland toplevel mapping with dummy wl-clipboard window floating

sequenceDiagram
    participant Client as WlClipboardClient
    participant Compositor as WaylandState
    participant Surface as WlSurface
    participant Renderer as RendererSurfaceState
    participant Globals as Globals

    Client->>Compositor: create xdg_toplevel (wl-clipboard dummy)
    Client->>Surface: commit 1x1 transparent buffer

    Compositor->>Surface: check has_buffer
    Surface-->>Compositor: has_buffer = true

    Compositor->>Renderer: with_renderer_surface_state(surface)
    Renderer-->>Compositor: surface_size = (w <= 1, h <= 1)

    Compositor->>Compositor: toplevel = pending_toplevels.swap_remove(pos)
    Compositor->>Compositor: win = map_new_toplevel(toplevel)

    alt surface_size <= 1x1
        Compositor->>Globals: globals_mut()
        Globals-->>Compositor: Some(globals)
        Compositor->>Globals: clients.get_mut(win)
        Globals-->>Compositor: Some(client)
        Compositor->>Globals: client.is_floating = true
    end

    Compositor->>Client: focus dummy floating window
    Client->>Compositor: clipboard operation completes
    Client->>Compositor: destroy dummy window
Loading

Class diagram for Globals, client rules, and Wayland toplevel floating logic

classDiagram
    class Globals {
        +HashMap~WindowId, Client~ clients
        +Behavior behavior
        +Option~Globals~ globals_mut()
    }

    class Behavior {
        +bool specialnext
    }

    class Client {
        +String name
        +bool is_floating
    }

    class WindowProperties {
        +String class
        +String title
    }

    class WindowId

    class WaylandState {
        +Vec~Toplevel~ pending_toplevels
        +Option~Globals~ globals_mut()
        +WindowId map_new_toplevel(Toplevel toplevel)
    }

    class Toplevel

    class SurfaceState {
        +Option~Size~ surface_size()
    }

    class Size {
        +i32 w
        +i32 h
    }

    class RendererSurfaceUtils {
        +static with_renderer_surface_state(Surface surface, fn callback)
    }

    class Surface

    Globals "1" --> "*" Client : clients
    Globals "1" --> "1" Behavior : behavior
    WaylandState "1" --> "*" Toplevel : pending_toplevels
    WaylandState ..> Globals : uses globals_mut
    WaylandState ..> RendererSurfaceUtils : uses with_renderer_surface_state
    RendererSurfaceUtils ..> SurfaceState : callback receives
    SurfaceState --> Size : surface_size

    class RulesModule {
        +apply_rules(Globals g, WindowId win, WindowProperties props)
    }

    RulesModule ..> Globals : modifies clients
    RulesModule ..> WindowProperties : reads class, title
    RulesModule ..> WindowId
    RulesModule ..> Client : sets is_floating

    %% apply_rules logic
    class ApplyRulesLogic {
        +void apply_rules(Globals g, WindowId win, WindowProperties props)
    }

    ApplyRulesLogic ..> Client : set name from title
    ApplyRulesLogic ..> Client : set is_floating when wl_clipboard
Loading

File-Level Changes

Change Details Files
Add a dynamic compositor-level fallback that marks newly mapped toplevels with an initial 1x1 (or smaller) buffer as floating instead of tiled.
  • Compute the initial committed surface size via with_renderer_surface_state when handling a toplevel with a buffer
  • Map the pending toplevel and capture its WindowId from map_new_toplevel
  • If the initial width and height are both <= 1, look up the corresponding client in globals and set its is_floating flag to true
src/backend/wayland/compositor/handlers.rs
Add a static client rule to always float wl-clipboard windows based on their class or title.
  • Extend apply_rules to detect windows whose class or title equals "wl-clipboard"
  • Immediately mark such clients as floating by setting is_floating = true during rule application
src/client/rules.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 21, 2026

📝 Walkthrough

Walkthrough

Two modifications implement floating window detection: in handlers.rs, windows with dimensions ≤1 pixel are marked floating based on renderer surface queries; in rules.rs, windows with class or title "wl-clipboard" are unconditionally marked floating during client initialization.

Changes

Cohort / File(s) Summary
Surface Size Detection
src/backend/wayland/compositor/handlers.rs
Captures window ID from toplevel promotion and queries renderer surface dimensions. Marks client as floating if surface width and height are both ≤1 pixel.
Clipboard Window Handling
src/client/rules.rs
Forces windows with class or title matching "wl-clipboard" to be floating by setting is_floating = true during initial client field initialization, independent of other rule logic.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 Tiny windows float so free,
Clipboard panes just wanna be,
Untethered from the grid below,
A graceful, drifting, feathered flow!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix Wayland layout shift from wl-copy dummy windows' directly addresses the main problem being solved: preventing layout disruption caused by wl-copy's 1×1 transparent buffer windows.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-wl-copy-layout-shift-757461423317328864

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • You now have two separate mechanisms to special-case dummy windows (hardcoded wl-clipboard rule and the 1x1-size heuristic); consider centralizing this logic (e.g. a helper or a dedicated predicate) to avoid divergence in behavior over time.
  • The size-based heuristic uses <= 1x1 with unwrap_or((0, 0)), which conflates ‘no size yet’ and ‘dummy 1x1 window’; if that distinction matters, you might want to treat None or 0x0 separately from an explicit 1x1 buffer.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- You now have two separate mechanisms to special-case dummy windows (hardcoded wl-clipboard rule and the 1x1-size heuristic); consider centralizing this logic (e.g. a helper or a dedicated predicate) to avoid divergence in behavior over time.
- The size-based heuristic uses `<= 1x1` with `unwrap_or((0, 0))`, which conflates ‘no size yet’ and ‘dummy 1x1 window’; if that distinction matters, you might want to treat `None` or `0x0` separately from an explicit 1x1 buffer.

## Individual Comments

### Comment 1
<location path="src/backend/wayland/compositor/handlers.rs" line_range="85-86" />
<code_context>
                 })
                 .unwrap_or(false);
+
+            let (w, h) = smithay::backend::renderer::utils::with_renderer_surface_state(surface, |state| {
+                state.surface_size().map(|s| (s.w, s.h)).unwrap_or((0, 0))
+            }).unwrap_or((0, 0));
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Avoid treating an unknown surface size as 0x0 to prevent misclassifying normal windows as dummy ones.

`unwrap_or((0, 0))` makes an unknown size indistinguishable from a real 0x0 surface. As a result, any surface whose size isn’t known yet will appear as `(w, h) = (0, 0)` and may be treated as a dummy window (`w <= 1 && h <= 1`), incorrectly forcing real windows into floating mode. Please propagate `Option<(u32, u32)>` (or equivalent) and only apply the dummy-window heuristic when the size is known and small.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +85 to +86
let (w, h) = smithay::backend::renderer::utils::with_renderer_surface_state(surface, |state| {
state.surface_size().map(|s| (s.w, s.h)).unwrap_or((0, 0))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Avoid treating an unknown surface size as 0x0 to prevent misclassifying normal windows as dummy ones.

unwrap_or((0, 0)) makes an unknown size indistinguishable from a real 0x0 surface. As a result, any surface whose size isn’t known yet will appear as (w, h) = (0, 0) and may be treated as a dummy window (w <= 1 && h <= 1), incorrectly forcing real windows into floating mode. Please propagate Option<(u32, u32)> (or equivalent) and only apply the dummy-window heuristic when the size is known and small.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/backend/wayland/compositor/handlers.rs`:
- Around line 85-102: The dummy-window detection currently treats unknown
surface sizes as (0,0) by unwrapping the result of with_renderer_surface_state,
which can misclassify real windows; change the logic so you call
with_renderer_surface_state and inspect the Option returned by
state.surface_size() directly and only run the `if w <= 1 && h <= 1 { ... }`
block when that Option is Some((w,h)); keep the early flow (checking has_buffer,
swap_remove on self.pending_toplevels, map_new_toplevel) the same but move or
guard the floating-flag mutation (access via self.globals_mut() and
g.clients.get_mut(&win).is_floating) behind a check that surface_size returned
Some, rather than treating None as (0,0).

In `@src/client/rules.rs`:
- Around line 35-39: The wl-clipboard floating flag is currently reversible
because later rule processing can set RuleFloat::Tiled; change the wl-clipboard
special-case so it not only sets c.is_floating = true but also marks this
decision as final so subsequent rule application skips overriding it (e.g., set
a new boolean like c.rule_floating_override = true or set c.rule_floating =
Some(RuleFloat::FloatingFinal)); then update the rule-application logic that
applies RuleFloat (the code that inspects RuleFloat::Tiled/Floating) to respect
that override flag and no-op when the override is present.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d1c1aa5b-31fa-4a22-95a7-1fed1aa5a33f

📥 Commits

Reviewing files that changed from the base of the PR and between 2969143 and fa5de47.

📒 Files selected for processing (2)
  • src/backend/wayland/compositor/handlers.rs
  • src/client/rules.rs

Comment on lines +85 to +102
let (w, h) = smithay::backend::renderer::utils::with_renderer_surface_state(surface, |state| {
state.surface_size().map(|s| (s.w, s.h)).unwrap_or((0, 0))
}).unwrap_or((0, 0));

if has_buffer {
let toplevel = self.pending_toplevels.swap_remove(pos);
let _ = self.map_new_toplevel(toplevel);
let win = self.map_new_toplevel(toplevel);

// If the requested dimensions are 1x1 or 0x0, it's likely a dummy
// window (e.g. some clipboard tools use this to gain focus).
// Force it to float so it doesn't cause a layout shift.
if w <= 1 && h <= 1 {
if let Some(g) = self.globals_mut() {
if let Some(client) = g.clients.get_mut(&win) {
client.is_floating = true;
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Smithay, under what conditions can with_renderer_surface_state(surface, ...)orstate.surface_size()returnNoneduring a commit wherestate.buffer().is_some() is true?

💡 Result:

In Smithay, with_renderer_surface_state(surface, ...) returns None if the RendererSurfaceStateUserData is not present in the surface's SurfaceData data_map. This user data is typically inserted by the compositor during surface commit processing if a buffer is attached and successfully imported into a texture by the renderer (via ImportAll trait methods like import_shm_buffer, import_egl_buffer, etc.). Even if state.buffer.is_some is true (indicating a valid buffer attachment), the user data (and thus the RendererSurfaceState) may be absent if: - The compositor failed to retrieve or insert the RendererSurfaceStateUserData during commit (e.g., due to a rendering/import error not propagated as a protocol error). - The surface state was not fully processed for rendering in this commit cycle (e.g., custom compositor logic skips renderer state update despite buffer presence). - The buffer import failed silently or the texture creation errored, preventing state population. Similarly, state.surface_size returns None if the SurfaceView is invalid or missing, which occurs if the surface has no valid destination geometry defined (e.g., unset or zero-sized window geometry from shell protocols like xdg_shell during initial commits before configure acknowledgment). The SurfaceView relies on committed surface attributes (size from buffer or shell geometry), so during commits where state.buffer.is_some but the surface lacks mapped geometry (e.g., pre-configure), data.view? fails, leading to None. These conditions are evident during Wayland surface commits where a buffer is attached (SurfaceAttributes.buffer.is_some), but rendering state or geometry is incomplete. Smithay's WaylandSurfaceRenderElement::from_surface explicitly returns None without the user data or if data.view fails, mirroring these checks. Compositors must handle None by skipping rendering for that surface in the frame.

Citations:


🏁 Script executed:

# First, let's view the exact code at lines 85-102
sed -n '75,110p' src/backend/wayland/compositor/handlers.rs | cat -n

Repository: instantOS/instantWM

Length of output: 1807


🏁 Script executed:

# Let's also check the broader context to understand has_buffer and map_new_toplevel
sed -n '50,120p' src/backend/wayland/compositor/handlers.rs | cat -n

Repository: instantOS/instantWM

Length of output: 3247


🏁 Script executed:

# Check if there's any validation of surface state before the problematic code
rg -B 10 "with_renderer_surface_state" src/backend/wayland/compositor/handlers.rs | head -50

Repository: instantOS/instantWM

Length of output: 740


Avoid coercing unknown surface size to (0, 0) in dummy-window detection.

Even when has_buffer is true, state.surface_size() can return None if the surface lacks valid destination geometry (e.g., during pre-configure state or before window geometry is set). Lines 36–38 treat this case as (0, 0), causing line 47 to misclassify real windows as dummy windows.

The heuristic should only apply when size is actually known:

Proposed fix
-            let (w, h) = smithay::backend::renderer::utils::with_renderer_surface_state(surface, |state| {
-                state.surface_size().map(|s| (s.w, s.h)).unwrap_or((0, 0))
-            }).unwrap_or((0, 0));
+            let surface_size =
+                smithay::backend::renderer::utils::with_renderer_surface_state(surface, |state| {
+                    state.surface_size().map(|s| (s.w, s.h))
+                })
+                .flatten();
 
             if has_buffer {
                 let toplevel = self.pending_toplevels.swap_remove(pos);
                 let win = self.map_new_toplevel(toplevel);
 
                 // If the requested dimensions are 1x1 or 0x0, it's likely a dummy
                 // window (e.g. some clipboard tools use this to gain focus).
                 // Force it to float so it doesn't cause a layout shift.
-                if w <= 1 && h <= 1 {
+                if let Some((w, h)) = surface_size
+                    && w <= 1
+                    && h <= 1
+                {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/wayland/compositor/handlers.rs` around lines 85 - 102, The
dummy-window detection currently treats unknown surface sizes as (0,0) by
unwrapping the result of with_renderer_surface_state, which can misclassify real
windows; change the logic so you call with_renderer_surface_state and inspect
the Option returned by state.surface_size() directly and only run the `if w <= 1
&& h <= 1 { ... }` block when that Option is Some((w,h)); keep the early flow
(checking has_buffer, swap_remove on self.pending_toplevels, map_new_toplevel)
the same but move or guard the floating-flag mutation (access via
self.globals_mut() and g.clients.get_mut(&win).is_floating) behind a check that
surface_size returned Some, rather than treating None as (0,0).

Comment on lines +35 to +39
// wl-clipboard (wl-copy) creates dummy windows just to get keyboard focus.
// Float them immediately so they don't cause layout shifts.
if props.class == "wl-clipboard" || props.title == "wl-clipboard" {
c.is_floating = true;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

wl-clipboard floating override can be undone by later rules.

Line 37-Line 39 sets is_floating = true, but later matched rules can reset it to tiled (RuleFloat::Tiled), so this is not a guaranteed override.

💡 Proposed fix (make the override final)
 pub fn apply_rules(g: &mut Globals, win: WindowId, props: &WindowProperties) {
+    let force_wl_clipboard_float =
+        props.class == "wl-clipboard" || props.title == "wl-clipboard";
+
     // --- Initialise fields we are about to set -------------------------------
     if let Some(c) = g.clients.get_mut(&win) {
-        c.is_floating = false;
+        c.is_floating = force_wl_clipboard_float;
         c.tags = 0;
         // Also update the client name from properties if it's not empty
         if !props.title.is_empty() {
             c.name = props.title.clone();
         }
-        // wl-clipboard (wl-copy) creates dummy windows just to get keyboard focus.
-        // Float them immediately so they don't cause layout shifts.
-        if props.class == "wl-clipboard" || props.title == "wl-clipboard" {
-            c.is_floating = true;
-        }
     }
@@
     }
 
+    // Keep wl-clipboard windows floating even if later rules requested tiled.
+    if force_wl_clipboard_float
+        && let Some(c) = g.clients.get_mut(&win)
+    {
+        c.is_floating = true;
+    }
+
     // --- Clamp tags to the valid tag mask ------------------------------------
     clamp_client_tags(g, win, tag_mask);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/rules.rs` around lines 35 - 39, The wl-clipboard floating flag is
currently reversible because later rule processing can set RuleFloat::Tiled;
change the wl-clipboard special-case so it not only sets c.is_floating = true
but also marks this decision as final so subsequent rule application skips
overriding it (e.g., set a new boolean like c.rule_floating_override = true or
set c.rule_floating = Some(RuleFloat::FloatingFinal)); then update the
rule-application logic that applies RuleFloat (the code that inspects
RuleFloat::Tiled/Floating) to respect that override flag and no-op when the
override is present.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant