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

commitb6229d6dc92e199b57c3a64614e770b37b2f054c
parent6bb6d4bbb0
authorIsaac Freund <[email protected]>
date2024-12-25 09:59
Seat: start integration with rwm state machine

 build.zig.zon                           |   4 +-
 protocol/river-window-management-v1.xml |  20 +-
 river/Cursor.zig                        | 578 ++++++++++++++------------------
 river/ForeignToplevelHandle.zig         |  16 +-
 river/InputManager.zig                  |  28 +-
 river/Keyboard.zig                      |  35 +-
 river/LockManager.zig                   |  14 +-
 river/LockSurface.zig                   |  16 +-
 river/Output.zig                        |   7 +-
 river/Seat.zig                          | 302 +++++++++++++++--
 river/Server.zig                        |   2 +-
 river/Window.zig                        |  38 ++-
 river/WindowManager.zig                 |  50 ++-
 river/XdgToplevel.zig                   |   4 +-
 river/XwaylandOverrideRedirect.zig      |   5 +-
 rivercompat/WindowManager.zig           |   3 +
 16 files changed, 668 insertions(+), 454 deletions(-)

diff --git a/build.zig.zon b/build.zig.zon
index 9237e55..8aa09e0 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -12,8 +12,8 @@
             .hash = "12206015b1750462788de094b8154eab733a36523639124ace437d4631ca01b5ba18",
         },
         .@"zig-wlroots" = .{
-            .url = "https://codeberg.org/ifreund/zig-wlroots/archive/a2372a124d9b8568775849a2ea60da36c4f5b0dd.tar.gz",
-            .hash = "122019ca283db70a0f4932e54b5195e2392b9220c7f97808d0e74c14be30aea4a458",
+            .url = "https://codeberg.org/ifreund/zig-wlroots/archive/70a4de5cd6886003e54f55239d2753b025271bc9.tar.gz",
+            .hash = "122026eea643c568b68cb58d925344033bc143b8ff2f43b0851a4577f68c83b9f96c",
         },
         .@"zig-xkbcommon" = .{
             .url = "https://codeberg.org/ifreund/zig-xkbcommon/archive/v0.2.0.tar.gz",
diff --git a/protocol/river-window-management-v1.xml b/protocol/river-window-management-v1.xml
index 1523008..91a2365 100644
--- a/protocol/river-window-management-v1.xml
+++ b/protocol/river-window-management-v1.xml
@@ -1021,22 +1021,18 @@
       <arg name="window" type="object" interface="river_window_v1"/>
     </event>
 
-    <event name="pointer_motion">
-      <description summary="pointer moved">
-        The seat's pointer moved. The x and y coordinates are in the
-        compositor's logical coordinate space.
+    <event name="pointer_activity">
+      <description summary="the pointer was moved">
+        The seat's pointer was moved.
 
-        Rationale: pointer motion events in some form are necessary to implement
-        the "always" style of focus-follows-cursor. Choosing to expose global
-        pointer position/motion information to the window manager rather than
-        motion events relative to a window allows other potentially interesting
-        features, for example hot corners.
+        Rationale: The motivating window manager feature for this event is the
+        "always" style of focus-follows-cursor. Waiting for the window manager
+        to ack and commit in response to every single pointer motion event is
+        noisy, wasteful, and unnecessary.
 
         This event is double-buffered state and will be followed by a
         river_window_manager_v1.update event.
       </description>
-      <arg name="x" type="int"/>
-      <arg name="y" type="int"/>
     </event>
 
     <event name="window_interaction">
@@ -1077,7 +1073,7 @@
 
     <request name="pointer_resize_window">
       <description summary="start interactive pointer resize of a window">
-        This request causes the a window to be resized from the specified edges,
+        This request causes the window to be resized from the specified edges,
         updating the position/dimensions of the window as the pointer is moved.
         Multiple windows may be interactively resized at the same time.
 
diff --git a/river/Cursor.zig b/river/Cursor.zig
index dadb308..38c900e 100644
--- a/river/Cursor.zig
+++ b/river/Cursor.zig
@@ -1,6 +1,6 @@
 // This file is part of river, a dynamic tiling wayland compositor.
 //
-// Copyright 2020 The River Developers
+// Copyright 2020-2024 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
@@ -43,6 +43,8 @@ const TabletTool = @import("TabletTool.zig");
 const Window = @import("Window.zig");
 const XwaylandOverrideRedirect = @import("XwaylandOverrideRedirect.zig");
 
+const log = std.log.scoped(.cursor);
+
 const Mode = union(enum) {
     passthrough: void,
     down: struct {
@@ -107,8 +109,6 @@ const LayoutPoint = struct {
     ly: f64,
 };
 
-const log = std.log.scoped(.cursor);
-
 /// Current cursor mode as well as any state needed to implement that mode
 mode: Mode = .passthrough,
 
@@ -139,28 +139,31 @@ constraint: ?*PointerConstraint = null,
 /// This information is necessary for proper touch dnd support if there are multiple touch points.
 touch_points: std.AutoHashMapUnmanaged(i32, LayoutPoint) = .{},
 
-axis: wl.Listener(*wlr.Pointer.event.Axis) = wl.Listener(*wlr.Pointer.event.Axis).init(handleAxis),
-frame: wl.Listener(*wlr.Cursor) = wl.Listener(*wlr.Cursor).init(handleFrame),
-button: wl.Listener(*wlr.Pointer.event.Button) =
-    wl.Listener(*wlr.Pointer.event.Button).init(handleButton),
-motion_absolute: wl.Listener(*wlr.Pointer.event.MotionAbsolute) =
-    wl.Listener(*wlr.Pointer.event.MotionAbsolute).init(handleMotionAbsolute),
-motion: wl.Listener(*wlr.Pointer.event.Motion) =
-    wl.Listener(*wlr.Pointer.event.Motion).init(handleMotion),
-pinch_begin: wl.Listener(*wlr.Pointer.event.PinchBegin) =
-    wl.Listener(*wlr.Pointer.event.PinchBegin).init(handlePinchBegin),
-pinch_update: wl.Listener(*wlr.Pointer.event.PinchUpdate) =
-    wl.Listener(*wlr.Pointer.event.PinchUpdate).init(handlePinchUpdate),
-pinch_end: wl.Listener(*wlr.Pointer.event.PinchEnd) =
-    wl.Listener(*wlr.Pointer.event.PinchEnd).init(handlePinchEnd),
 request_set_cursor: wl.Listener(*wlr.Seat.event.RequestSetCursor) =
     wl.Listener(*wlr.Seat.event.RequestSetCursor).init(handleRequestSetCursor),
+
+motion_relative: wl.Listener(*wlr.Pointer.event.Motion) =
+    wl.Listener(*wlr.Pointer.event.Motion).init(queueMotionRelative),
+motion_absolute: wl.Listener(*wlr.Pointer.event.MotionAbsolute) =
+    wl.Listener(*wlr.Pointer.event.MotionAbsolute).init(queueMotionAbsolute),
+button: wl.Listener(*wlr.Pointer.event.Button) =
+    wl.Listener(*wlr.Pointer.event.Button).init(queueButton),
+axis: wl.Listener(*wlr.Pointer.event.Axis) = wl.Listener(*wlr.Pointer.event.Axis).init(queueAxis),
+frame: wl.Listener(*wlr.Cursor) = wl.Listener(*wlr.Cursor).init(queueFrame),
+
 swipe_begin: wl.Listener(*wlr.Pointer.event.SwipeBegin) =
-    wl.Listener(*wlr.Pointer.event.SwipeBegin).init(handleSwipeBegin),
+    wl.Listener(*wlr.Pointer.event.SwipeBegin).init(queueSwipeBegin),
 swipe_update: wl.Listener(*wlr.Pointer.event.SwipeUpdate) =
-    wl.Listener(*wlr.Pointer.event.SwipeUpdate).init(handleSwipeUpdate),
+    wl.Listener(*wlr.Pointer.event.SwipeUpdate).init(queueSwipeUpdate),
 swipe_end: wl.Listener(*wlr.Pointer.event.SwipeEnd) =
-    wl.Listener(*wlr.Pointer.event.SwipeEnd).init(handleSwipeEnd),
+    wl.Listener(*wlr.Pointer.event.SwipeEnd).init(queueSwipeEnd),
+
+pinch_begin: wl.Listener(*wlr.Pointer.event.PinchBegin) =
+    wl.Listener(*wlr.Pointer.event.PinchBegin).init(queuePinchBegin),
+pinch_update: wl.Listener(*wlr.Pointer.event.PinchUpdate) =
+    wl.Listener(*wlr.Pointer.event.PinchUpdate).init(queuePinchUpdate),
+pinch_end: wl.Listener(*wlr.Pointer.event.PinchEnd) =
+    wl.Listener(*wlr.Pointer.event.PinchEnd).init(queuePinchEnd),
 
 touch_down: wl.Listener(*wlr.Touch.event.Down) =
     wl.Listener(*wlr.Touch.event.Down).init(handleTouchDown),
@@ -199,23 +202,21 @@ pub fn init(cursor: *Cursor, seat: *Seat) !void {
     };
     try cursor.setTheme(null, null);
 
-    // wlr_cursor *only* displays an image on screen. It does not move around
-    // when the pointer moves. However, we can attach input devices to it, and
-    // it will generate aggregate events for all of them. In these events, we
-    // can choose how we want to process them, forwarding them to clients and
-    // moving the cursor around.
-    wlr_cursor.events.axis.add(&cursor.axis);
+    seat.wlr_seat.events.request_set_cursor.add(&cursor.request_set_cursor);
+
+    wlr_cursor.events.motion.add(&cursor.motion_relative);
+    wlr_cursor.events.motion_absolute.add(&cursor.motion_absolute);
     wlr_cursor.events.button.add(&cursor.button);
+    wlr_cursor.events.axis.add(&cursor.axis);
     wlr_cursor.events.frame.add(&cursor.frame);
-    wlr_cursor.events.motion_absolute.add(&cursor.motion_absolute);
-    wlr_cursor.events.motion.add(&cursor.motion);
+
     wlr_cursor.events.swipe_begin.add(&cursor.swipe_begin);
     wlr_cursor.events.swipe_update.add(&cursor.swipe_update);
     wlr_cursor.events.swipe_end.add(&cursor.swipe_end);
+
     wlr_cursor.events.pinch_begin.add(&cursor.pinch_begin);
     wlr_cursor.events.pinch_update.add(&cursor.pinch_update);
     wlr_cursor.events.pinch_end.add(&cursor.pinch_end);
-    seat.wlr_seat.events.request_set_cursor.add(&cursor.request_set_cursor);
 
     wlr_cursor.events.touch_down.add(&cursor.touch_down);
     wlr_cursor.events.touch_motion.add(&cursor.touch_motion);
@@ -282,42 +283,164 @@ pub fn setXcursor(cursor: *Cursor, name: [*:0]const u8) void {
     cursor.xcursor_name = name;
 }
 
+fn handleRequestSetCursor(
+    listener: *wl.Listener(*wlr.Seat.event.RequestSetCursor),
+    event: *wlr.Seat.event.RequestSetCursor,
+) void {
+    // This event is rasied by the seat when a client provides a cursor image
+    const cursor: *Cursor = @fieldParentPtr("request_set_cursor", listener);
+    const focused_client = cursor.seat.wlr_seat.pointer_state.focused_client;
+
+    // This can be sent by any client, so we check to make sure this one is
+    // actually has pointer focus first.
+    if (focused_client == event.seat_client) {
+        // Once we've vetted the client, we can tell the cursor to use the
+        // provided surface as the cursor image. It will set the hardware cursor
+        // on the output that it's currently on and continue to do so as the
+        // cursor moves between outputs.
+        log.debug("focused client set cursor", .{});
+        cursor.wlr_cursor.setSurface(event.surface, event.hotspot_x, event.hotspot_y);
+        cursor.xcursor_name = null;
+    }
+}
+
 fn clearFocus(cursor: *Cursor) void {
     cursor.setXcursor("default");
     cursor.seat.wlr_seat.pointerNotifyClearFocus();
 }
 
-/// Axis event is a scroll wheel or similiar
-fn handleAxis(listener: *wl.Listener(*wlr.Pointer.event.Axis), event: *wlr.Pointer.event.Axis) void {
-    const cursor: *Cursor = @fieldParentPtr("axis", listener);
-    const device: *InputDevice = @ptrFromInt(event.device.data);
+pub fn processMotionRelative(cursor: *Cursor, event: *const wlr.Pointer.event.Motion) void {
+    server.input_manager.relative_pointer_manager.sendRelativeMotion(
+        cursor.seat.wlr_seat,
+        @as(u64, event.time_msec) * 1000,
+        event.delta_x,
+        event.delta_y,
+        event.unaccel_dx,
+        event.unaccel_dy,
+    );
 
-    cursor.seat.handleActivity();
+    var dx: f64 = event.delta_x;
+    var dy: f64 = event.delta_y;
 
-    // Notify the client with pointer focus of the axis event.
-    cursor.seat.wlr_seat.pointerNotifyAxis(
-        event.time_msec,
-        event.orientation,
-        event.delta * device.config.scroll_factor,
-        @intFromFloat(math.clamp(
-            @round(@as(f32, @floatFromInt(event.delta_discrete)) * device.config.scroll_factor),
-            // It seems that clamping to exactly the bounds of an i32 is insufficient to make the
-            // @intFromFloat() call safe due to the max/min i32 not being exactly representable
-            // by an f32. Dividing by 2 is a low effort way to ensure the value is in bounds and
-            // allow users to set their scroll-factor to inf without crashing river.
-            math.minInt(i32) / 2,
-            math.maxInt(i32) / 2,
-        )),
-        event.source,
-        event.relative_direction,
-    );
+    if (cursor.constraint) |constraint| {
+        if (constraint.state == .active) {
+            switch (constraint.wlr_constraint.type) {
+                .locked => return,
+                .confined => constraint.confine(&dx, &dy),
+            }
+        }
+    }
+
+    switch (cursor.mode) {
+        .passthrough, .down => {
+            cursor.wlr_cursor.move(event.device, dx, dy);
+
+            switch (cursor.mode) {
+                .passthrough => {
+                    cursor.passthrough(event.time_msec);
+                },
+                .down => |data| {
+                    cursor.seat.wlr_seat.pointerNotifyMotion(
+                        event.time_msec,
+                        data.sx + (cursor.wlr_cursor.x - data.lx),
+                        data.sy + (cursor.wlr_cursor.y - data.ly),
+                    );
+                },
+                else => unreachable,
+            }
+
+            cursor.updateDragIcons();
+
+            if (cursor.constraint) |constraint| {
+                constraint.maybeActivate();
+            }
+        },
+        .move => |*data| {
+            dx += data.delta_x;
+            dy += data.delta_y;
+            data.delta_x = dx - @trunc(dx);
+            data.delta_y = dy - @trunc(dy);
+
+            // XXX move window
+
+            server.wm.dirtyPending();
+        },
+        .resize => |*data| {
+            dx += data.delta_x;
+            dy += data.delta_y;
+            data.delta_x = dx - @trunc(dx);
+            data.delta_y = dy - @trunc(dy);
+
+            data.x += @intFromFloat(dx);
+            data.y += @intFromFloat(dy);
+
+            if (true) return; // XXX resize window
+
+            // Modify width/height of the pending box, taking constraints into account
+            // The x/y coordinates of the window will be adjusted as needed in Window.resizeCommit()
+            // based on the dimensions actually committed by the client.
+            const border_width = if (data.window.pending.ssd) server.config.border_width else 0;
+
+            // TODO
+            const output_width: i32 = 1920;
+            const output_height: i32 = 1080;
+
+            const constraints = &data.window.constraints;
+            const box = &data.window.pending.box;
+
+            if (data.edges.left) {
+                const x2 = box.x + box.width;
+                box.width = data.initial_width - data.x;
+                box.width = @max(box.width, constraints.min_width);
+                box.width = @min(box.width, constraints.max_width);
+                box.width = @min(box.width, x2 - border_width);
+                data.x = data.initial_width - box.width;
+            } else if (data.edges.right) {
+                box.width = data.initial_width + data.x;
+                box.width = @max(box.width, constraints.min_width);
+                box.width = @min(box.width, constraints.max_width);
+                box.width = @min(box.width, output_width - border_width - box.x);
+                data.x = box.width - data.initial_width;
+            }
+
+            if (data.edges.top) {
+                const y2 = box.y + box.height;
+                box.height = data.initial_height - data.y;
+                box.height = @max(box.height, constraints.min_height);
+                box.height = @min(box.height, constraints.max_height);
+                box.height = @min(box.height, y2 - border_width);
+                data.y = data.initial_height - box.height;
+            } else if (data.edges.bottom) {
+                box.height = data.initial_height + data.y;
+                box.height = @max(box.height, constraints.min_height);
+                box.height = @min(box.height, constraints.max_height);
+                box.height = @min(box.height, output_height - border_width - box.y);
+                data.y = box.height - data.initial_height;
+            }
+
+            server.wm.dirtyPending();
+        },
+    }
 }
 
-fn handleButton(listener: *wl.Listener(*wlr.Pointer.event.Button), event: *wlr.Pointer.event.Button) void {
-    const cursor: *Cursor = @fieldParentPtr("button", listener);
+pub fn processMotionAbsolute(cursor: *Cursor, event: *const wlr.Pointer.event.MotionAbsolute) void {
+    var lx: f64 = undefined;
+    var ly: f64 = undefined;
+    cursor.wlr_cursor.absoluteToLayoutCoords(event.device, event.x, event.y, &lx, &ly);
 
-    cursor.seat.handleActivity();
+    const dx = lx - cursor.wlr_cursor.x;
+    const dy = ly - cursor.wlr_cursor.y;
+    cursor.processMotionRelative(&.{
+        .device = event.device,
+        .time_msec = event.time_msec,
+        .delta_x = dx,
+        .delta_y = dy,
+        .unaccel_dx = dx,
+        .unaccel_dy = dy,
+    });
+}
 
+pub fn processButton(cursor: *Cursor, event: *const wlr.Pointer.event.Button) void {
     if (event.state == .released) {
         assert(cursor.pressed_count > 0);
         cursor.pressed_count -= 1;
@@ -357,7 +480,7 @@ fn handleButton(listener: *wl.Listener(*wlr.Pointer.event.Button), event: *wlr.P
             return;
         }
 
-        cursor.updateKeyboardFocus(result);
+        cursor.interact(result);
 
         _ = cursor.seat.wlr_seat.pointerNotifyButton(event.time_msec, event.button, event.state);
 
@@ -372,19 +495,37 @@ fn handleButton(listener: *wl.Listener(*wlr.Pointer.event.Button), event: *wlr.P
             };
         }
     }
+}
 
-    server.wm.dirtyPending();
+pub fn processAxis(cursor: *Cursor, event: *const wlr.Pointer.event.Axis) void {
+    const device: *InputDevice = @ptrFromInt(event.device.data);
+    cursor.seat.wlr_seat.pointerNotifyAxis(
+        event.time_msec,
+        event.orientation,
+        event.delta * device.config.scroll_factor,
+        @intFromFloat(math.clamp(
+            @round(@as(f32, @floatFromInt(event.delta_discrete)) * device.config.scroll_factor),
+            // It seems that clamping to exactly the bounds of an i32 is insufficient to make the
+            // @intFromFloat() call safe due to the max/min i32 not being exactly representable
+            // by an f32. Dividing by 2 is a low effort way to ensure the value is in bounds and
+            // allow users to set their scroll-factor to inf without crashing river.
+            math.minInt(i32) / 2,
+            math.maxInt(i32) / 2,
+        )),
+        event.source,
+        event.relative_direction,
+    );
 }
 
-/// Requires a call to WindowManager.dirtyPending()
-fn updateKeyboardFocus(cursor: Cursor, result: Scene.AtResult) void {
+fn interact(cursor: Cursor, result: Scene.AtResult) void {
     switch (result.data) {
         .window => |window| {
-            cursor.seat.focus(window);
+            cursor.seat.pending.window_interaction = window;
+            server.wm.dirtyPending();
         },
         .lock_surface => |lock_surface| {
             assert(server.lock_manager.state != .unlocked);
-            cursor.seat.setFocusRaw(.{ .lock_surface = lock_surface });
+            cursor.seat.focus(.{ .lock_surface = lock_surface });
         },
         .override_redirect => |override_redirect| {
             assert(server.lock_manager.state != .locked);
@@ -393,82 +534,6 @@ fn updateKeyboardFocus(cursor: Cursor, result: Scene.AtResult) void {
     }
 }
 
-fn handlePinchBegin(
-    listener: *wl.Listener(*wlr.Pointer.event.PinchBegin),
-    event: *wlr.Pointer.event.PinchBegin,
-) void {
-    const cursor: *Cursor = @fieldParentPtr("pinch_begin", listener);
-    server.input_manager.pointer_gestures.sendPinchBegin(
-        cursor.seat.wlr_seat,
-        event.time_msec,
-        event.fingers,
-    );
-}
-
-fn handlePinchUpdate(
-    listener: *wl.Listener(*wlr.Pointer.event.PinchUpdate),
-    event: *wlr.Pointer.event.PinchUpdate,
-) void {
-    const cursor: *Cursor = @fieldParentPtr("pinch_update", listener);
-    server.input_manager.pointer_gestures.sendPinchUpdate(
-        cursor.seat.wlr_seat,
-        event.time_msec,
-        event.dx,
-        event.dy,
-        event.scale,
-        event.rotation,
-    );
-}
-
-fn handlePinchEnd(
-    listener: *wl.Listener(*wlr.Pointer.event.PinchEnd),
-    event: *wlr.Pointer.event.PinchEnd,
-) void {
-    const cursor: *Cursor = @fieldParentPtr("pinch_end", listener);
-    server.input_manager.pointer_gestures.sendPinchEnd(
-        cursor.seat.wlr_seat,
-        event.time_msec,
-        event.cancelled,
-    );
-}
-
-fn handleSwipeBegin(
-    listener: *wl.Listener(*wlr.Pointer.event.SwipeBegin),
-    event: *wlr.Pointer.event.SwipeBegin,
-) void {
-    const cursor: *Cursor = @fieldParentPtr("swipe_begin", listener);
-    server.input_manager.pointer_gestures.sendSwipeBegin(
-        cursor.seat.wlr_seat,
-        event.time_msec,
-        event.fingers,
-    );
-}
-
-fn handleSwipeUpdate(
-    listener: *wl.Listener(*wlr.Pointer.event.SwipeUpdate),
-    event: *wlr.Pointer.event.SwipeUpdate,
-) void {
-    const cursor: *Cursor = @fieldParentPtr("swipe_update", listener);
-    server.input_manager.pointer_gestures.sendSwipeUpdate(
-        cursor.seat.wlr_seat,
-        event.time_msec,
-        event.dx,
-        event.dy,
-    );
-}
-
-fn handleSwipeEnd(
-    listener: *wl.Listener(*wlr.Pointer.event.SwipeEnd),
-    event: *wlr.Pointer.event.SwipeEnd,
-) void {
-    const cursor: *Cursor = @fieldParentPtr("swipe_end", listener);
-    server.input_manager.pointer_gestures.sendSwipeEnd(
-        cursor.seat.wlr_seat,
-        event.time_msec,
-        event.cancelled,
-    );
-}
-
 fn handleTouchDown(
     listener: *wl.Listener(*wlr.Touch.event.Down),
     event: *wlr.Touch.event.Down,
@@ -487,7 +552,7 @@ fn handleTouchDown(
     };
 
     if (server.scene.at(lx, ly)) |result| {
-        cursor.updateKeyboardFocus(result);
+        cursor.interact(result);
 
         if (result.surface) |surface| {
             _ = cursor.seat.wlr_seat.touchNotifyDown(
@@ -499,8 +564,6 @@ fn handleTouchDown(
             );
         }
     }
-
-    server.wm.dirtyPending();
 }
 
 fn handleTouchMotion(
@@ -617,8 +680,9 @@ fn handleTabletToolButton(
 
 /// Handle the mapping for the passed button if any. Returns true if there
 /// was a mapping and the button was handled.
-fn handlePointerMapping(cursor: *Cursor, event: *wlr.Pointer.event.Button, _: *Window) bool {
+fn handlePointerMapping(cursor: *Cursor, event: *const wlr.Pointer.event.Button, _: *Window) bool {
     const wlr_keyboard = cursor.seat.wlr_seat.getKeyboard() orelse return false;
+    // XXX this is not ok, we need to store current modifiers per-Keyboard ourselves
     const modifiers = wlr_keyboard.getModifiers();
 
     return for (server.config.pointer_mappings.items) |mapping| {
@@ -629,71 +693,6 @@ fn handlePointerMapping(cursor: *Cursor, event: *wlr.Pointer.event.Button, _: *W
     } else false;
 }
 
-/// Frame events are sent after regular pointer events to group multiple
-/// events together. For instance, two axis events may happen at the same
-/// time, in which case a frame event won't be sent in between.
-fn handleFrame(listener: *wl.Listener(*wlr.Cursor), _: *wlr.Cursor) void {
-    const cursor: *Cursor = @fieldParentPtr("frame", listener);
-    cursor.seat.wlr_seat.pointerNotifyFrame();
-}
-
-/// This event is forwarded by the cursor when a pointer emits an _absolute_
-/// motion event, from 0..1 on each axis. This happens, for example, when
-/// wlroots is running under a Wayland window rather than KMS+DRM, and you
-/// move the mouse over the window. You could enter the window from any edge,
-/// so we have to warp the mouse there. There is also some hardware which
-/// emits these events.
-fn handleMotionAbsolute(
-    listener: *wl.Listener(*wlr.Pointer.event.MotionAbsolute),
-    event: *wlr.Pointer.event.MotionAbsolute,
-) void {
-    const cursor: *Cursor = @fieldParentPtr("motion_absolute", listener);
-
-    cursor.seat.handleActivity();
-
-    var lx: f64 = undefined;
-    var ly: f64 = undefined;
-    cursor.wlr_cursor.absoluteToLayoutCoords(event.device, event.x, event.y, &lx, &ly);
-
-    const dx = lx - cursor.wlr_cursor.x;
-    const dy = ly - cursor.wlr_cursor.y;
-    cursor.processMotion(event.device, event.time_msec, dx, dy, dx, dy);
-}
-
-/// This event is forwarded by the cursor when a pointer emits a _relative_
-/// pointer motion event (i.e. a delta)
-fn handleMotion(
-    listener: *wl.Listener(*wlr.Pointer.event.Motion),
-    event: *wlr.Pointer.event.Motion,
-) void {
-    const cursor: *Cursor = @fieldParentPtr("motion", listener);
-
-    cursor.seat.handleActivity();
-
-    cursor.processMotion(event.device, event.time_msec, event.delta_x, event.delta_y, event.unaccel_dx, event.unaccel_dy);
-}
-
-fn handleRequestSetCursor(
-    listener: *wl.Listener(*wlr.Seat.event.RequestSetCursor),
-    event: *wlr.Seat.event.RequestSetCursor,
-) void {
-    // This event is rasied by the seat when a client provides a cursor image
-    const cursor: *Cursor = @fieldParentPtr("request_set_cursor", listener);
-    const focused_client = cursor.seat.wlr_seat.pointer_state.focused_client;
-
-    // This can be sent by any client, so we check to make sure this one is
-    // actually has pointer focus first.
-    if (focused_client == event.seat_client) {
-        // Once we've vetted the client, we can tell the cursor to use the
-        // provided surface as the cursor image. It will set the hardware cursor
-        // on the output that it's currently on and continue to do so as the
-        // cursor moves between outputs.
-        log.debug("focused client set cursor", .{});
-        cursor.wlr_cursor.setSurface(event.surface, event.hotspot_x, event.hotspot_y);
-        cursor.xcursor_name = null;
-    }
-}
-
 pub fn startMove(cursor: *Cursor, window: *Window) void {
     if (cursor.constraint) |constraint| {
         if (constraint.state == .active) constraint.deactivate();
@@ -790,120 +789,6 @@ fn enterMode(cursor: *Cursor, mode: Mode, window: *Window, xcursor_name: [*:0]co
     server.wm.dirtyPending();
 }
 
-fn processMotion(cursor: *Cursor, device: *wlr.InputDevice, time: u32, delta_x: f64, delta_y: f64, unaccel_dx: f64, unaccel_dy: f64) void {
-    server.input_manager.relative_pointer_manager.sendRelativeMotion(
-        cursor.seat.wlr_seat,
-        @as(u64, time) * 1000,
-        delta_x,
-        delta_y,
-        unaccel_dx,
-        unaccel_dy,
-    );
-
-    var dx: f64 = delta_x;
-    var dy: f64 = delta_y;
-
-    if (cursor.constraint) |constraint| {
-        if (constraint.state == .active) {
-            switch (constraint.wlr_constraint.type) {
-                .locked => return,
-                .confined => constraint.confine(&dx, &dy),
-            }
-        }
-    }
-
-    switch (cursor.mode) {
-        .passthrough, .down => {
-            cursor.wlr_cursor.move(device, dx, dy);
-
-            switch (cursor.mode) {
-                .passthrough => {
-                    cursor.passthrough(time);
-                },
-                .down => |data| {
-                    cursor.seat.wlr_seat.pointerNotifyMotion(
-                        time,
-                        data.sx + (cursor.wlr_cursor.x - data.lx),
-                        data.sy + (cursor.wlr_cursor.y - data.ly),
-                    );
-                },
-                else => unreachable,
-            }
-
-            cursor.updateDragIcons();
-
-            if (cursor.constraint) |constraint| {
-                constraint.maybeActivate();
-            }
-        },
-        .move => |*data| {
-            dx += data.delta_x;
-            dy += data.delta_y;
-            data.delta_x = dx - @trunc(dx);
-            data.delta_y = dy - @trunc(dy);
-
-            // XXX move window
-
-            server.wm.dirtyPending();
-        },
-        .resize => |*data| {
-            dx += data.delta_x;
-            dy += data.delta_y;
-            data.delta_x = dx - @trunc(dx);
-            data.delta_y = dy - @trunc(dy);
-
-            data.x += @intFromFloat(dx);
-            data.y += @intFromFloat(dy);
-
-            if (true) return; // XXX resize window
-
-            // Modify width/height of the pending box, taking constraints into account
-            // The x/y coordinates of the window will be adjusted as needed in Window.resizeCommit()
-            // based on the dimensions actually committed by the client.
-            const border_width = if (data.window.pending.ssd) server.config.border_width else 0;
-
-            // TODO
-            const output_width: i32 = 1920;
-            const output_height: i32 = 1080;
-
-            const constraints = &data.window.constraints;
-            const box = &data.window.pending.box;
-
-            if (data.edges.left) {
-                const x2 = box.x + box.width;
-                box.width = data.initial_width - data.x;
-                box.width = @max(box.width, constraints.min_width);
-                box.width = @min(box.width, constraints.max_width);
-                box.width = @min(box.width, x2 - border_width);
-                data.x = data.initial_width - box.width;
-            } else if (data.edges.right) {
-                box.width = data.initial_width + data.x;
-                box.width = @max(box.width, constraints.min_width);
-                box.width = @min(box.width, constraints.max_width);
-                box.width = @min(box.width, output_width - border_width - box.x);
-                data.x = box.width - data.initial_width;
-            }
-
-            if (data.edges.top) {
-                const y2 = box.y + box.height;
-                box.height = data.initial_height - data.y;
-                box.height = @max(box.height, constraints.min_height);
-                box.height = @min(box.height, constraints.max_height);
-                box.height = @min(box.height, y2 - border_width);
-                data.y = data.initial_height - box.height;
-            } else if (data.edges.bottom) {
-                box.height = data.initial_height + data.y;
-                box.height = @max(box.height, constraints.min_height);
-                box.height = @min(box.height, constraints.max_height);
-                box.height = @min(box.height, output_height - border_width - box.y);
-                data.y = box.height - data.initial_height;
-            }
-
-            server.wm.dirtyPending();
-        },
-    }
-}
-
 /// Handle potential change in location of windows on the output, as well as
 /// the target window of a cursor operation potentially being moved to a non-visible tag,
 /// becoming fullscreen, etc.
@@ -1003,3 +888,58 @@ fn updateDragIcons(cursor: *Cursor) void {
         }
     }
 }
+
+fn queueMotionRelative(listener: *wl.Listener(*wlr.Pointer.event.Motion), event: *wlr.Pointer.event.Motion) void {
+    const cursor: *Cursor = @fieldParentPtr("motion_relative", listener);
+    cursor.seat.queueEvent(.{ .pointer_motion_relative = event.* });
+}
+
+fn queueMotionAbsolute(listener: *wl.Listener(*wlr.Pointer.event.MotionAbsolute), event: *wlr.Pointer.event.MotionAbsolute) void {
+    const cursor: *Cursor = @fieldParentPtr("motion_absolute", listener);
+    cursor.seat.queueEvent(.{ .pointer_motion_absolute = event.* });
+}
+
+fn queueButton(listener: *wl.Listener(*wlr.Pointer.event.Button), event: *wlr.Pointer.event.Button) void {
+    const cursor: *Cursor = @fieldParentPtr("button", listener);
+    cursor.seat.queueEvent(.{ .pointer_button = event.* });
+}
+
+fn queueAxis(listener: *wl.Listener(*wlr.Pointer.event.Axis), event: *wlr.Pointer.event.Axis) void {
+    const cursor: *Cursor = @fieldParentPtr("axis", listener);
+    cursor.seat.queueEvent(.{ .pointer_axis = event.* });
+}
+
+fn queueFrame(listener: *wl.Listener(*wlr.Cursor), _: *wlr.Cursor) void {
+    const cursor: *Cursor = @fieldParentPtr("frame", listener);
+    cursor.seat.queueEvent(.pointer_frame);
+}
+
+fn queuePinchBegin(listener: *wl.Listener(*wlr.Pointer.event.PinchBegin), event: *wlr.Pointer.event.PinchBegin) void {
+    const cursor: *Cursor = @fieldParentPtr("pinch_begin", listener);
+    cursor.seat.queueEvent(.{ .pointer_pinch_begin = event.* });
+}
+
+fn queuePinchUpdate(listener: *wl.Listener(*wlr.Pointer.event.PinchUpdate), event: *wlr.Pointer.event.PinchUpdate) void {
+    const cursor: *Cursor = @fieldParentPtr("pinch_update", listener);
+    cursor.seat.queueEvent(.{ .pointer_pinch_update = event.* });
+}
+
+fn queuePinchEnd(listener: *wl.Listener(*wlr.Pointer.event.PinchEnd), event: *wlr.Pointer.event.PinchEnd) void {
+    const cursor: *Cursor = @fieldParentPtr("pinch_end", listener);
+    cursor.seat.queueEvent(.{ .pointer_pinch_end = event.* });
+}
+
+fn queueSwipeBegin(listener: *wl.Listener(*wlr.Pointer.event.SwipeBegin), event: *wlr.Pointer.event.SwipeBegin) void {
+    const cursor: *Cursor = @fieldParentPtr("swipe_begin", listener);
+    cursor.seat.queueEvent(.{ .pointer_swipe_begin = event.* });
+}
+
+fn queueSwipeUpdate(listener: *wl.Listener(*wlr.Pointer.event.SwipeUpdate), event: *wlr.Pointer.event.SwipeUpdate) void {
+    const cursor: *Cursor = @fieldParentPtr("swipe_update", listener);
+    cursor.seat.queueEvent(.{ .pointer_swipe_update = event.* });
+}
+
+fn queueSwipeEnd(listener: *wl.Listener(*wlr.Pointer.event.SwipeEnd), event: *wlr.Pointer.event.SwipeEnd) void {
+    const cursor: *Cursor = @fieldParentPtr("swipe_end", listener);
+    cursor.seat.queueEvent(.{ .pointer_swipe_end = event.* });
+}
diff --git a/river/ForeignToplevelHandle.zig b/river/ForeignToplevelHandle.zig
index beaa607..6cdb402 100644
--- a/river/ForeignToplevelHandle.zig
+++ b/river/ForeignToplevelHandle.zig
@@ -71,22 +71,18 @@ pub fn update(handle: *ForeignToplevelHandle) void {
 
     const wlr_handle = handle.wlr_handle orelse return;
 
-    wlr_handle.setActivated(window.inflight.focus != 0);
+    wlr_handle.setActivated(window.inflight.activated);
     wlr_handle.setFullscreen(window.inflight.fullscreen);
 }
 
-/// Only honors the request if the window is already visible on the seat's
-/// currently focused output.
 fn handleForeignActivate(
-    listener: *wl.Listener(*wlr.ForeignToplevelHandleV1.event.Activated),
-    event: *wlr.ForeignToplevelHandleV1.event.Activated,
+    _: *wl.Listener(*wlr.ForeignToplevelHandleV1.event.Activated),
+    _: *wlr.ForeignToplevelHandleV1.event.Activated,
 ) void {
-    const handle: *ForeignToplevelHandle = @fieldParentPtr("foreign_activate", listener);
-    const window: *Window = @fieldParentPtr("foreign_toplevel_handle", handle);
-    const seat: *Seat = @ptrFromInt(event.seat.data);
+    //const handle: *ForeignToplevelHandle = @fieldParentPtr("foreign_activate", listener);
+    //const window: *Window = @fieldParentPtr("foreign_toplevel_handle", handle);
 
-    seat.focus(window);
-    server.wm.dirtyPending();
+    // XXX Can I just delete this protocol?
 }
 
 fn handleForeignFullscreen(
diff --git a/river/InputManager.zig b/river/InputManager.zig
index 4ec86d3..db6ef2b 100644
--- a/river/InputManager.zig
+++ b/river/InputManager.zig
@@ -58,7 +58,7 @@ tablet_manager: *wlr.TabletManagerV2,
 configs: std.ArrayList(InputConfig),
 
 devices: wl.list.Head(InputDevice, .link),
-seats: std.TailQueue(Seat) = .{},
+seats: wl.list.Head(Seat, .link),
 
 new_virtual_pointer: wl.Listener(*wlr.VirtualPointerManagerV1.event.NewPointer) =
     wl.Listener(*wlr.VirtualPointerManagerV1.event.NewPointer).init(handleNewVirtualPointer),
@@ -72,9 +72,6 @@ new_text_input: wl.Listener(*wlr.TextInputV3) =
     wl.Listener(*wlr.TextInputV3).init(handleNewTextInput),
 
 pub fn init(input_manager: *InputManager) !void {
-    const seat_node = try util.gpa.create(std.TailQueue(Seat).Node);
-    errdefer util.gpa.destroy(seat_node);
-
     input_manager.* = .{
         // These are automatically freed when the display is destroyed
         .idle_notifier = try wlr.IdleNotifierV1.create(server.wl_server),
@@ -89,11 +86,12 @@ pub fn init(input_manager: *InputManager) !void {
         .configs = std.ArrayList(InputConfig).init(util.gpa),
 
         .devices = undefined,
+        .seats = undefined,
     };
     input_manager.devices.init();
+    input_manager.seats.init();
 
-    input_manager.seats.prepend(seat_node);
-    try seat_node.data.init(default_seat_name);
+    try Seat.create(default_seat_name);
 
     if (build_options.xwayland) {
         if (server.xwayland) |xwayland| {
@@ -119,9 +117,8 @@ pub fn deinit(input_manager: *InputManager) void {
     input_manager.new_input_method.link.remove();
     input_manager.new_text_input.link.remove();
 
-    while (input_manager.seats.pop()) |seat_node| {
-        seat_node.data.deinit();
-        util.gpa.destroy(seat_node);
+    while (input_manager.seats.first()) |seat| {
+        seat.destroy();
     }
 
     for (input_manager.configs.items) |*config| {
@@ -130,8 +127,17 @@ pub fn deinit(input_manager: *InputManager) void {
     input_manager.configs.deinit();
 }
 
-pub fn defaultSeat(input_manager: InputManager) *Seat {
-    return &input_manager.seats.first.?.data;
+pub fn defaultSeat(input_manager: *InputManager) *Seat {
+    return input_manager.seats.first().?;
+}
+
+pub fn processEvents(input_manager: *InputManager) void {
+    assert(server.wm.state == .idle);
+
+    var it = input_manager.seats.iterator(.forward);
+    while (it.next()) |seat| {
+        seat.processEvents();
+    }
 }
 
 /// Reconfigures all devices' libinput configuration as well as their output mapping.
diff --git a/river/Keyboard.zig b/river/Keyboard.zig
index 258d089..799e423 100644
--- a/river/Keyboard.zig
+++ b/river/Keyboard.zig
@@ -31,6 +31,11 @@ const InputDevice = @import("InputDevice.zig");
 
 const log = std.log.scoped(.keyboard);
 
+pub const Event = union(enum) {
+    key: wlr.Keyboard.event.Key,
+    modifiers: wlr.Keyboard.Modifiers,
+};
+
 const KeyConsumer = enum {
     mapping,
     im_grab,
@@ -85,8 +90,8 @@ device: InputDevice,
 /// Pressed keys along with where their press event has been sent
 pressed: Pressed = .{},
 
-key: wl.Listener(*wlr.Keyboard.event.Key) = wl.Listener(*wlr.Keyboard.event.Key).init(handleKey),
-modifiers: wl.Listener(*wlr.Keyboard) = wl.Listener(*wlr.Keyboard).init(handleModifiers),
+key: wl.Listener(*wlr.Keyboard.event.Key) = wl.Listener(*wlr.Keyboard.event.Key).init(queueKey),
+modifiers: wl.Listener(*wlr.Keyboard) = wl.Listener(*wlr.Keyboard).init(queueModifiers),
 
 pub fn init(keyboard: *Keyboard, seat: *Seat, wlr_device: *wlr.InputDevice) !void {
     keyboard.* = .{
@@ -144,9 +149,7 @@ pub fn deinit(keyboard: *Keyboard) void {
     keyboard.* = undefined;
 }
 
-fn handleKey(listener: *wl.Listener(*wlr.Keyboard.event.Key), event: *wlr.Keyboard.event.Key) void {
-    // This event is raised when a key is pressed or released.
-    const keyboard: *Keyboard = @fieldParentPtr("key", listener);
+pub fn processKey(keyboard: *Keyboard, event: *const wlr.Keyboard.event.Key) void {
     const wlr_keyboard = keyboard.device.wlr_device.toKeyboard();
 
     // If the keyboard is in a group, this event will be handled by the group's Keyboard instance.
@@ -157,6 +160,7 @@ fn handleKey(listener: *wl.Listener(*wlr.Keyboard.event.Key), event: *wlr.Keyboa
     // Translate libinput keycode -> xkbcommon
     const keycode = event.keycode + 8;
 
+    // XXX this is not ok, we need to store current modifiers per-Keyboard ourselves
     const modifiers = wlr_keyboard.getModifiers();
     const released = event.state == .released;
 
@@ -229,12 +233,7 @@ fn handleKey(listener: *wl.Listener(*wlr.Keyboard.event.Key), event: *wlr.Keyboa
     if (released) _ = keyboard.device.seat.handleMapping(keycode, modifiers, released, xkb_state);
 }
 
-fn isModifier(keysym: xkb.Keysym) bool {
-    return @intFromEnum(keysym) >= xkb.Keysym.Shift_L and @intFromEnum(keysym) <= xkb.Keysym.Hyper_R;
-}
-
-fn handleModifiers(listener: *wl.Listener(*wlr.Keyboard), _: *wlr.Keyboard) void {
-    const keyboard: *Keyboard = @fieldParentPtr("modifiers", listener);
+pub fn processModifiers(keyboard: *Keyboard, modifiers: *const wlr.Keyboard.Modifiers) void {
     const wlr_keyboard = keyboard.device.wlr_device.toKeyboard();
 
     // If the keyboard is in a group, this event will be handled by the group's Keyboard instance.
@@ -242,10 +241,10 @@ fn handleModifiers(listener: *wl.Listener(*wlr.Keyboard), _: *wlr.Keyboard) void
 
     if (keyboard.getInputMethodGrab()) |keyboard_grab| {
         keyboard_grab.setKeyboard(keyboard_grab.keyboard);
-        keyboard_grab.sendModifiers(&wlr_keyboard.modifiers);
+        keyboard_grab.sendModifiers(modifiers);
     } else {
         keyboard.device.seat.wlr_seat.setKeyboard(keyboard.device.wlr_device.toKeyboard());
-        keyboard.device.seat.wlr_seat.keyboardNotifyModifiers(&wlr_keyboard.modifiers);
+        keyboard.device.seat.wlr_seat.keyboardNotifyModifiers(modifiers);
     }
 }
 
@@ -283,3 +282,13 @@ fn getInputMethodGrab(keyboard: Keyboard) ?*wlr.InputMethodV2.KeyboardGrab {
     }
     return null;
 }
+
+fn queueKey(listener: *wl.Listener(*wlr.Keyboard.event.Key), event: *wlr.Keyboard.event.Key) void {
+    const keyboard: *Keyboard = @fieldParentPtr("key", listener);
+    keyboard.device.seat.queueEvent(.{ .keyboard_key = .{ .keyboard = keyboard, .key = event.* } });
+}
+
+fn queueModifiers(listener: *wl.Listener(*wlr.Keyboard), wlr_keyboard: *wlr.Keyboard) void {
+    const keyboard: *Keyboard = @fieldParentPtr("modifiers", listener);
+    keyboard.device.seat.queueEvent(.{ .keyboard_modifiers = .{ .keyboard = keyboard, .modifiers = wlr_keyboard.modifiers } });
+}
diff --git a/river/LockManager.zig b/river/LockManager.zig
index 466fa6b..98ecbb8 100644
--- a/river/LockManager.zig
+++ b/river/LockManager.zig
@@ -110,10 +110,9 @@ fn handleLock(listener: *wl.Listener(*wlr.SessionLockV1), lock: *wlr.SessionLock
         };
 
         {
-            var it = server.input_manager.seats.first;
-            while (it) |node| : (it = node.next) {
-                const seat = &node.data;
-                seat.setFocusRaw(.none);
+            var it = server.input_manager.seats.iterator(.forward);
+            while (it.next()) |seat| {
+                seat.focus(.none);
             }
         }
     } else {
@@ -196,10 +195,9 @@ fn handleUnlock(listener: *wl.Listener(void)) void {
     server.scene.locked_tree.node.setEnabled(true);
 
     {
-        var it = server.input_manager.seats.first;
-        while (it) |node| : (it = node.next) {
-            const seat = &node.data;
-            seat.setFocusRaw(.none);
+        var it = server.input_manager.seats.iterator(.forward);
+        while (it.next()) |seat| {
+            seat.focus(.none);
         }
     }
 
diff --git a/river/LockSurface.zig b/river/LockSurface.zig
index b146dea..3192ff7 100644
--- a/river/LockSurface.zig
+++ b/river/LockSurface.zig
@@ -62,16 +62,15 @@ pub fn create(wlr_lock_surface: *wlr.SessionLockSurfaceV1, lock: *wlr.SessionLoc
 pub fn destroy(lock_surface: *LockSurface) void {
     {
         var surface_it = lock_surface.lock.surfaces.iterator(.forward);
-        const new_focus: Seat.FocusTarget = while (surface_it.next()) |surface| {
+        const new_focus: Seat.Focus = while (surface_it.next()) |surface| {
             if (surface != lock_surface.wlr_lock_surface)
                 break .{ .lock_surface = @ptrFromInt(surface.data) };
         } else .none;
 
-        var seat_it = server.input_manager.seats.first;
-        while (seat_it) |node| : (seat_it = node.next) {
-            const seat = &node.data;
+        var seat_it = server.input_manager.seats.iterator(.forward);
+        while (seat_it.next()) |seat| {
             if (seat.focused == .lock_surface and seat.focused.lock_surface == lock_surface) {
-                seat.setFocusRaw(new_focus);
+                seat.focus(new_focus);
             }
             seat.cursor.updateState();
         }
@@ -117,11 +116,10 @@ fn handleMap(listener: *wl.Listener(void)) void {
 }
 
 fn updateFocus(lock_surface: *LockSurface) void {
-    var it = server.input_manager.seats.first;
-    while (it) |node| : (it = node.next) {
-        const seat = &node.data;
+    var it = server.input_manager.seats.iterator(.forward);
+    while (it.next()) |seat| {
         if (seat.focused != .lock_surface) {
-            seat.setFocusRaw(.{ .lock_surface = lock_surface });
+            seat.focus(.{ .lock_surface = lock_surface });
         }
         seat.cursor.updateState();
     }
diff --git a/river/Output.zig b/river/Output.zig
index a9d2d09..9b846ab 100644
--- a/river/Output.zig
+++ b/river/Output.zig
@@ -239,13 +239,16 @@ fn handleDestroy(listener: *wl.Listener(*wlr.Output), wlr_output: *wlr.Output) v
     server.wm.dirtyPending();
 }
 
-pub fn sendDirty(output: *Output) !void {
+pub fn sendDirty(output: *Output) void {
     switch (output.pending.state) {
         .enabled, .disabled_soft => {
             if (server.wm.object) |wm_v1| {
                 const new = output.object == null;
                 const output_v1 = output.object orelse blk: {
-                    const output_v1 = try river.OutputV1.create(wm_v1.getClient(), wm_v1.getVersion(), 0);
+                    const output_v1 = river.OutputV1.create(wm_v1.getClient(), wm_v1.getVersion(), 0) catch {
+                        log.err("out of memory", .{});
+                        return; // try again next update
+                    };
                     output.object = output_v1;
 
                     output_v1.setHandler(*Output, handleRequest, null, output);
diff --git a/river/Seat.zig b/river/Seat.zig
index c1694bd..15b2b6a 100644
--- a/river/Seat.zig
+++ b/river/Seat.zig
@@ -1,6 +1,6 @@
 // This file is part of river, a dynamic tiling wayland compositor.
 //
-// Copyright 2020 - 2024 The River Developers
+// Copyright 2020-2024 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
@@ -20,7 +20,9 @@ const build_options = @import("build_options");
 const std = @import("std");
 const assert = std.debug.assert;
 const wlr = @import("wlroots");
-const wl = @import("wayland").server.wl;
+const wayland = @import("wayland");
+const wl = wayland.server.wl;
+const river = wayland.server.river;
 const xkb = @import("xkbcommon");
 
 const server = &@import("main.zig").server;
@@ -44,13 +46,52 @@ const XwaylandOverrideRedirect = @import("XwaylandOverrideRedirect.zig");
 
 const log = std.log.scoped(.seat);
 
-pub const FocusTarget = union(enum) {
+pub const Event = union(enum) {
+    keyboard_key: struct {
+        keyboard: *Keyboard,
+        key: wlr.Keyboard.event.Key,
+    },
+    keyboard_modifiers: struct {
+        keyboard: *Keyboard,
+        modifiers: wlr.Keyboard.Modifiers,
+    },
+
+    pointer_motion_relative: wlr.Pointer.event.Motion,
+    pointer_motion_absolute: wlr.Pointer.event.MotionAbsolute,
+    pointer_button: wlr.Pointer.event.Button,
+    pointer_axis: wlr.Pointer.event.Axis,
+    pointer_frame: void,
+
+    pointer_swipe_begin: wlr.Pointer.event.SwipeBegin,
+    pointer_swipe_update: wlr.Pointer.event.SwipeUpdate,
+    pointer_swipe_end: wlr.Pointer.event.SwipeEnd,
+
+    pointer_pinch_begin: wlr.Pointer.event.PinchBegin,
+    pointer_pinch_update: wlr.Pointer.event.PinchUpdate,
+    pointer_pinch_end: wlr.Pointer.event.PinchEnd,
+};
+
+pub const WmState = struct {
+    focus: WmFocus = .none,
+    // TODO pointer move/resize state
+    // TODO confine region
+    // TODO pointer warp
+    // TODO xkb/pointer bindings
+};
+
+pub const WmFocus = union(enum) {
+    none,
+    window: *Window,
+    // TODO shell_surface: *ShellSurface,
+};
+
+pub const Focus = union(enum) {
     window: *Window,
     override_redirect: if (build_options.xwayland) *XwaylandOverrideRedirect else noreturn,
     lock_surface: *LockSurface,
     none: void,
 
-    pub fn surface(target: FocusTarget) ?*wlr.Surface {
+    pub fn surface(target: Focus) ?*wlr.Surface {
         return switch (target) {
             .window => |window| window.rootSurface(),
             .override_redirect => |override_redirect| override_redirect.xsurface.surface,
@@ -60,16 +101,49 @@ pub const FocusTarget = union(enum) {
     }
 };
 
+/// XXX experiment with different sizes here, consider making dynamic
+/// XXX Clean out events for destroyed input devices
+const EventQueue = std.fifo.LinearFifo(Event, .{ .Static = 1024 });
+
 wlr_seat: *wlr.Seat,
 
-/// Multiple mice are handled by the same Cursor
+link: wl.list.Link,
+
+destroying: bool = false,
+
+object: ?*river.SeatV1 = null,
+
+event_queue: EventQueue = EventQueue.init(),
+
+/// State to be sent to the window manager client in the next update sequence.
+pending: struct {
+    /// The window entered/hovered by the pointer, if any
+    window: ?*Window = null,
+    /// The window clicked on, touched, etc.
+    window_interaction: ?*Window = null,
+} = .{},
+link_pending: wl.list.Link,
+
+/// State sent to the window manager client in the latest update sequence.
+sent: struct {
+    /// The window entered/hovered by the pointer, if any
+    window: ?*Window = null,
+} = .{},
+link_sent: wl.list.Link,
+
+/// State requested by the window manager client but not yet committed.
+uncommitted: WmState = .{},
+/// State requested by the window manager client and committed.
+committed: WmState = .{},
+
+/// Multiple physical mice are handled by the same Cursor
 cursor: Cursor,
-/// Input Method handling
+
 relay: InputRelay,
 
 keyboard_groups: std.TailQueue(KeyboardGroup) = .{},
 
-focused: FocusTarget = .none,
+focused: Focus = .none,
 
 /// The currently in progress drag operation type.
 drag: enum {
@@ -87,15 +161,26 @@ drag_destroy: wl.Listener(*wlr.Drag) = wl.Listener(*wlr.Drag).init(handleDragDes
 request_set_primary_selection: wl.Listener(*wlr.Seat.event.RequestSetPrimarySelection) =
     wl.Listener(*wlr.Seat.event.RequestSetPrimarySelection).init(handleRequestSetPrimarySelection),
 
-pub fn init(seat: *Seat, name: [*:0]const u8) !void {
+pub fn create(name: [*:0]const u8) !void {
+    const seat = try util.gpa.create(Seat);
+    errdefer util.gpa.destroy(seat);
+
     seat.* = .{
         // This will be automatically destroyed when the display is destroyed
         .wlr_seat = try wlr.Seat.create(server.wl_server, name),
+        .link = undefined,
+        .link_pending = undefined,
+        .link_sent = undefined,
         .cursor = undefined,
         .relay = undefined,
     };
     seat.wlr_seat.data = @intFromPtr(seat);
 
+    server.input_manager.seats.append(seat);
+    server.wm.pending.seats.append(seat);
+    seat.link_sent.init();
+    server.wm.dirtyPending();
+
     try seat.cursor.init(seat);
     seat.relay.init();
 
@@ -105,12 +190,16 @@ pub fn init(seat: *Seat, name: [*:0]const u8) !void {
     seat.wlr_seat.events.request_set_primary_selection.add(&seat.request_set_primary_selection);
 }
 
-pub fn deinit(seat: *Seat) void {
+pub fn destroy(seat: *Seat) void {
     {
         var it = server.input_manager.devices.iterator(.forward);
         while (it.next()) |device| assert(device.seat != seat);
     }
 
+    seat.link.remove();
+    seat.link_pending.remove();
+    seat.link_sent.remove();
+
     seat.cursor.deinit();
 
     while (seat.keyboard_groups.first) |node| {
@@ -124,25 +213,179 @@ pub fn deinit(seat: *Seat) void {
     seat.request_set_primary_selection.link.remove();
 }
 
-/// Set the current focus. If a visible window is passed it will be focused.
-/// If null is passed, the top window in the stack of the focused output will be focused.
-/// Requires a call to WindowManager.dirtyPending()
-pub fn focus(seat: *Seat, target: ?*Window) void {
-    // Views may not receive focus while locked.
-    if (server.lock_manager.state != .unlocked) return;
+pub fn queueEvent(seat: *Seat, event: Event) void {
+    seat.handleActivity();
 
-    // Focus the target window or clear the focus if target is null
-    if (target) |window| {
-        seat.setFocusRaw(.{ .window = window });
-    } else {
-        seat.setFocusRaw(.{ .none = {} });
+    seat.event_queue.writeItem(event) catch {
+        log.err("dropping {s} event, no space in event queue", .{@tagName(event)});
+        return;
+    };
+
+    if (server.wm.state == .idle) {
+        seat.processEvents();
+    }
+}
+
+pub fn processEvents(seat: *Seat) void {
+    assert(server.wm.state == .idle);
+
+    var expect_frame: bool = false;
+    while (seat.event_queue.readItem()) |event| {
+        const pg = server.input_manager.pointer_gestures;
+        switch (event) {
+            .keyboard_key => |ev| ev.keyboard.processKey(&ev.key),
+            .keyboard_modifiers => |ev| ev.keyboard.processModifiers(&ev.modifiers),
+
+            .pointer_motion_relative => |ev| seat.cursor.processMotionRelative(&ev),
+            .pointer_motion_absolute => |ev| seat.cursor.processMotionAbsolute(&ev),
+            .pointer_button => |ev| seat.cursor.processButton(&ev),
+            .pointer_axis => |ev| seat.cursor.processAxis(&ev),
+            .pointer_frame => seat.wlr_seat.pointerNotifyFrame(),
+
+            .pointer_swipe_begin => |ev| pg.sendSwipeBegin(seat.wlr_seat, ev.time_msec, ev.fingers),
+            .pointer_swipe_update => |ev| pg.sendSwipeUpdate(seat.wlr_seat, ev.time_msec, ev.dx, ev.dy),
+            .pointer_swipe_end => |ev| pg.sendSwipeEnd(seat.wlr_seat, ev.time_msec, ev.cancelled),
+
+            .pointer_pinch_begin => |ev| pg.sendPinchBegin(seat.wlr_seat, ev.time_msec, ev.fingers),
+            .pointer_pinch_update => |ev| pg.sendPinchUpdate(seat.wlr_seat, ev.time_msec, ev.dx, ev.dy, ev.scale, ev.rotation),
+            .pointer_pinch_end => |ev| pg.sendPinchEnd(seat.wlr_seat, ev.time_msec, ev.cancelled),
+        }
+
+        // Don't split up pointer events grouped by a frame event
+        switch (event) {
+            .pointer_motion_relative,
+            .pointer_motion_absolute,
+            .pointer_button,
+            .pointer_axis,
+            => {
+                expect_frame = true;
+                continue;
+            },
+            .pointer_frame => expect_frame = false,
+            else => assert(!expect_frame),
+        }
+
+        if (server.wm.pending.dirty) {
+            // Wait for feedback from the window manager before further processing.
+            // The window manager might decide to change focus or redefine keyboard/pointer bindings.
+            break;
+        }
+    }
+}
+
+pub fn sendDirty(seat: *Seat) void {
+    if (seat.destroying) {
+        if (seat.object) |seat_v1| {
+            seat_v1.sendRemoved();
+            seat_v1.setHandler(?*anyopaque, handleRequestInert, null, null);
+            seat.object = null;
+        }
+
+        seat.link_pending.remove();
+        seat.link_sent.remove();
+        seat.link_pending.init();
+        seat.link_sent.init();
+
+        seat.destroy();
+        return;
+    }
+
+    if (server.wm.object) |wm_v1| {
+        const new = seat.object == null;
+        const seat_v1 = seat.object orelse blk: {
+            const seat_v1 = river.SeatV1.create(wm_v1.getClient(), wm_v1.getVersion(), 0) catch {
+                log.err("out of memory", .{});
+                return; // try again next update
+            };
+            seat.object = seat_v1;
+
+            seat_v1.setHandler(*Seat, handleRequest, null, seat);
+            wm_v1.sendSeat(seat_v1);
+
+            seat.link_sent.remove();
+            server.wm.sent.seats.append(seat);
+
+            break :blk seat_v1;
+        };
+        errdefer comptime unreachable;
+
+        if (new) {
+            if (seat.pending.window) |window| {
+                if (window.object) |window_v1| {
+                    seat_v1.sendPointerEnter(window_v1);
+                    seat.sent.window = seat.pending.window;
+                }
+            }
+        } else if (seat.pending.window != seat.sent.window) {
+            if (seat.sent.window) |window| {
+                if (window.object) |window_v1| {
+                    seat_v1.sendPointerLeave(window_v1);
+                    seat.sent.window = null;
+                }
+            }
+            if (seat.pending.window) |window| {
+                if (window.object) |window_v1| {
+                    seat_v1.sendPointerEnter(window_v1);
+                    seat.sent.window = window;
+                }
+            }
+        }
+
+        if (seat.pending.window_interaction) |window| {
+            if (window.object) |window_v1| {
+                seat_v1.sendWindowInteraction(window_v1);
+                seat.pending.window_interaction = null;
+            }
+        }
+    }
+}
+
+fn handleRequestInert(
+    seat_v1: *river.SeatV1,
+    request: river.SeatV1.Request,
+    _: ?*anyopaque,
+) void {
+    if (request == .destroy) seat_v1.destroy();
+}
+
+fn handleRequest(
+    seat_v1: *river.SeatV1,
+    request: river.SeatV1.Request,
+    seat: *Seat,
+) void {
+    assert(seat.object == seat_v1);
+    switch (request) {
+        .destroy => {}, // XXX send protocol error
+        .focus_window => |args| {
+            const data = args.window.getUserData() orelse return;
+            const window: *Window = @ptrCast(@alignCast(data));
+            seat.uncommitted.focus = .{ .window = window };
+        },
+        .focus_shell_surface => {},
+        .clear_focus => seat.uncommitted.focus = .none,
+        .pointer_move_window => {},
+        .pointer_resize_window => {},
+        .pointer_confine_to_region => {},
+        .pointer_warp => {},
+        .define_xkb_binding => {},
+        .define_pointer_binding => {},
     }
 }
 
-/// Switch focus to the target, handling unfocus and input inhibition
-/// properly. This should only be called directly if dealing with layers or
-/// override redirect xwayland windows.
-pub fn setFocusRaw(seat: *Seat, new_focus: FocusTarget) void {
+pub fn commitWmState(seat: *Seat) void {
+    seat.committed = seat.uncommitted;
+}
+
+pub fn applyCommitted(seat: *Seat) void {
+    if (server.lock_manager.state == .unlocked) {
+        switch (seat.committed.focus) {
+            .none => seat.focus(.none),
+            .window => |window| seat.focus(.{ .window = window }),
+        }
+    }
+}
+
+pub fn focus(seat: *Seat, new_focus: Focus) void {
     // If the target is already focused, do nothing
     if (std.meta.eql(new_focus, seat.focused)) return;
 
@@ -150,19 +393,13 @@ pub fn setFocusRaw(seat: *Seat, new_focus: FocusTarget) void {
 
     // First clear the current focus
     switch (seat.focused) {
-        .window => |window| {
-            //window.pending.focus -= 1; XXX update focus to send activated state
-            window.destroyPopups();
-        },
+        .window => |window| window.destroyPopups(),
         .override_redirect, .lock_surface, .none => {},
     }
 
     // Set the new focus
     switch (new_focus) {
-        .window => |_| {
-            assert(server.lock_manager.state != .locked);
-            //target_window.pending.focus += 1; XXX update focus to send activated state
-        },
+        .window => assert(server.lock_manager.state != .locked),
         .lock_surface => assert(server.lock_manager.state != .unlocked),
         .override_redirect, .none => {},
     }
@@ -217,6 +454,7 @@ fn keyboardNotifyEnter(seat: *Seat, wlr_surface: *wlr.Surface) void {
         seat.wlr_seat.keyboardNotifyEnter(
             wlr_surface,
             keycodes.constSlice(),
+            // XXX this is not ok, use our own stored modifiers
             &wlr_keyboard.modifiers,
         );
     } else {
diff --git a/river/Server.zig b/river/Server.zig
index 76347bb..2a892b7 100644
--- a/river/Server.zig
+++ b/river/Server.zig
@@ -175,12 +175,12 @@ pub fn init(server: *Server, runtime_xwayland: bool) !void {
         server.xwayland.?.events.new_surface.add(&server.new_xsurface);
     }
 
+    try server.wm.init();
     try server.scene.init();
     try server.om.init();
     try server.input_manager.init();
     try server.idle_inhibit_manager.init();
     try server.lock_manager.init();
-    try server.wm.init();
 
     server.xdg_shell.events.new_toplevel.add(&server.new_xdg_toplevel);
     server.xdg_decoration_manager.events.new_toplevel_decoration.add(&server.new_toplevel_decoration);
diff --git a/river/Window.zig b/river/Window.zig
index 54c2564..7bc65ff 100644
--- a/river/Window.zig
+++ b/river/Window.zig
@@ -1,6 +1,6 @@
 // This file is part of river, a dynamic tiling wayland compositor.
 //
-// Copyright 2020 The River Developers
+// Copyright 2020-2024 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
@@ -69,8 +69,8 @@ pub const State = struct {
     /// The output-relative coordinates of the window and dimensions requested by river.
     box: wlr.Box = .{ .x = 0, .y = 0, .width = 0, .height = 0 },
     hidden: bool = false,
-    /// Number of seats currently focusing the window
-    focus: u32 = 0,
+    /// True if the window has keyboard focus from at least one seat.
+    activated: bool = false,
     ssd: bool = false,
     border: Border = .{},
     tiled: river.WindowV1.Edges = .{},
@@ -228,7 +228,7 @@ pub fn create(impl: Impl) error{OutOfMemory}!*Window {
 pub fn destroy(window: *Window, when: enum { lazy, assert }) void {
     assert(window.impl == .none);
     assert(!window.mapped);
-    assert(window.object == null);
+    assert(window.pending.state == .closing);
 
     window.destroying = true;
 
@@ -293,7 +293,7 @@ pub fn setFullscreenRequested(window: *Window, fullscreen_requested: bool) void
 }
 
 /// Send dirty pending state as part of an in progress update sequence.
-pub fn sendDirty(window: *Window) !void {
+pub fn sendDirty(window: *Window) void {
     assert(window.pending.state != .init);
 
     switch (window.pending.state) {
@@ -324,7 +324,10 @@ pub fn sendDirty(window: *Window) !void {
             const wm_v1 = server.wm.object orelse return;
             const new = window.object == null;
             const window_v1 = window.object orelse blk: {
-                const window_v1 = try river.WindowV1.create(wm_v1.getClient(), wm_v1.getVersion(), 0);
+                const window_v1 = river.WindowV1.create(wm_v1.getClient(), wm_v1.getVersion(), 0) catch {
+                    log.err("out of memory", .{});
+                    return; // try again next update
+                };
                 window.object = window_v1;
                 window_v1.setHandler(*Window, handleRequest, null, window);
                 wm_v1.sendWindow(window_v1);
@@ -452,11 +455,12 @@ pub fn resizeUpdatePosition(window: *Window, width: i32, height: i32) void {
     assert(window.inflight.resizing);
 
     const data = blk: {
-        var it = server.input_manager.seats.first;
-        while (it) |node| : (it = node.next) {
-            const cursor = &node.data.cursor;
-            if (cursor.inflight_mode == .resize and cursor.inflight_mode.resize.window == window) {
-                break :blk cursor.inflight_mode.resize;
+        var it = server.input_manager.seats.iterator(.forward);
+        while (it.next()) |seat| {
+            if (seat.cursor.inflight_mode == .resize and
+                seat.cursor.inflight_mode.resize.window == window)
+            {
+                break :blk seat.cursor.inflight_mode.resize;
             }
         } else {
             // The window resizing state should never be set when the window is
@@ -602,6 +606,16 @@ pub fn configure(window: *Window) bool {
 
     assert(!window.destroying);
 
+    const activated = blk: {
+        var it = server.wm.sent.seats.iterator(.forward);
+        while (it.next()) |seat| {
+            if (seat.committed.focus == .window and seat.committed.focus.window == window) {
+                break :blk true;
+            }
+        }
+        break :blk false;
+    };
+
     const committed = &window.committed;
     window.inflight = .{
         .box = .{
@@ -611,7 +625,7 @@ pub fn configure(window: *Window) bool {
             .height = if (committed.proposed) |p| p.height else window.pending.box.height,
         },
         .hidden = committed.hidden,
-        .focus = 0, // XXX
+        .activated = activated,
         .ssd = committed.ssd,
         .border = committed.border,
         .tiled = committed.tiled,
diff --git a/river/WindowManager.zig b/river/WindowManager.zig
index e91d7e9..0e7b1d1 100644
--- a/river/WindowManager.zig
+++ b/river/WindowManager.zig
@@ -26,6 +26,7 @@ const server = &@import("main.zig").server;
 const util = @import("util.zig");
 
 const Output = @import("Output.zig");
+const Seat = @import("Seat.zig");
 const Window = @import("Window.zig");
 const WmNode = @import("WmNode.zig");
 
@@ -59,12 +60,16 @@ pending: struct {
 
     outputs: wl.list.Head(Output, .link_pending),
     output_config: ?*wlr.OutputConfigurationV1 = null,
+
+    seats: wl.list.Head(Seat, .link_pending),
 },
 
 /// State sent to the wm in the latest update sequence.
 sent: struct {
     outputs: wl.list.Head(Output, .link_sent),
     output_config: ?*wlr.OutputConfigurationV1 = null,
+
+    seats: wl.list.Head(Seat, .link_sent),
 },
 
 /// State sent by the wm but not yet committed with a commit request.
@@ -74,6 +79,7 @@ uncommitted: struct {
 
 /// State sent by the wm and committed with a commit request.
 committed: struct {
+    // The wm has committed state since state was last sent to windows.
     dirty: bool = false,
     render_list: wl.list.Head(WmNode, .link_committed),
 },
@@ -99,9 +105,11 @@ pub fn init(wm: *WindowManager) !void {
         .pending = .{
             .dirty_windows = undefined,
             .outputs = undefined,
+            .seats = undefined,
         },
         .sent = .{
             .outputs = undefined,
+            .seats = undefined,
         },
         .uncommitted = .{
             .render_list = undefined,
@@ -117,7 +125,9 @@ pub fn init(wm: *WindowManager) !void {
     wm.windows.init();
     wm.pending.dirty_windows.init();
     wm.pending.outputs.init();
+    wm.pending.seats.init();
     wm.sent.outputs.init();
+    wm.sent.seats.init();
     wm.uncommitted.render_list.init();
     wm.committed.render_list.init();
     wm.inflight.render_list.init();
@@ -147,7 +157,7 @@ fn bind(client: *wl.Client, wm: *WindowManager, version: u32, id: u32) void {
 
     wm.object = object;
     object.setHandler(*WindowManager, handleRequest, null, wm);
-    // XXX send existing windows?
+    // XXX send existing windows outputs and seats, including output dimensions
 }
 
 fn handleRequestInert(
@@ -195,6 +205,11 @@ fn handleRequest(
                 }
             }
 
+            {
+                var it = wm.sent.seats.iterator(.forward);
+                while (it.next()) |seat| seat.commitWmState();
+            }
+
             wm.committed.dirty = true;
             switch (wm.state) {
                 .idle, .update_acked => {
@@ -204,7 +219,6 @@ fn handleRequest(
                 .update_sent, .inflight_configures => {},
             }
         },
-        .get_seat => |_| {},
         .get_shell_surface => |_| {},
     }
 }
@@ -239,17 +253,10 @@ fn sendUpdate(wm: *WindowManager) void {
 
     log.debug("sending update to window manager", .{});
 
-    // XXX send all dirty pending state
-
     wm.autoLayoutOutputs();
     {
         var it = wm.pending.outputs.safeIterator(.forward);
-        while (it.next()) |output| {
-            output.sendDirty() catch {
-                log.err("out of memory", .{});
-                continue; // Try again next update
-            };
-        }
+        while (it.next()) |output| output.sendDirty();
     }
 
     assert(wm.sent.output_config == null);
@@ -258,12 +265,12 @@ fn sendUpdate(wm: *WindowManager) void {
 
     {
         var it = wm.pending.dirty_windows.safeIterator(.forward);
-        while (it.next()) |window| {
-            window.sendDirty() catch {
-                log.err("out of memory", .{});
-                continue; // Try again next update
-            };
-        }
+        while (it.next()) |window| window.sendDirty();
+    }
+
+    {
+        var it = wm.pending.seats.safeIterator(.forward);
+        while (it.next()) |seat| seat.sendDirty();
     }
 
     wm.pending.dirty = false;
@@ -319,6 +326,11 @@ fn sendConfigures(wm: *WindowManager) void {
     assert(wm.committed.dirty);
     wm.committed.dirty = false;
 
+    {
+        var it = wm.sent.seats.iterator(.forward);
+        while (it.next()) |seat| seat.applyCommitted();
+    }
+
     wm.state = .{ .inflight_configures = 0 };
     {
         var it = wm.committed.render_list.iterator(.forward);
@@ -414,8 +426,8 @@ fn commitTransaction(wm: *WindowManager) void {
     server.om.commitOutputState();
 
     {
-        var it = server.input_manager.seats.first;
-        while (it) |node| : (it = node.next) node.data.cursor.updateState();
+        var it = server.input_manager.seats.iterator(.forward);
+        while (it.next()) |seat| seat.cursor.updateState();
     }
 
     server.idle_inhibit_manager.checkActive();
@@ -426,5 +438,7 @@ fn commitTransaction(wm: *WindowManager) void {
         wm.sendConfigures();
     } else if (wm.pending.dirty) {
         wm.sendUpdate();
+    } else {
+        server.input_manager.processEvents();
     }
 }
diff --git a/river/XdgToplevel.zig b/river/XdgToplevel.zig
index 517766e..ce7df68 100644
--- a/river/XdgToplevel.zig
+++ b/river/XdgToplevel.zig
@@ -147,7 +147,7 @@ pub fn configure(toplevel: *XdgToplevel) bool {
 
     const wlr_toplevel = toplevel.wlr_toplevel;
 
-    _ = wlr_toplevel.setActivated(inflight.focus != 0);
+    _ = wlr_toplevel.setActivated(inflight.activated);
     _ = wlr_toplevel.setTiled(.{
         .top = inflight.tiled.top,
         .bottom = inflight.tiled.bottom,
@@ -205,7 +205,7 @@ fn needsConfigure(toplevel: *XdgToplevel) bool {
         return true;
     }
 
-    if ((inflight.focus != 0) != (current.focus != 0)) return true;
+    if (inflight.activated != current.activated) return true;
     if (inflight.ssd != current.ssd) return true;
     if (!std.meta.eql(inflight.tiled, current.tiled)) return true;
     if (!std.meta.eql(inflight.capabilities, current.capabilities)) return true;
diff --git a/river/XwaylandOverrideRedirect.zig b/river/XwaylandOverrideRedirect.zig
index 639dc38..c3f5161 100644
--- a/river/XwaylandOverrideRedirect.zig
+++ b/river/XwaylandOverrideRedirect.zig
@@ -165,9 +165,8 @@ fn handleUnmap(listener: *wl.Listener(void)) void {
 
     // If the unmapped surface is currently focused, pass keyboard focus
     // to the most appropriate surface.
-    var seat_it = server.input_manager.seats.first;
-    while (seat_it) |seat_node| : (seat_it = seat_node.next) {
-        const seat = &seat_node.data;
+    var seat_it = server.input_manager.seats.iterator(.forward);
+    while (seat_it.next()) |seat| {
         if (seat.focused == .window and seat.focused.window.impl == .xwayland and
             seat.focused.window.impl.xwayland.xsurface.pid == override_redirect.xsurface.pid and
             seat.wlr_seat.keyboard_state.focused_surface == override_redirect.xsurface.surface)
diff --git a/rivercompat/WindowManager.zig b/rivercompat/WindowManager.zig
index 44d4311..ce7d5b8 100644
--- a/rivercompat/WindowManager.zig
+++ b/rivercompat/WindowManager.zig
@@ -47,5 +47,8 @@ fn handleEvent(wm_v1: *river.WindowManagerV1, event: river.WindowManagerV1.Event
         .output => |args| {
             _ = args;
         },
+        .seat => |args| {
+            _ = args;
+        },
     }
 }