git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit9ae6814eba10e0c80cdd78c0ddb440739562119c
parent4e773315a5
authorIsaac Freund <[email protected]>
date2025-11-19 14:59
layer-shell: implement protocol

This commit adds a new river-layer-shell-v1 protocol that exposes the
necessary window management information from wlr-layer-shell-v1 to the
window manager.

This is an optional extension for window managers, a window manager does
not have to support layer shell. If the window manager chooses not to
support layer shell, all layer surfaces are closed immediately.

 build.zig                                |   5 +
 protocol/river-layer-shell-v1.xml        | 182 ++++++++++++++
 protocol/wlr-layer-shell-unstable-v1.xml | 407 +++++++++++++++++++++++++++++++
 river/Cursor.zig                         |  27 +-
 river/IdleInhibitManager.zig             |   2 +-
 river/InputPopup.zig                     |   1 +
 river/LayerShell.zig                     | 206 ++++++++++++++++
 river/LayerShellOutput.zig               | 190 +++++++++++++++
 river/LayerShellSeat.zig                 | 109 +++++++++
 river/LayerSurface.zig                   | 188 ++++++++++++++
 river/Output.zig                         |  28 ++-
 river/Scene.zig                          |  11 +
 river/SceneNodeData.zig                  |   2 +
 river/Seat.zig                           |  78 ++++--
 river/Server.zig                         |   7 +
 15 files changed, 1401 insertions(+), 42 deletions(-)

diff --git a/build.zig b/build.zig
index 3b56e34..9c03e39 100644
--- a/build.zig
+++ b/build.zig
@@ -78,6 +78,9 @@ pub fn build(b: *Build) !void {
 
     scanner.addCustomProtocol(b.path("protocol/river-window-management-v1.xml"));
     scanner.addCustomProtocol(b.path("protocol/river-xkb-bindings-v1.xml"));
+    scanner.addCustomProtocol(b.path("protocol/river-layer-shell-v1.xml"));
+
+    scanner.addCustomProtocol(b.path("protocol/wlr-layer-shell-unstable-v1.xml"));
     scanner.addCustomProtocol(b.path("protocol/wlr-output-power-management-unstable-v1.xml"));
 
     // Some of these versions may be out of date with what wlroots implements.
@@ -104,8 +107,10 @@ pub fn build(b: *Build) !void {
 
     scanner.generate("river_window_manager_v1", 1);
     scanner.generate("river_xkb_bindings_v1", 1);
+    scanner.generate("river_layer_shell_v1", 1);
 
     scanner.generate("zwlr_output_power_manager_v1", 1);
+    scanner.generate("zwlr_layer_shell_v1", 4);
 
     const wayland = b.createModule(.{ .root_source_file = scanner.result });
 
diff --git a/protocol/river-layer-shell-v1.xml b/protocol/river-layer-shell-v1.xml
new file mode 100644
index 0000000..8959131
--- /dev/null
+++ b/protocol/river-layer-shell-v1.xml
@@ -0,0 +1,182 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="river_layer_shell_v1">
+  <copyright>
+    Copyright 2025 The River Developers
+
+    Permission to use, copy, modify, and/or distribute this software for any
+    purpose with or without fee is hereby granted, provided that the above
+    copyright notice and this permission notice appear in all copies.
+
+    THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+    WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+    MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+    SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+    WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
+    OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+    CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+  </copyright>
+
+  <description summary="layer shell support for river">
+    This protocol allows the river-window-management-v1 window manager to
+    support the wlr-layer-shell-v1 protocol.
+
+    The key words "must", "must not", "required", "shall", "shall not",
+    "should", "should not", "recommended", "may", and "optional" in this
+    document are to be interpreted as described in IETF RFC 2119.
+
+    Warning! The protocol described in this file is currently in the testing
+    phase. Backward compatible changes may be added together with the
+    corresponding interface version bump. Backward incompatible changes can only
+    be done by creating a new major version of the extension.
+  </description>
+
+  <interface name="river_layer_shell_v1" version="1">
+    <description summary="river layer shell global interface">
+      This global interface should only be advertised to the client if the
+      river_window_manager_v1 global is also advertised. Binding this interface
+      indicates that the window manager supports layer shell.
+
+      If the window manager does not bind this interface, the compositor should
+      not allow clients to map layer surfaces. This can be achieved by
+      closing layer surfaces immediately.
+    </description>
+
+    <enum name="error">
+      <entry name="object_already_created" value="0"
+        sumary="the layer_shell_output/seat object was already created."/>
+    </enum>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the river_layer_shell_v1 object">
+        This request indicates that the client will no longer use the
+        river_layer_shell_v1 object.
+      </description>
+    </request>
+
+    <request name="get_output">
+      <description summary="get layer shell output state">
+        It is a protocol error to make this request more than once for a given
+        river_output_v1 object.
+      </description>
+      <arg name="id" type="new_id" interface="river_layer_shell_output_v1"/>
+      <arg name="output" type="object" interface="river_output_v1"/>
+    </request>
+
+    <request name="get_seat">
+      <description summary="get layer shell seat state">
+        It is a protocol error to make this request more than once for a given
+        river_seat_v1 object.
+      </description>
+      <arg name="id" type="new_id" interface="river_layer_shell_seat_v1"/>
+      <arg name="seat" type="object" interface="river_seat_v1"/>
+    </request>
+  </interface>
+
+  <interface name="river_layer_shell_output_v1" version="1">
+    <description summary="layer shell output state">
+      The lifetime of this object is tied to the corresponding river_output_v1.
+      This object is made inert when the river_output_v1.removed event is sent
+      and should be destroyed.
+    </description>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the object">
+        This request indicates that the client will no longer use the
+        river_layer_shell_output_v1 object and that it may be safely destroyed.
+
+        This request should be made after the river_output_v1.removed event is
+        received to complete destruction of the output.
+      </description>
+    </request>
+
+    <event name="non_exclusive_area">
+      <description summary="area left after subtracting exclusive zones">
+        This event indicates the area of the output remaining after subtracting
+        the exclusive zones of layer surfaces. Exclusive zones are a hint, the
+        window manager is free to ignore this area hint if it wishes.
+
+        This event will be followed by a manage_start event after all other new
+        state has been sent by the server.
+      </description>
+      <arg name="x" type="int"/>
+      <arg name="y" type="int"/>
+      <arg name="width" type="int"/>
+      <arg name="height" type="int"/>
+    </event>
+
+    <request name="set_default">
+      <description summary="Set default output for layer surfaces">
+        Mark this output as the default for new layer surfaces which do not
+        request a specific output themselves. This request overrides any
+        previous set_default request on any river_layer_shell_output_v1 object.
+
+        If no set_default request is made or if the default output is destroyed,
+        the default output is undefined until the next set_default request.
+
+        This request modifies window management state and may only be made as
+        part of a manage sequence, see the river_window_manager_v1 description.
+      </description>
+    </request>
+  </interface>
+
+  <interface name="river_layer_shell_seat_v1" version="1">
+    <description summary="layer shell seat state">
+      The lifetime of this object is tied to the corresponding river_seat_v1.
+      This object is made inert when the river_seat_v1.removed event is sent and
+      should be destroyed.
+    </description>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the object">
+        This request indicates that the client will no longer use the
+        river_layer_shell_seat_v1 object and that it may be safely destroyed.
+
+        This request should be made after the river_seat_v1.removed event is
+        received to complete destruction of the seat.
+      </description>
+    </request>
+
+    <event name="focus_exclusive">
+      <description summary="">
+        A layer shell surface will be given exclusive keyboard focus at the end
+        of the manage sequence in which this event is sent. The window manager
+        may want to update window decorations or similar to indicate that no
+        window is focused.
+
+        Until the focus_non_exclusive or focus_none event is sent, all window
+        manager requests to change focus are ignored.
+
+        This event will be followed by a manage_start event after all other new
+        state has been sent by the server.
+      </description>
+    </event>
+
+    <event name="focus_non_exclusive">
+      <description summary="">
+        A layer shell surface will be given non-exclusive keyboard focus at the
+        end of the manage sequence in which this event is sent. The window
+        manager may want to update window decorations or similar to indicate
+        that no window is focused.
+
+        The window manager continues to control focus and may choose to focus a
+        different window/shell surface at any time. If the window manager sets
+        focus during the same manage sequence in which this event is sent, the
+        layer surface will not be focused.
+
+        This event will be followed by a manage_start event after all other new
+        state has been sent by the server.
+      </description>
+    </event>
+
+    <event name="focus_none">
+      <description summary="">
+        No layer shell surface will have keyboard focus at the end of the manage
+        sequence in which this event is sent. The window manager may want to
+        return focus to whichever window last had focus, for example.
+
+        This event will be followed by a manage_start event after all other new
+        state has been sent by the server.
+      </description>
+    </event>
+  </interface>
+</protocol>
diff --git a/protocol/wlr-layer-shell-unstable-v1.xml b/protocol/wlr-layer-shell-unstable-v1.xml
new file mode 100644
index 0000000..e9f27e4
--- /dev/null
+++ b/protocol/wlr-layer-shell-unstable-v1.xml
@@ -0,0 +1,407 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="wlr_layer_shell_unstable_v1">
+  <copyright>
+    Copyright © 2017 Drew DeVault
+
+    Permission to use, copy, modify, distribute, and sell this
+    software and its documentation for any purpose is hereby granted
+    without fee, provided that the above copyright notice appear in
+    all copies and that both that copyright notice and this permission
+    notice appear in supporting documentation, and that the name of
+    the copyright holders not be used in advertising or publicity
+    pertaining to distribution of the software without specific,
+    written prior permission.  The copyright holders make no
+    representations about the suitability of this software for any
+    purpose.  It is provided "as is" without express or implied
+    warranty.
+
+    THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
+    SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+    FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
+    SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+    WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+    AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+    ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+    THIS SOFTWARE.
+  </copyright>
+
+  <interface name="zwlr_layer_shell_v1" version="5">
+    <description summary="create surfaces that are layers of the desktop">
+      Clients can use this interface to assign the surface_layer role to
+      wl_surfaces. Such surfaces are assigned to a "layer" of the output and
+      rendered with a defined z-depth respective to each other. They may also be
+      anchored to the edges and corners of a screen and specify input handling
+      semantics. This interface should be suitable for the implementation of
+      many desktop shell components, and a broad number of other applications
+      that interact with the desktop.
+    </description>
+
+    <request name="get_layer_surface">
+      <description summary="create a layer_surface from a surface">
+        Create a layer surface for an existing surface. This assigns the role of
+        layer_surface, or raises a protocol error if another role is already
+        assigned.
+
+        Creating a layer surface from a wl_surface which has a buffer attached
+        or committed is a client error, and any attempts by a client to attach
+        or manipulate a buffer prior to the first layer_surface.configure call
+        must also be treated as errors.
+
+        After creating a layer_surface object and setting it up, the client
+        must perform an initial commit without any buffer attached.
+        The compositor will reply with a layer_surface.configure event.
+        The client must acknowledge it and is then allowed to attach a buffer
+        to map the surface.
+
+        You may pass NULL for output to allow the compositor to decide which
+        output to use. Generally this will be the one that the user most
+        recently interacted with.
+
+        Clients can specify a namespace that defines the purpose of the layer
+        surface.
+      </description>
+      <arg name="id" type="new_id" interface="zwlr_layer_surface_v1"/>
+      <arg name="surface" type="object" interface="wl_surface"/>
+      <arg name="output" type="object" interface="wl_output" allow-null="true"/>
+      <arg name="layer" type="uint" enum="layer" summary="layer to add this surface to"/>
+      <arg name="namespace" type="string" summary="namespace for the layer surface"/>
+    </request>
+
+    <enum name="error">
+      <entry name="role" value="0" summary="wl_surface has another role"/>
+      <entry name="invalid_layer" value="1" summary="layer value is invalid"/>
+      <entry name="already_constructed" value="2" summary="wl_surface has a buffer attached or committed"/>
+    </enum>
+
+    <enum name="layer">
+      <description summary="available layers for surfaces">
+        These values indicate which layers a surface can be rendered in. They
+        are ordered by z depth, bottom-most first. Traditional shell surfaces
+        will typically be rendered between the bottom and top layers.
+        Fullscreen shell surfaces are typically rendered at the top layer.
+        Multiple surfaces can share a single layer, and ordering within a
+        single layer is undefined.
+      </description>
+
+      <entry name="background" value="0"/>
+      <entry name="bottom" value="1"/>
+      <entry name="top" value="2"/>
+      <entry name="overlay" value="3"/>
+    </enum>
+
+    <!-- Version 3 additions -->
+
+    <request name="destroy" type="destructor" since="3">
+      <description summary="destroy the layer_shell object">
+        This request indicates that the client will not use the layer_shell
+        object any more. Objects that have been created through this instance
+        are not affected.
+      </description>
+    </request>
+  </interface>
+
+  <interface name="zwlr_layer_surface_v1" version="5">
+    <description summary="layer metadata interface">
+      An interface that may be implemented by a wl_surface, for surfaces that
+      are designed to be rendered as a layer of a stacked desktop-like
+      environment.
+
+      Layer surface state (layer, size, anchor, exclusive zone,
+      margin, interactivity) is double-buffered, and will be applied at the
+      time wl_surface.commit of the corresponding wl_surface is called.
+
+      Attaching a null buffer to a layer surface unmaps it.
+
+      Unmapping a layer_surface means that the surface cannot be shown by the
+      compositor until it is explicitly mapped again. The layer_surface
+      returns to the state it had right after layer_shell.get_layer_surface.
+      The client can re-map the surface by performing a commit without any
+      buffer attached, waiting for a configure event and handling it as usual.
+    </description>
+
+    <request name="set_size">
+      <description summary="sets the size of the surface">
+        Sets the size of the surface in surface-local coordinates. The
+        compositor will display the surface centered with respect to its
+        anchors.
+
+        If you pass 0 for either value, the compositor will assign it and
+        inform you of the assignment in the configure event. You must set your
+        anchor to opposite edges in the dimensions you omit; not doing so is a
+        protocol error. Both values are 0 by default.
+
+        Size is double-buffered, see wl_surface.commit.
+      </description>
+      <arg name="width" type="uint"/>
+      <arg name="height" type="uint"/>
+    </request>
+
+    <request name="set_anchor">
+      <description summary="configures the anchor point of the surface">
+        Requests that the compositor anchor the surface to the specified edges
+        and corners. If two orthogonal edges are specified (e.g. 'top' and
+        'left'), then the anchor point will be the intersection of the edges
+        (e.g. the top left corner of the output); otherwise the anchor point
+        will be centered on that edge, or in the center if none is specified.
+
+        Anchor is double-buffered, see wl_surface.commit.
+      </description>
+      <arg name="anchor" type="uint" enum="anchor"/>
+    </request>
+
+    <request name="set_exclusive_zone">
+      <description summary="configures the exclusive geometry of this surface">
+        Requests that the compositor avoids occluding an area with other
+        surfaces. The compositor's use of this information is
+        implementation-dependent - do not assume that this region will not
+        actually be occluded.
+
+        A positive value is only meaningful if the surface is anchored to one
+        edge or an edge and both perpendicular edges. If the surface is not
+        anchored, anchored to only two perpendicular edges (a corner), anchored
+        to only two parallel edges or anchored to all edges, a positive value
+        will be treated the same as zero.
+
+        A positive zone is the distance from the edge in surface-local
+        coordinates to consider exclusive.
+
+        Surfaces that do not wish to have an exclusive zone may instead specify
+        how they should interact with surfaces that do. If set to zero, the
+        surface indicates that it would like to be moved to avoid occluding
+        surfaces with a positive exclusive zone. If set to -1, the surface
+        indicates that it would not like to be moved to accommodate for other
+        surfaces, and the compositor should extend it all the way to the edges
+        it is anchored to.
+
+        For example, a panel might set its exclusive zone to 10, so that
+        maximized shell surfaces are not shown on top of it. A notification
+        might set its exclusive zone to 0, so that it is moved to avoid
+        occluding the panel, but shell surfaces are shown underneath it. A
+        wallpaper or lock screen might set their exclusive zone to -1, so that
+        they stretch below or over the panel.
+
+        The default value is 0.
+
+        Exclusive zone is double-buffered, see wl_surface.commit.
+      </description>
+      <arg name="zone" type="int"/>
+    </request>
+
+    <request name="set_margin">
+      <description summary="sets a margin from the anchor point">
+        Requests that the surface be placed some distance away from the anchor
+        point on the output, in surface-local coordinates. Setting this value
+        for edges you are not anchored to has no effect.
+
+        The exclusive zone includes the margin.
+
+        Margin is double-buffered, see wl_surface.commit.
+      </description>
+      <arg name="top" type="int"/>
+      <arg name="right" type="int"/>
+      <arg name="bottom" type="int"/>
+      <arg name="left" type="int"/>
+    </request>
+
+    <enum name="keyboard_interactivity">
+      <description summary="types of keyboard interaction possible for a layer shell surface">
+        Types of keyboard interaction possible for layer shell surfaces. The
+        rationale for this is twofold: (1) some applications are not interested
+        in keyboard events and not allowing them to be focused can improve the
+        desktop experience; (2) some applications will want to take exclusive
+        keyboard focus.
+      </description>
+
+      <entry name="none" value="0">
+        <description summary="no keyboard focus is possible">
+          This value indicates that this surface is not interested in keyboard
+          events and the compositor should never assign it the keyboard focus.
+
+          This is the default value, set for newly created layer shell surfaces.
+
+          This is useful for e.g. desktop widgets that display information or
+          only have interaction with non-keyboard input devices.
+        </description>
+      </entry>
+      <entry name="exclusive" value="1">
+        <description summary="request exclusive keyboard focus">
+          Request exclusive keyboard focus if this surface is above the shell surface layer.
+
+          For the top and overlay layers, the seat will always give
+          exclusive keyboard focus to the top-most layer which has keyboard
+          interactivity set to exclusive. If this layer contains multiple
+          surfaces with keyboard interactivity set to exclusive, the compositor
+          determines the one receiving keyboard events in an implementation-
+          defined manner. In this case, no guarantee is made when this surface
+          will receive keyboard focus (if ever).
+
+          For the bottom and background layers, the compositor is allowed to use
+          normal focus semantics.
+
+          This setting is mainly intended for applications that need to ensure
+          they receive all keyboard events, such as a lock screen or a password
+          prompt.
+        </description>
+      </entry>
+      <entry name="on_demand" value="2" since="4">
+        <description summary="request regular keyboard focus semantics">
+          This requests the compositor to allow this surface to be focused and
+          unfocused by the user in an implementation-defined manner. The user
+          should be able to unfocus this surface even regardless of the layer
+          it is on.
+
+          Typically, the compositor will want to use its normal mechanism to
+          manage keyboard focus between layer shell surfaces with this setting
+          and regular toplevels on the desktop layer (e.g. click to focus).
+          Nevertheless, it is possible for a compositor to require a special
+          interaction to focus or unfocus layer shell surfaces (e.g. requiring
+          a click even if focus follows the mouse normally, or providing a
+          keybinding to switch focus between layers).
+
+          This setting is mainly intended for desktop shell components (e.g.
+          panels) that allow keyboard interaction. Using this option can allow
+          implementing a desktop shell that can be fully usable without the
+          mouse.
+        </description>
+      </entry>
+    </enum>
+
+    <request name="set_keyboard_interactivity">
+      <description summary="requests keyboard events">
+        Set how keyboard events are delivered to this surface. By default,
+        layer shell surfaces do not receive keyboard events; this request can
+        be used to change this.
+
+        This setting is inherited by child surfaces set by the get_popup
+        request.
+
+        Layer surfaces receive pointer, touch, and tablet events normally. If
+        you do not want to receive them, set the input region on your surface
+        to an empty region.
+
+        Keyboard interactivity is double-buffered, see wl_surface.commit.
+      </description>
+      <arg name="keyboard_interactivity" type="uint" enum="keyboard_interactivity"/>
+    </request>
+
+    <request name="get_popup">
+      <description summary="assign this layer_surface as an xdg_popup parent">
+        This assigns an xdg_popup's parent to this layer_surface.  This popup
+        should have been created via xdg_surface::get_popup with the parent set
+        to NULL, and this request must be invoked before committing the popup's
+        initial state.
+
+        See the documentation of xdg_popup for more details about what an
+        xdg_popup is and how it is used.
+      </description>
+      <arg name="popup" type="object" interface="xdg_popup"/>
+    </request>
+
+    <request name="ack_configure">
+      <description summary="ack a configure event">
+        When a configure event is received, if a client commits the
+        surface in response to the configure event, then the client
+        must make an ack_configure request sometime before the commit
+        request, passing along the serial of the configure event.
+
+        If the client receives multiple configure events before it
+        can respond to one, it only has to ack the last configure event.
+
+        A client is not required to commit immediately after sending
+        an ack_configure request - it may even ack_configure several times
+        before its next surface commit.
+
+        A client may send multiple ack_configure requests before committing, but
+        only the last request sent before a commit indicates which configure
+        event the client really is responding to.
+      </description>
+      <arg name="serial" type="uint" summary="the serial from the configure event"/>
+    </request>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the layer_surface">
+        This request destroys the layer surface.
+      </description>
+    </request>
+
+    <event name="configure">
+      <description summary="suggest a surface change">
+        The configure event asks the client to resize its surface.
+
+        Clients should arrange their surface for the new states, and then send
+        an ack_configure request with the serial sent in this configure event at
+        some point before committing the new surface.
+
+        The client is free to dismiss all but the last configure event it
+        received.
+
+        The width and height arguments specify the size of the window in
+        surface-local coordinates.
+
+        The size is a hint, in the sense that the client is free to ignore it if
+        it doesn't resize, pick a smaller size (to satisfy aspect ratio or
+        resize in steps of NxM pixels). If the client picks a smaller size and
+        is anchored to two opposite anchors (e.g. 'top' and 'bottom'), the
+        surface will be centered on this axis.
+
+        If the width or height arguments are zero, it means the client should
+        decide its own window dimension.
+      </description>
+      <arg name="serial" type="uint"/>
+      <arg name="width" type="uint"/>
+      <arg name="height" type="uint"/>
+    </event>
+
+    <event name="closed">
+      <description summary="surface should be closed">
+        The closed event is sent by the compositor when the surface will no
+        longer be shown. The output may have been destroyed or the user may
+        have asked for it to be removed. Further changes to the surface will be
+        ignored. The client should destroy the resource after receiving this
+        event, and create a new surface if they so choose.
+      </description>
+    </event>
+
+    <enum name="error">
+      <entry name="invalid_surface_state" value="0" summary="provided surface state is invalid"/>
+      <entry name="invalid_size" value="1" summary="size is invalid"/>
+      <entry name="invalid_anchor" value="2" summary="anchor bitfield is invalid"/>
+      <entry name="invalid_keyboard_interactivity" value="3" summary="keyboard interactivity is invalid"/>
+      <entry name="invalid_exclusive_edge" value="4" summary="exclusive edge is invalid given the surface anchors"/>
+    </enum>
+
+    <enum name="anchor" bitfield="true">
+      <entry name="top" value="1" summary="the top edge of the anchor rectangle"/>
+      <entry name="bottom" value="2" summary="the bottom edge of the anchor rectangle"/>
+      <entry name="left" value="4" summary="the left edge of the anchor rectangle"/>
+      <entry name="right" value="8" summary="the right edge of the anchor rectangle"/>
+    </enum>
+
+    <!-- Version 2 additions -->
+
+    <request name="set_layer" since="2">
+      <description summary="change the layer of the surface">
+        Change the layer that the surface is rendered on.
+
+        Layer is double-buffered, see wl_surface.commit.
+      </description>
+      <arg name="layer" type="uint" enum="zwlr_layer_shell_v1.layer" summary="layer to move this surface to"/>
+    </request>
+
+    <!-- Version 5 additions -->
+
+    <request name="set_exclusive_edge" since="5">
+      <description summary="set the edge the exclusive zone will be applied to">
+        Requests an edge for the exclusive zone to apply. The exclusive
+        edge will be automatically deduced from anchor points when possible,
+        but when the surface is anchored to a corner, it will be necessary
+        to set it explicitly to disambiguate, as it is not possible to deduce
+        which one of the two corner edges should be used.
+
+        The edge must be one the surface is anchored to, otherwise the
+        invalid_exclusive_edge protocol error will be raised.
+      </description>
+      <arg name="edge" type="uint" enum="anchor"/>
+    </request>
+  </interface>
+</protocol>
diff --git a/river/Cursor.zig b/river/Cursor.zig
index b9ac020..fb0caaf 100644
--- a/river/Cursor.zig
+++ b/river/Cursor.zig
@@ -370,7 +370,7 @@ pub fn processMotionRelative(cursor: *Cursor, event: *const wlr.Pointer.event.Mo
 }
 
 fn updateHovered(cursor: *Cursor) void {
-    const old = cursor.seat.wm_scheduled.window;
+    const old = cursor.seat.wm_scheduled.hovered;
     if (server.scene.at(cursor.wlr_cursor.x, cursor.wlr_cursor.y)) |result| {
         switch (result.data) {
             .window => |window| {
@@ -379,28 +379,30 @@ fn updateHovered(cursor: *Cursor) void {
                         // Exclude input regions of the toplevel that extend beyond the window
                         if (result.surface != null and result.surface.?.getRootSurface() == toplevel.wlr_toplevel.base.surface) {
                             if (window.box.containsPoint(cursor.wlr_cursor.x, cursor.wlr_cursor.y)) {
-                                cursor.seat.wm_scheduled.window = window.ref;
+                                cursor.seat.wm_scheduled.hovered = window.ref;
                             }
                         } else {
-                            cursor.seat.wm_scheduled.window = window.ref;
+                            cursor.seat.wm_scheduled.hovered = window.ref;
                         }
                     },
                     .xwayland => cursor.seat.wm_scheduled.window = window.ref,
                     .destroying => {},
                 }
             },
-            .shell_surface, .lock_surface => cursor.seat.wm_scheduled.window = null,
+            .shell_surface, .lock_surface, .layer_surface => {
+                cursor.seat.wm_scheduled.hovered = null;
+            },
             .override_redirect => {
                 assert(build_options.xwayland);
                 assert(server.xwayland != null);
-                cursor.seat.wm_scheduled.window = null;
+                cursor.seat.wm_scheduled.hovered = null;
             },
         }
     } else {
-        cursor.seat.wm_scheduled.window = null;
+        cursor.seat.wm_scheduled.hovered = null;
     }
 
-    if (cursor.seat.wm_scheduled.window != old) {
+    if (cursor.seat.wm_scheduled.hovered != old) {
         server.wm.dirtyWindowing();
     }
 }
@@ -554,6 +556,17 @@ fn interact(cursor: Cursor, result: Scene.AtResult) void {
             assert(server.lock_manager.state != .unlocked);
             cursor.seat.focus(.{ .lock_surface = lock_surface });
         },
+        .layer_surface => |layer_surface| {
+            switch (cursor.seat.layer_shell.scheduled.focus) {
+                .none, .non_exclusive => {
+                    cursor.seat.layer_shell.scheduled.focus = .{
+                        .non_exclusive = layer_surface.ref,
+                    };
+                    server.wm.dirtyWindowing();
+                },
+                .exclusive => {},
+            }
+        },
         .override_redirect => |override_redirect| {
             assert(server.lock_manager.state != .locked);
             override_redirect.focusIfDesired();
diff --git a/river/IdleInhibitManager.zig b/river/IdleInhibitManager.zig
index ca7e01f..287fcaa 100644
--- a/river/IdleInhibitManager.zig
+++ b/river/IdleInhibitManager.zig
@@ -58,7 +58,7 @@ pub fn checkActive(inhibit_manager: *IdleInhibitManager) void {
                 inhibited = true; // XXX be strict
                 break;
             },
-            .shell_surface, .lock_surface, .override_redirect => {
+            .shell_surface, .lock_surface, .layer_surface, .override_redirect => {
                 inhibited = true;
                 break;
             },
diff --git a/river/InputPopup.zig b/river/InputPopup.zig
index 8fc3dd9..d9448a3 100644
--- a/river/InputPopup.zig
+++ b/river/InputPopup.zig
@@ -112,6 +112,7 @@ pub fn update(input_popup: *InputPopup) void {
         .shell_surface => |shell_surface| shell_surface.popup_tree,
         // TODO fix positioning for lock surfaces not at 0,0 in layout coords
         .lock_surface => |_| server.scene.layers.popups,
+        .layer_surface => |layer_surface| layer_surface.popup_tree,
         // Xwayland doesn't use the text-input protocol
         .override_redirect => unreachable,
     };
diff --git a/river/LayerShell.zig b/river/LayerShell.zig
new file mode 100644
index 0000000..4e2408b
--- /dev/null
+++ b/river/LayerShell.zig
@@ -0,0 +1,206 @@
+// This file is part of river, a dynamic tiling wayland compositor.
+//
+// Copyright 2025 The River Developers
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+const LayerShell = @This();
+
+const std = @import("std");
+const assert = std.debug.assert;
+const math = std.math;
+const wlr = @import("wlroots");
+const wayland = @import("wayland");
+const wl = wayland.server.wl;
+const river = wayland.server.river;
+const zwlr = wayland.server.zwlr;
+
+const server = &@import("main.zig").server;
+const util = @import("util.zig");
+
+const LayerShellOutput = @import("LayerShellOutput.zig");
+const LayerShellSeat = @import("LayerShellSeat.zig");
+const LayerSurface = @import("LayerSurface.zig");
+const Output = @import("Output.zig");
+const SceneNodeData = @import("SceneNodeData.zig");
+const Seat = @import("Seat.zig");
+const SlotMap = @import("slotmap").SlotMap;
+
+const log = std.log.scoped(.wm);
+
+global: *wl.Global,
+wlr_shell: *wlr.LayerShellV1,
+
+/// The layer shell object of the active window manager, if any
+objects: wl.list.Head(river.LayerShellV1, null),
+
+surfaces: SlotMap(*LayerSurface) = .empty,
+
+new_surface: wl.Listener(*wlr.LayerSurfaceV1) = .init(handleNewSurface),
+
+pub fn init(layer_shell: *LayerShell) !void {
+    layer_shell.* = .{
+        .global = try wl.Global.create(server.wl_server, river.LayerShellV1, 1, *LayerShell, layer_shell, bind),
+        .wlr_shell = try wlr.LayerShellV1.create(server.wl_server, 4),
+        .objects = undefined,
+    };
+    layer_shell.objects.init();
+    layer_shell.wlr_shell.events.new_surface.add(&layer_shell.new_surface);
+}
+
+// Use a deinit function rather than listening for the wl_server to be destroyed
+// in order to avoid a signal ordering issue. The wlr.LayerShellV1 also listens
+// for the wl_server to be destroyed and asserts that the new_surface event has
+// no remaining listeners.
+pub fn deinit(layer_shell: *LayerShell) void {
+    layer_shell.global.destroy();
+    layer_shell.new_surface.link.remove();
+}
+
+fn bind(client: *wl.Client, layer_shell: *LayerShell, version: u32, id: u32) void {
+    const object = river.LayerShellV1.create(client, version, id) catch {
+        client.postNoMemory();
+        log.err("out of memory", .{});
+        return;
+    };
+    object.setHandler(?*anyopaque, handleRequest, handleDestroy, null);
+    layer_shell.objects.append(object);
+}
+
+fn handleDestroy(object: *river.LayerShellV1, _: ?*anyopaque) void {
+    object.getLink().remove();
+}
+
+fn handleRequest(
+    object: *river.LayerShellV1,
+    request: river.LayerShellV1.Request,
+    _: ?*anyopaque,
+) void {
+    switch (request) {
+        .destroy => object.destroy(),
+        .get_output => |args| {
+            const output_data = args.output.getUserData() orelse return;
+            const output: *Output = @ptrCast(@alignCast(output_data));
+            if (output.layer_shell.object != null) {
+                object.postError(
+                    .object_already_created,
+                    "river_layer_shell_output_v1 already created",
+                );
+                return;
+            }
+            output.layer_shell.createObject(object.getClient(), object.getVersion(), args.id);
+        },
+        .get_seat => |args| {
+            const seat_data = args.seat.getUserData() orelse return;
+            const seat: *Seat = @ptrCast(@alignCast(seat_data));
+            if (seat.layer_shell.object != null) {
+                object.postError(
+                    .object_already_created,
+                    "river_layer_shell_seat_v1 already created",
+                );
+                return;
+            }
+            seat.layer_shell.createObject(object.getClient(), object.getVersion(), args.id);
+        },
+    }
+}
+
+fn supported(layer_shell: *LayerShell) bool {
+    const wm_v1 = server.wm.object orelse return false;
+    var it = layer_shell.objects.iterator(.forward);
+    while (it.next()) |object| {
+        if (object.getClient() == wm_v1.getClient()) return true;
+    }
+    return false;
+}
+
+fn handleNewSurface(_: *wl.Listener(*wlr.LayerSurfaceV1), wlr_layer_surface: *wlr.LayerSurfaceV1) void {
+    log.debug(
+        "new layer surface: namespace {s}, layer {s}, anchor {b:0>4}, size {},{}, margin {},{},{},{}, exclusive_zone {}",
+        .{
+            wlr_layer_surface.namespace,
+            @tagName(wlr_layer_surface.current.layer),
+            @as(u32, @bitCast(wlr_layer_surface.current.anchor)),
+            wlr_layer_surface.current.desired_width,
+            wlr_layer_surface.current.desired_height,
+            wlr_layer_surface.current.margin.top,
+            wlr_layer_surface.current.margin.right,
+            wlr_layer_surface.current.margin.bottom,
+            wlr_layer_surface.current.margin.left,
+            wlr_layer_surface.current.exclusive_zone,
+        },
+    );
+
+    if (!server.layer_shell.supported()) {
+        log.info("window manager did not bind river_layer_shell_v1, closing layer surface", .{});
+        wlr_layer_surface.destroy();
+        return;
+    }
+
+    if (wlr_layer_surface.output == null) {
+        var it = server.om.outputs.iterator(.forward);
+        while (it.next()) |output| {
+            if (output.layer_shell.requested.default) {
+                wlr_layer_surface.output = output.wlr_output;
+                break;
+            }
+        } else {
+            if (server.om.outputs.first()) |output| {
+                log.info("window manager did not set default layer surface output, choosing arbitrary output", .{});
+                wlr_layer_surface.output = output.wlr_output;
+            } else {
+                log.err("no output available for layer surface '{s}'", .{wlr_layer_surface.namespace});
+                wlr_layer_surface.destroy();
+                return;
+            }
+        }
+    }
+
+    LayerSurface.create(wlr_layer_surface) catch {
+        wlr_layer_surface.resource.postNoMemory();
+        return;
+    };
+}
+
+pub fn updateFocus(_: *LayerShell) void {
+    // Find the topmost layer surface (if any) in the top or overlay layers which
+    // requests exclusive keyboard interactivity.
+    const to_focus = blk: {
+        for ([_]zwlr.LayerShellV1.Layer{ .overlay, .top }) |layer| {
+            const tree = server.scene.layerSurfaceTree(layer);
+            // Iterate in reverse to match rendering order.
+            var it = tree.children.iterator(.reverse);
+            while (it.next()) |node| {
+                assert(node.type == .tree);
+                const node_data: *SceneNodeData = @ptrCast(@alignCast(node.data orelse continue));
+                const layer_surface = node_data.data.layer_surface;
+                const wlr_layer_surface = layer_surface.wlr_layer_surface;
+                if (wlr_layer_surface.surface.mapped and
+                    wlr_layer_surface.current.keyboard_interactive == .exclusive)
+                {
+                    break :blk layer_surface;
+                }
+            }
+        }
+        break :blk null;
+    };
+
+    var it = server.input_manager.seats.iterator(.forward);
+    while (it.next()) |seat| {
+        if (to_focus) |layer_surface| {
+            seat.layer_shell.scheduled.focus = .{ .exclusive = layer_surface.ref };
+        } else if (seat.layer_shell.scheduled.focus == .exclusive) {
+            seat.layer_shell.scheduled.focus = .none;
+        }
+    }
+}
diff --git a/river/LayerShellOutput.zig b/river/LayerShellOutput.zig
new file mode 100644
index 0000000..a0dbf84
--- /dev/null
+++ b/river/LayerShellOutput.zig
@@ -0,0 +1,190 @@
+// This file is part of river, a dynamic tiling wayland compositor.
+//
+// Copyright 2025 The River Developers
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+const LayerShellOutput = @This();
+
+const std = @import("std");
+const assert = std.debug.assert;
+const wlr = @import("wlroots");
+const wayland = @import("wayland");
+const wl = wayland.server.wl;
+const river = wayland.server.river;
+const zwlr = wayland.server.zwlr;
+
+const server = &@import("main.zig").server;
+const util = @import("util.zig");
+
+const Output = @import("Output.zig");
+const SceneNodeData = @import("SceneNodeData.zig");
+
+const log = std.log.scoped(.wm);
+
+object: ?*river.LayerShellOutputV1 = null,
+
+scheduled: struct {
+    non_exclusive_area: wlr.Box = .{ .x = 0, .y = 0, .width = 0, .height = 0 },
+} = .{},
+sent: struct {
+    non_exclusive_area: ?wlr.Box = null,
+} = .{},
+requested: struct {
+    default: bool = false,
+} = .{},
+
+pub fn createObject(
+    shell_output: *LayerShellOutput,
+    client: *wl.Client,
+    version: u32,
+    id: u32,
+) void {
+    assert(shell_output.object == null);
+    shell_output.object = river.LayerShellOutputV1.create(client, version, id) catch {
+        client.postNoMemory();
+        log.err("out of memory", .{});
+        return;
+    };
+    shell_output.object.?.setHandler(*LayerShellOutput, handleRequest, handleDestroy, shell_output);
+    server.wm.dirtyWindowing();
+}
+
+pub fn makeInert(shell_output: *LayerShellOutput) void {
+    if (shell_output.object) |object| {
+        object.setHandler(?*anyopaque, handleRequestInert, null, null);
+        shell_output.object = null;
+        shell_output.sent = .{};
+    }
+}
+
+fn handleRequestInert(
+    object: *river.LayerShellOutputV1,
+    request: river.LayerShellOutputV1.Request,
+    _: ?*anyopaque,
+) void {
+    if (request == .destroy) object.destroy();
+}
+
+fn handleDestroy(_: *river.LayerShellOutputV1, shell_output: *LayerShellOutput) void {
+    shell_output.object = null;
+}
+
+fn handleRequest(
+    layer_shell_output_v1: *river.LayerShellOutputV1,
+    request: river.LayerShellOutputV1.Request,
+    shell_output: *LayerShellOutput,
+) void {
+    assert(shell_output.object == layer_shell_output_v1);
+    switch (request) {
+        .destroy => layer_shell_output_v1.destroy(),
+        .set_default => {
+            var it = server.om.outputs.iterator(.forward);
+            while (it.next()) |output| {
+                output.layer_shell.requested.default = false;
+            }
+            shell_output.requested.default = true;
+        },
+    }
+}
+
+pub fn arrange(shell_output: *LayerShellOutput) void {
+    const output: *Output = @fieldParentPtr("layer_shell", shell_output);
+    shell_output.scheduled.non_exclusive_area = output.scheduled.box();
+    sendConfigures(output, .exclusive);
+    sendConfigures(output, .non_exclusive);
+    if (!std.meta.eql(
+        shell_output.sent.non_exclusive_area,
+        shell_output.scheduled.non_exclusive_area,
+    )) {
+        server.wm.dirtyWindowing();
+    }
+}
+
+fn sendConfigures(
+    output: *Output,
+    mode: enum { exclusive, non_exclusive },
+) void {
+    const output_width, const output_height = output.scheduled.dimensions();
+    const output_box = output.scheduled.box();
+    for ([_]zwlr.LayerShellV1.Layer{ .background, .bottom, .top, .overlay }) |layer| {
+        const tree = server.scene.layerSurfaceTree(layer);
+        var it = tree.children.safeIterator(.forward);
+        while (it.next()) |node| {
+            assert(node.type == .tree);
+            const node_data: *SceneNodeData = @ptrCast(@alignCast(node.data orelse continue));
+            const layer_surface = node_data.data.layer_surface;
+            if (!layer_surface.wlr_layer_surface.initialized) {
+                continue;
+            }
+            if (layer_surface.wlr_layer_surface.output != output.wlr_output) {
+                continue;
+            }
+            const current = layer_surface.wlr_layer_surface.current;
+            const exclusive = current.exclusive_zone > 0;
+            if (exclusive != (mode == .exclusive)) {
+                continue;
+            }
+            {
+                var new_area = output.layer_shell.scheduled.non_exclusive_area;
+                layer_surface.scene_layer_surface.configure(&output_box, &new_area);
+                // Clients can request bogus exclusive zones larger than the output
+                // dimensions and river must handle this gracefully. It seems reasonable
+                // to close layer shell clients that would cause the usable area of the
+                // output to become less than half the width/height of its full dimensions.
+                if (new_area.width < output_width / 2 or new_area.height < output_height / 2) {
+                    layer_surface.wlr_layer_surface.destroy();
+                    continue;
+                }
+                output.layer_shell.scheduled.non_exclusive_area = new_area;
+            }
+            const x = layer_surface.scene_layer_surface.tree.node.x;
+            const y = layer_surface.scene_layer_surface.tree.node.y;
+            layer_surface.popup_tree.node.setPosition(x, y);
+            layer_surface.scene_layer_surface.tree.node.subsurfaceTreeSetClip(&.{
+                .x = -x,
+                .y = -y,
+                .width = output_width,
+                .height = output_height,
+            });
+        }
+    }
+}
+
+pub fn manageStart(shell_output: *LayerShellOutput) void {
+    const output: *Output = @fieldParentPtr("layer_shell", shell_output);
+    assert(output.scheduled.state == .enabled or output.scheduled.state == .disabled_soft);
+
+    const scheduled_width, const scheduled_height = output.scheduled.dimensions();
+    const sent_width, const sent_height = output.sent.dimensions();
+
+    if (scheduled_width != sent_width or scheduled_height != sent_height) {
+        shell_output.scheduled.non_exclusive_area = output.scheduled.box();
+        sendConfigures(output, .exclusive);
+        sendConfigures(output, .non_exclusive);
+    }
+    if (!std.meta.eql(
+        shell_output.sent.non_exclusive_area,
+        shell_output.scheduled.non_exclusive_area,
+    )) {
+        if (shell_output.object) |layer_shell_output_v1| {
+            layer_shell_output_v1.sendNonExclusiveArea(
+                shell_output.scheduled.non_exclusive_area.x,
+                shell_output.scheduled.non_exclusive_area.y,
+                shell_output.scheduled.non_exclusive_area.width,
+                shell_output.scheduled.non_exclusive_area.height,
+            );
+        }
+        shell_output.sent.non_exclusive_area = shell_output.scheduled.non_exclusive_area;
+    }
+}
diff --git a/river/LayerShellSeat.zig b/river/LayerShellSeat.zig
new file mode 100644
index 0000000..a73bd88
--- /dev/null
+++ b/river/LayerShellSeat.zig
@@ -0,0 +1,109 @@
+// This file is part of river, a dynamic tiling wayland compositor.
+//
+// Copyright 2025 The River Developers
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+const LayerShellSeat = @This();
+
+const std = @import("std");
+const assert = std.debug.assert;
+const wlr = @import("wlroots");
+const wayland = @import("wayland");
+const wl = wayland.server.wl;
+const river = wayland.server.river;
+
+const server = &@import("main.zig").server;
+const util = @import("util.zig");
+
+const LayerSurface = @import("LayerSurface.zig");
+const Seat = @import("Seat.zig");
+
+const log = std.log.scoped(.wm);
+
+const Focus = union(enum) {
+    exclusive: LayerSurface.Ref,
+    non_exclusive: LayerSurface.Ref,
+    none,
+};
+
+object: ?*river.LayerShellSeatV1 = null,
+
+scheduled: struct {
+    focus: Focus = .none,
+} = .{},
+sent: struct {
+    focus: Focus = .none,
+} = .{},
+requested: struct {} = .{},
+
+pub fn createObject(
+    shell_seat: *LayerShellSeat,
+    client: *wl.Client,
+    version: u32,
+    id: u32,
+) void {
+    assert(shell_seat.object == null);
+    shell_seat.object = river.LayerShellSeatV1.create(client, version, id) catch {
+        client.postNoMemory();
+        return;
+    };
+    shell_seat.object.?.setHandler(*LayerShellSeat, handleRequest, handleDestroy, shell_seat);
+    server.wm.dirtyWindowing();
+}
+
+pub fn makeInert(shell_seat: *LayerShellSeat) void {
+    if (shell_seat.object) |object| {
+        object.setHandler(?*anyopaque, handleRequestInert, null, null);
+        shell_seat.object = null;
+    }
+}
+
+fn handleRequestInert(
+    object: *river.LayerShellSeatV1,
+    request: river.LayerShellSeatV1.Request,
+    _: ?*anyopaque,
+) void {
+    if (request == .destroy) object.destroy();
+}
+
+fn handleDestroy(_: *river.LayerShellSeatV1, shell_seat: *LayerShellSeat) void {
+    shell_seat.object = null;
+}
+
+fn handleRequest(
+    object: *river.LayerShellSeatV1,
+    request: river.LayerShellSeatV1.Request,
+    shell_seat: *LayerShellSeat,
+) void {
+    assert(shell_seat.object == object);
+    switch (request) {
+        .destroy => object.destroy(),
+    }
+}
+
+pub fn manageStart(shell_seat: *LayerShellSeat) void {
+    if (@as(std.meta.Tag(Focus), shell_seat.scheduled.focus) != shell_seat.sent.focus) {
+        if (shell_seat.object) |shell_seat_v1| {
+            switch (shell_seat.scheduled.focus) {
+                .exclusive => shell_seat_v1.sendFocusExclusive(),
+                .non_exclusive => shell_seat_v1.sendFocusNonExclusive(),
+                .none => shell_seat_v1.sendFocusNone(),
+            }
+        }
+    }
+    shell_seat.sent.focus = shell_seat.scheduled.focus;
+    if (shell_seat.scheduled.focus == .non_exclusive) {
+        shell_seat.scheduled.focus = .none;
+    }
+}
diff --git a/river/LayerSurface.zig b/river/LayerSurface.zig
new file mode 100644
index 0000000..9502323
--- /dev/null
+++ b/river/LayerSurface.zig
@@ -0,0 +1,188 @@
+// This file is part of river, a dynamic tiling wayland compositor.
+//
+// Copyright 2025 The River Developers
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+const LayerSurface = @This();
+
+const std = @import("std");
+const assert = std.debug.assert;
+const wlr = @import("wlroots");
+const wl = @import("wayland").server.wl;
+const zwlr = @import("wayland").server.zwlr;
+
+const server = &@import("main.zig").server;
+const util = @import("util.zig");
+
+const Output = @import("Output.zig");
+const SceneNodeData = @import("SceneNodeData.zig");
+const SlotMap = @import("slotmap").SlotMap;
+const XdgPopup = @import("XdgPopup.zig");
+
+const log = std.log.scoped(.wm);
+
+/// Only packed in order to make == work.
+pub const Ref = packed struct {
+    key: SlotMap(*LayerSurface).Key,
+
+    pub fn get(ref: Ref) ?*LayerSurface {
+        return server.layer_shell.surfaces.get(ref.key);
+    }
+};
+
+ref: Ref,
+
+wlr_layer_surface: *wlr.LayerSurfaceV1,
+scene_layer_surface: *wlr.SceneLayerSurfaceV1,
+popup_tree: *wlr.SceneTree,
+
+destroy: wl.Listener(*wlr.LayerSurfaceV1) = wl.Listener(*wlr.LayerSurfaceV1).init(handleDestroy),
+map: wl.Listener(void) = wl.Listener(void).init(handleMap),
+unmap: wl.Listener(void) = wl.Listener(void).init(handleUnmap),
+commit: wl.Listener(*wlr.Surface) = wl.Listener(*wlr.Surface).init(handleCommit),
+new_popup: wl.Listener(*wlr.XdgPopup) = wl.Listener(*wlr.XdgPopup).init(handleNewPopup),
+
+pub fn create(wlr_layer_surface: *wlr.LayerSurfaceV1) error{OutOfMemory}!void {
+    const layer_surface = try util.gpa.create(LayerSurface);
+    errdefer util.gpa.destroy(layer_surface);
+
+    const key = try server.layer_shell.surfaces.put(util.gpa, layer_surface);
+    errdefer server.layer_shell.surfaces.remove(key);
+
+    const layer_tree = server.scene.layerSurfaceTree(wlr_layer_surface.current.layer);
+
+    layer_surface.* = .{
+        .ref = .{ .key = key },
+        .wlr_layer_surface = wlr_layer_surface,
+        .scene_layer_surface = try layer_tree.createSceneLayerSurfaceV1(wlr_layer_surface),
+        .popup_tree = try server.scene.layers.popups.createSceneTree(),
+    };
+
+    try SceneNodeData.attach(&layer_surface.scene_layer_surface.tree.node, .{ .layer_surface = layer_surface });
+    try SceneNodeData.attach(&layer_surface.popup_tree.node, .{ .layer_surface = layer_surface });
+
+    wlr_layer_surface.surface.data = &layer_surface.scene_layer_surface.tree.node;
+
+    wlr_layer_surface.events.destroy.add(&layer_surface.destroy);
+    wlr_layer_surface.surface.events.map.add(&layer_surface.map);
+    wlr_layer_surface.surface.events.unmap.add(&layer_surface.unmap);
+    wlr_layer_surface.surface.events.commit.add(&layer_surface.commit);
+    wlr_layer_surface.events.new_popup.add(&layer_surface.new_popup);
+}
+
+pub fn destroyPopups(layer_surface: *LayerSurface) void {
+    var it = layer_surface.wlr_layer_surface.popups.safeIterator(.forward);
+    while (it.next()) |wlr_xdg_popup| wlr_xdg_popup.destroy();
+}
+
+fn handleDestroy(listener: *wl.Listener(*wlr.LayerSurfaceV1), _: *wlr.LayerSurfaceV1) void {
+    const layer_surface: *LayerSurface = @fieldParentPtr("destroy", listener);
+
+    log.debug("layer surface '{s}' destroyed", .{layer_surface.wlr_layer_surface.namespace});
+
+    layer_surface.destroy.link.remove();
+    layer_surface.map.link.remove();
+    layer_surface.unmap.link.remove();
+    layer_surface.commit.link.remove();
+    layer_surface.new_popup.link.remove();
+
+    layer_surface.destroyPopups();
+
+    layer_surface.popup_tree.node.destroy();
+
+    // The wlr_surface may outlive the wlr_layer_surface so we must clean up the user data.
+    layer_surface.wlr_layer_surface.surface.data = null;
+
+    server.layer_shell.surfaces.remove(layer_surface.ref.key);
+    util.gpa.destroy(layer_surface);
+}
+
+fn handleMap(listener: *wl.Listener(void)) void {
+    const layer_surface: *LayerSurface = @fieldParentPtr("map", listener);
+    const wlr_layer_surface = layer_surface.wlr_layer_surface;
+
+    log.debug("layer surface '{s}' mapped", .{wlr_layer_surface.namespace});
+
+    if (wlr_layer_surface.current.keyboard_interactive == .on_demand) {
+        var it = server.input_manager.seats.iterator(.forward);
+        while (it.next()) |seat| {
+            if (seat.layer_shell.scheduled.focus != .exclusive) {
+                seat.layer_shell.scheduled.focus = .{ .non_exclusive = layer_surface.ref };
+            }
+        }
+    }
+
+    // Beware: it is possible for arrange() to destroy this LayerSurface!
+    const output: *Output = @ptrCast(@alignCast(layer_surface.wlr_layer_surface.output.?.data));
+    output.layer_shell.arrange();
+    server.layer_shell.updateFocus();
+    server.wm.dirtyWindowing();
+}
+
+fn handleUnmap(listener: *wl.Listener(void)) void {
+    const layer_surface: *LayerSurface = @fieldParentPtr("unmap", listener);
+
+    log.debug("layer surface '{s}' unmapped", .{layer_surface.wlr_layer_surface.namespace});
+
+    {
+        var it = server.input_manager.seats.iterator(.forward);
+        while (it.next()) |seat| {
+            if (seat.focused == .layer_surface and seat.focused.layer_surface == layer_surface) {
+                seat.focus(.none);
+            }
+        }
+    }
+
+    // Beware: it is possible for arrange() to destroy this LayerSurface!
+    const output: *Output = @ptrCast(@alignCast(layer_surface.wlr_layer_surface.output.?.data));
+    output.layer_shell.arrange();
+    server.layer_shell.updateFocus();
+    server.wm.dirtyWindowing();
+}
+
+fn handleCommit(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
+    const layer_surface: *LayerSurface = @fieldParentPtr("commit", listener);
+    const wlr_layer_surface = layer_surface.wlr_layer_surface;
+
+    assert(wlr_layer_surface.output != null);
+
+    // If the layer was changed, move the LayerSurface to the proper tree.
+    if (wlr_layer_surface.current.committed.layer) {
+        const tree = server.scene.layerSurfaceTree(wlr_layer_surface.current.layer);
+        layer_surface.scene_layer_surface.tree.node.reparent(tree);
+    }
+
+    if (wlr_layer_surface.initial_commit or
+        @as(u32, @bitCast(wlr_layer_surface.current.committed)) != 0)
+    {
+        // Beware: it is possible for arrange() to destroy this LayerSurface!
+        const output: *Output = @ptrCast(@alignCast(layer_surface.wlr_layer_surface.output.?.data));
+        output.layer_shell.arrange();
+        server.layer_shell.updateFocus();
+        server.wm.dirtyWindowing();
+    }
+}
+
+fn handleNewPopup(listener: *wl.Listener(*wlr.XdgPopup), wlr_xdg_popup: *wlr.XdgPopup) void {
+    const layer_surface: *LayerSurface = @fieldParentPtr("new_popup", listener);
+
+    XdgPopup.create(
+        wlr_xdg_popup,
+        layer_surface.popup_tree,
+        layer_surface.popup_tree,
+    ) catch {
+        wlr_xdg_popup.resource.postNoMemory();
+        return;
+    };
+}
diff --git a/river/Output.zig b/river/Output.zig
index 3eef3ba..be05c07 100644
--- a/river/Output.zig
+++ b/river/Output.zig
@@ -1,6 +1,6 @@
 // This file is part of river, a dynamic tiling wayland compositor.
 //
-// Copyright 2020-2024 The River Developers
+// Copyright 2020-2025 The River Developers
 //
 // This program is free software: you can redistribute it and/or modify
 // it under the terms of the GNU General Public License as published by
@@ -31,6 +31,7 @@ const river = wayland.server.river;
 const server = &@import("main.zig").server;
 const util = @import("util.zig");
 
+const LayerShellOutput = @import("LayerShellOutput.zig");
 const LockSurface = @import("LockSurface.zig");
 const SceneNodeData = @import("SceneNodeData.zig");
 const Window = @import("Window.zig");
@@ -86,6 +87,11 @@ pub const State = struct {
         };
     }
 
+    pub fn box(state: *const State) wlr.Box {
+        const w, const h = state.dimensions();
+        return .{ .x = state.x, .y = state.y, .width = w, .height = h };
+    }
+
     pub fn applyNoModeset(state: *const State, wlr_state: *wlr.Output.State) void {
         wlr_state.setScale(state.scale);
         wlr_state.setTransform(state.transform);
@@ -108,6 +114,7 @@ wlr_output: ?*wlr.Output,
 scene_output: ?*wlr.SceneOutput,
 
 object: ?*river.OutputV1 = null,
+layer_shell: LayerShellOutput = .{},
 
 /// Tracks the currently presented frame on the output as it pertains to ext-session-lock.
 /// The output is initially considered blanked:
@@ -221,6 +228,8 @@ pub fn manageStart(output: *Output) void {
         .enabled, .disabled_soft => {
             const wlr_output = output.wlr_output.?;
 
+            output.layer_shell.manageStart();
+
             if (server.wm.object) |wm_v1| {
                 const new = output.object == null;
                 const output_v1 = output.object orelse blk: {
@@ -237,9 +246,6 @@ pub fn manageStart(output: *Output) void {
                 };
                 errdefer comptime unreachable;
 
-                const pending = &output.scheduled;
-                const sent = &output.sent;
-
                 if (!output.sent_wl_output) {
                     // wl_output globals are created/destroyed by the wlroots output layout.
                     if (wlr_output.global) |global| {
@@ -248,14 +254,17 @@ pub fn manageStart(output: *Output) void {
                     }
                 }
 
-                const pending_width, const pending_height = pending.dimensions();
+                const scheduled = &output.scheduled;
+                const sent = &output.sent;
+
+                const scheduled_width, const scheduled_height = scheduled.dimensions();
                 const sent_width, const sent_height = sent.dimensions();
 
-                if (new or pending_width != sent_width or pending_height != sent_height) {
-                    output_v1.sendDimensions(pending_width, pending_height);
+                if (new or scheduled_width != sent_width or scheduled_height != sent_height) {
+                    output_v1.sendDimensions(scheduled_width, scheduled_height);
                 }
-                if (new or pending.x != sent.x or pending.y != sent.y) {
-                    output_v1.sendPosition(pending.x, pending.y);
+                if (new or scheduled.x != sent.x or scheduled.y != sent.y) {
+                    output_v1.sendPosition(scheduled.x, scheduled.y);
                 }
             }
 
@@ -268,6 +277,7 @@ pub fn manageStart(output: *Output) void {
             if (output.object) |output_v1| {
                 output_v1.sendRemoved();
                 output_v1.setHandler(?*anyopaque, handleRequestInert, null, null);
+                output.layer_shell.makeInert();
                 output.object = null;
             }
 
diff --git a/river/Scene.zig b/river/Scene.zig
index b1b5ac9..33eb69e 100644
--- a/river/Scene.zig
+++ b/river/Scene.zig
@@ -20,6 +20,7 @@ const std = @import("std");
 const assert = std.debug.assert;
 const build_options = @import("build_options");
 const wlr = @import("wlroots");
+const zwlr = @import("wayland").server.zwlr;
 
 const server = &@import("main.zig").server;
 
@@ -130,6 +131,16 @@ pub fn at(scene: *const Scene, lx: f64, ly: f64) ?AtResult {
     }
 }
 
+pub fn layerSurfaceTree(scene: *Scene, layer: zwlr.LayerShellV1.Layer) *wlr.SceneTree {
+    return switch (layer) {
+        .background => scene.layers.background,
+        .bottom => scene.layers.bottom,
+        .top => scene.layers.top,
+        .overlay => scene.layers.overlay,
+        _ => unreachable,
+    };
+}
+
 pub const SaveableSurfaces = struct {
     tree: *wlr.SceneTree,
     saved: *wlr.SceneTree,
diff --git a/river/SceneNodeData.zig b/river/SceneNodeData.zig
index 324a410..0e5a0ac 100644
--- a/river/SceneNodeData.zig
+++ b/river/SceneNodeData.zig
@@ -23,6 +23,7 @@ const wl = @import("wayland").server.wl;
 const util = @import("util.zig");
 
 const LockSurface = @import("LockSurface.zig");
+const LayerSurface = @import("LayerSurface.zig");
 const InputPopup = @import("InputPopup.zig");
 const Window = @import("Window.zig");
 const ShellSurface = @import("ShellSurface.zig");
@@ -32,6 +33,7 @@ pub const Data = union(enum) {
     window: *Window,
     shell_surface: *ShellSurface,
     lock_surface: *LockSurface,
+    layer_surface: *LayerSurface,
     override_redirect: if (build_options.xwayland) *XwaylandOverrideRedirect else noreturn,
 };
 
diff --git a/river/Seat.zig b/river/Seat.zig
index fc61808..09333d6 100644
--- a/river/Seat.zig
+++ b/river/Seat.zig
@@ -36,6 +36,8 @@ const InputManager = @import("InputManager.zig");
 const InputRelay = @import("InputRelay.zig");
 const Keyboard = @import("Keyboard.zig");
 const KeyboardGroup = @import("KeyboardGroup.zig");
+const LayerShellSeat = @import("LayerShellSeat.zig");
+const LayerSurface = @import("LayerSurface.zig");
 const LockSurface = @import("LockSurface.zig");
 const Output = @import("Output.zig");
 const PointerBinding = @import("PointerBinding.zig");
@@ -79,18 +81,13 @@ pub const Event = union(enum) {
     pointer_pinch_end: wlr.Pointer.event.PinchEnd,
 };
 
-pub const WmFocus = union(enum) {
-    none,
-    window: Window.Ref,
-    shell_surface: *ShellSurface,
-};
-
 pub const Focus = union(enum) {
     none,
     window: *Window,
     shell_surface: *ShellSurface,
     override_redirect: if (build_options.xwayland) *XwaylandOverrideRedirect else noreturn,
     lock_surface: *LockSurface,
+    layer_surface: *LayerSurface,
 
     pub fn surface(target: Focus) ?*wlr.Surface {
         return switch (target) {
@@ -98,6 +95,7 @@ pub const Focus = union(enum) {
             .shell_surface => |shell_surface| shell_surface.surface,
             .override_redirect => |override_redirect| override_redirect.xsurface.surface,
             .lock_surface => |lock_surface| lock_surface.wlr_lock_surface.surface,
+            .layer_surface => |layer_surface| layer_surface.wlr_layer_surface.surface,
             .none => null,
         };
     }
@@ -110,28 +108,38 @@ link: wl.list.Link,
 destroying: bool = false,
 
 object: ?*river.SeatV1 = null,
+layer_shell: LayerShellSeat = .{},
 
 event_queue: Deque(Event),
 
 /// State to be sent to the wm in the next manage sequence.
 wm_scheduled: struct {
     /// The window entered/hovered by the pointer, if any
-    window: ?Window.Ref = null,
+    hovered: ?Window.Ref = null,
     /// The window clicked on, touched, etc.
-    interaction: WmFocus = .none,
+    interaction: union(enum) {
+        none,
+        window: Window.Ref,
+        shell_surface: *ShellSurface,
+    } = .none,
     op_release: bool = false,
 } = .{},
 
 /// State sent to the wm in the latest manage sequence.
 wm_sent: struct {
     /// The window entered/hovered by the pointer, if any
-    window: ?Window.Ref = null,
+    hovered: ?Window.Ref = null,
 } = .{},
 link_sent: wl.list.Link,
 
 /// Windowing state requested by the wm.
 wm_requested: struct {
-    focus: WmFocus = .none,
+    focus: union(enum) {
+        none,
+        clear,
+        window: Window.Ref,
+        shell_surface: *ShellSurface,
+    } = .none,
     op: union(enum) {
         none,
         start_pointer,
@@ -298,12 +306,15 @@ pub fn manageStart(seat: *Seat) void {
         if (seat.object) |seat_v1| {
             seat_v1.sendRemoved();
             seat_v1.setHandler(?*anyopaque, handleRequestInert, null, null);
+            seat.layer_shell.makeInert();
             seat.object = null;
         }
         seat.destroy();
         return;
     }
 
+    seat.layer_shell.manageStart();
+
     if (server.wm.object) |wm_v1| {
         const new = seat.object == null;
         const seat_v1 = seat.object orelse blk: {
@@ -328,24 +339,24 @@ pub fn manageStart(seat: *Seat) void {
         }
 
         if (new) {
-            if (seat.wm_scheduled.window) |ref| {
+            if (seat.wm_scheduled.hovered) |ref| {
                 if (ref.get()) |window| {
                     if (window.object) |window_v1| {
                         seat_v1.sendPointerEnter(window_v1);
-                        seat.wm_sent.window = seat.wm_scheduled.window;
+                        seat.wm_sent.hovered = seat.wm_scheduled.hovered;
                     }
                 }
             }
-        } else if (seat.wm_scheduled.window != seat.wm_sent.window) {
-            if (seat.wm_sent.window != null) {
+        } else if (seat.wm_scheduled.hovered != seat.wm_sent.hovered) {
+            if (seat.wm_sent.hovered != null) {
                 seat_v1.sendPointerLeave();
-                seat.wm_sent.window = null;
+                seat.wm_sent.hovered = null;
             }
-            if (seat.wm_scheduled.window) |ref| {
+            if (seat.wm_scheduled.hovered) |ref| {
                 if (ref.get()) |window| {
                     if (window.object) |window_v1| {
                         seat_v1.sendPointerEnter(window_v1);
-                        seat.wm_sent.window = seat.wm_scheduled.window;
+                        seat.wm_sent.hovered = seat.wm_scheduled.hovered;
                     }
                 }
             }
@@ -453,7 +464,7 @@ fn handleRequest(
             const shell_surface: *ShellSurface = @ptrCast(@alignCast(data));
             seat.wm_requested.focus = .{ .shell_surface = shell_surface };
         },
-        .clear_focus => seat.wm_requested.focus = .none,
+        .clear_focus => seat.wm_requested.focus = .clear,
 
         .op_start_pointer => {
             if (!server.wm.ensureWindowing()) return;
@@ -487,15 +498,31 @@ fn handleRequest(
 pub fn manageFinish(seat: *Seat) void {
     if (server.lock_manager.state != .unlocked) return;
 
-    switch (seat.wm_requested.focus) {
-        .none => seat.focus(.none),
-        .window => |ref| {
-            if (ref.get()) |window| {
+    switch (seat.layer_shell.sent.focus) {
+        .exclusive => |ref| if (ref.get()) |layer_surface| {
+            seat.focus(.{ .layer_surface = layer_surface });
+        },
+        .non_exclusive, .none => switch (seat.wm_requested.focus) {
+            .none => switch (seat.layer_shell.sent.focus) {
+                .exclusive => unreachable,
+                .non_exclusive => |ref| if (ref.get()) |layer_surface| {
+                    seat.focus(.{ .layer_surface = layer_surface });
+                    seat.layer_shell.sent.focus = .none;
+                },
+                .none => {},
+            },
+            .clear => {
+                seat.focus(.none);
+            },
+            .window => |ref| if (ref.get()) |window| {
                 seat.focus(.{ .window = window });
-            }
+            },
+            .shell_surface => |shell_surface| {
+                seat.focus(.{ .shell_surface = shell_surface });
+            },
         },
-        .shell_surface => |shell_surface| seat.focus(.{ .shell_surface = shell_surface }),
     }
+    seat.wm_requested.focus = .none;
 
     switch (seat.wm_requested.op) {
         .none => {},
@@ -525,12 +552,13 @@ pub fn focus(seat: *Seat, new_focus: Focus) void {
     // First clear the current focus
     switch (seat.focused) {
         .window => |window| window.destroyPopups(),
+        .layer_surface => |layer_surface| layer_surface.destroyPopups(),
         .shell_surface, .override_redirect, .lock_surface, .none => {},
     }
 
     // Set the new focus
     switch (new_focus) {
-        .window, .shell_surface => assert(server.lock_manager.state != .locked),
+        .window, .shell_surface, .layer_surface => assert(server.lock_manager.state != .locked),
         .lock_surface => assert(server.lock_manager.state != .unlocked),
         .override_redirect, .none => {},
     }
diff --git a/river/Server.zig b/river/Server.zig
index e93f6b5..ea90628 100644
--- a/river/Server.zig
+++ b/river/Server.zig
@@ -39,6 +39,7 @@ const Seat = @import("Seat.zig");
 const TabletTool = @import("TabletTool.zig");
 const WindowManager = @import("WindowManager.zig");
 const XkbBindings = @import("XkbBindings.zig");
+const LayerShell = @import("LayerShell.zig");
 const XdgDecoration = @import("XdgDecoration.zig");
 const XdgToplevel = @import("XdgToplevel.zig");
 const XwaylandOverrideRedirect = @import("XwaylandOverrideRedirect.zig");
@@ -91,6 +92,7 @@ idle_inhibit_manager: IdleInhibitManager,
 lock_manager: LockManager,
 wm: WindowManager,
 xkb_bindings: XkbBindings,
+layer_shell: LayerShell,
 
 xwayland: if (build_options.xwayland) ?*wlr.Xwayland else void = if (build_options.xwayland) null,
 new_xsurface: if (build_options.xwayland) wl.Listener(*wlr.XwaylandSurface) else void =
@@ -158,6 +160,7 @@ pub fn init(server: *Server, runtime_xwayland: bool) !void {
         .lock_manager = undefined,
         .wm = undefined,
         .xkb_bindings = undefined,
+        .layer_shell = undefined,
     };
 
     if (renderer.getTextureFormats(@intFromEnum(wlr.BufferCap.dmabuf)) != null) {
@@ -177,6 +180,7 @@ pub fn init(server: *Server, runtime_xwayland: bool) !void {
 
     try server.wm.init();
     try server.xkb_bindings.init();
+    try server.layer_shell.init();
     try server.scene.init();
     try server.om.init();
     try server.input_manager.init();
@@ -227,6 +231,7 @@ pub fn deinit(server: *Server) void {
     server.input_manager.deinit();
     server.idle_inhibit_manager.deinit();
     server.lock_manager.deinit();
+    server.layer_shell.deinit();
 
     server.wl_server.destroy();
 
@@ -320,6 +325,8 @@ fn allowlist(server: *Server, global: *const wl.Global) bool {
 fn blocklist(server: *Server, global: *const wl.Global) bool {
     return global == server.security_context_manager.global or
         global == server.wm.global or
+        global == server.layer_shell.global or
+        global == server.layer_shell.wlr_shell.global or
         global == server.xkb_bindings.global or
         global == server.foreign_toplevel_manager.global or
         global == server.screencopy_manager.global or