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

commit1a9801707ccb03d79adf300e1846a6fc0b048d63
parent68df95b081
authorIsaac Freund <[email protected]>
date2025-12-22 19:27
protocol: implement river-input-management-v1

 build.zig                              |   2 +
 build.zig.zon                          |   4 +-
 protocol/river-input-management-v1.xml | 233 +++++++++++++++++++++++++++++++++
 river/Config.zig                       |   9 --
 river/Cursor.zig                       |  10 +-
 river/InputDevice.zig                  | 202 +++++++++++++++++++++-------
 river/InputManager.zig                 | 103 ++++++++++++---
 river/Keyboard.zig                     | 109 ++++++++++-----
 river/KeyboardGroup.zig                |  10 +-
 river/Output.zig                       |   9 ++
 river/OutputManager.zig                |   2 -
 river/PointerBinding.zig               |  13 ++
 river/Seat.zig                         | 103 ++++++++++++---
 river/Server.zig                       |   1 +
 river/Tablet.zig                       |   9 +-
 river/TabletTool.zig                   |  20 ++-
 river/WindowManager.zig                |   1 +
 river/XkbBinding.zig                   |  13 ++
 18 files changed, 707 insertions(+), 146 deletions(-)

diff --git a/build.zig b/build.zig
index 2cc435f..a689d0d 100644
--- a/build.zig
+++ b/build.zig
@@ -79,6 +79,7 @@ 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/river-input-management-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"));
@@ -108,6 +109,7 @@ pub fn build(b: *Build) !void {
     scanner.generate("river_window_manager_v1", 2);
     scanner.generate("river_xkb_bindings_v1", 1);
     scanner.generate("river_layer_shell_v1", 1);
+    scanner.generate("river_input_manager_v1", 1);
 
     scanner.generate("zwlr_output_power_manager_v1", 1);
     scanner.generate("zwlr_layer_shell_v1", 4);
diff --git a/build.zig.zon b/build.zig.zon
index e27b6b6..a5a1fd2 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -14,8 +14,8 @@
             .hash = "pixman-0.3.0-LClMnz2VAAAs7QSCGwLimV5VUYx0JFnX5xWU6HwtMuDX",
         },
         .wayland = .{
-            .url = "https://codeberg.org/ifreund/zig-wayland/archive/v0.4.0.tar.gz",
-            .hash = "wayland-0.4.0-lQa1khbMAQAsLS2eBR7M5lofyEGPIbu2iFDmoz8lPC27",
+            .url = "git+https://codeberg.org/ifreund/zig-wayland?ref=main#52b54b71f1665b1b7ba61bf71a9319726e618fb6",
+            .hash = "wayland-0.5.0-dev-lQa1ki3YAQCEdP63PCLFnVQzfXAixkrXsPavlEl8UkPe",
         },
         .wlroots = .{
             .url = "https://codeberg.org/ifreund/zig-wlroots/archive/v0.19.3.tar.gz",
diff --git a/protocol/river-input-management-v1.xml b/protocol/river-input-management-v1.xml
new file mode 100644
index 0000000..62b04a3
--- /dev/null
+++ b/protocol/river-input-management-v1.xml
@@ -0,0 +1,233 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="river_input_management_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="configure input devices">
+    This protocol supports creating/destroying seats, assigning input devices to
+    seats, and configuring input devices (e.g. setting keyboard repeat rate).
+
+    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_input_manager_v1" version="1">
+    <description summary="input manager global interface">
+      Input manager global interface.
+    </description>
+
+    <enum name="error">
+      <entry name="invalid_destroy" value="0"/>
+    </enum>
+
+    <request name="stop">
+      <description summary="stop sending events">
+        This request indicates that the client no longer wishes to receive
+        events on this object.
+
+        The Wayland protocol is asynchronous, which means the server may send
+        further events until the stop request is processed. The client must wait
+        for a river_input_manager_v1.finished event before destroying this
+        object.
+      </description>
+    </request>
+
+    <event name="finished">
+      <description summary="the server has finished with the input manager">
+        This event indicates that the server will send no further events on this
+        object. The client should destroy the object. See
+        river_input_manager_v1.destroy for more information.
+      </description>
+    </event>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the river_input_manager_v1 object">
+        This request should be called after the finished event has been received
+        to complete destruction of the object.
+
+        It is a protocol error to make this request before the finished event
+        has been received.
+
+        If a client wishes to destroy this object it should send a
+        river_input_manager_v1.stop request and wait for a
+        river_input_manager_v1.finished event. Once the finished event is
+        received it is safe to destroy this object and any other objects created
+        through this interface.
+      </description>
+    </request>
+
+    <request name="create_seat">
+      <description summary="create a new seat">
+        Create a new seat with the given name. Has no effect if a seat with the
+        given name already exists.
+
+        The default seat with name "default" always exists and does not need to
+        be explicitly created.
+      </description>
+      <arg name="name" type="string"/>
+    </request>
+
+    <request name="destroy_seat">
+      <description summary="destroy a seat">
+        Destroy the seat with the given name. Has no effect if a seat with the
+        given name does not exist.
+
+        The default seat with name "default" cannot be destroyed and attempting
+        to destroy it will have no effect.
+
+        Any input devices assigned to the destroyed seat at the time of
+        destruction are assigned to the default seat.
+      </description>
+      <arg name="name" type="string"/>
+    </request>
+
+    <event name="input_device">
+      <description summary="new input device">
+        A new input device has been created.
+      </description>
+      <arg name="id" type="new_id" interface="river_input_device_v1"/>
+    </event>
+  </interface>
+
+  <interface name="river_input_device_v1" version="1">
+    <description summary="an input device">
+      An input device represents a physical keyboard, mouse, touchscreen, or
+      drawing tablet tool. It is assigned to exactly one seat at a time.
+      By default, all input devices are assigned to the default seat.
+    </description>
+
+    <enum name="error">
+      <entry name="invalid_repeat_info" value="0"/>
+      <entry name="invalid_scroll_factor" value="1"/>
+      <entry name="invalid_map_to_rectangle" value="2"/>
+    </enum>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the input device object">
+        This request indicates that the client will no longer use the input
+        device object and that it may be safely destroyed.
+      </description>
+    </request>
+
+    <event name="removed">
+      <description summary="the input device is removed">
+        This event indicates that the input device has been removed.
+
+        The server will send no further events on this object and ignore any
+        request (other than river_input_device_v1.destroy) made after this event is
+        sent. The client should destroy this object with the
+        river_input_device_v1.destroy request to free up resources.
+      </description>
+    </event>
+
+    <enum name="type">
+      <entry name="keyboard" value="0"/>
+      <entry name="pointer" value="1"/>
+      <entry name="touch" value="2"/>
+      <entry name="tablet" value="3"/>
+    </enum>
+
+    <event name="type">
+      <description summary="the type of the input device">
+        The type of the input device. This event is sent once when the
+        river_input_device_v1 object is created. The device type cannot
+        change during the lifetime of the object.
+      </description>
+      <arg name="type" type="uint" enum="type"/>
+    </event>
+
+    <event name="name">
+      <description summary="the name of the input device">
+        The name of the input device. This event is sent once when the
+        river_input_device_v1 object is created. The device name cannot
+        change during the lifetime of the object.
+      </description>
+      <arg name="name" type="string"/>
+    </event>
+
+    <request name="assign_to_seat">
+      <description summary="assign the input device to a seat">
+        Assign the input device to a seat. All input devices not explicitly
+        assigned to a seat are considered assigned to the default seat.
+
+        Has no effect if a seat with the given name does not exist.
+      </description>
+      <arg name="name" type="string" summary="name of the seat"/>
+    </request>
+
+    <request name="set_repeat_info">
+      <description summary="set keyboard repeat rate and delay">
+        Set repeat rate and delay for a keyboard input device. Has no effect if
+        the device is not a keyboard.
+
+        Negative values for either rate or delay are illegal. A rate of zero
+        will disable any repeating (regardless of the value of delay).
+      </description>
+      <arg name="rate" type="int" summary="rate in key repeats per second"/>
+      <arg name="delay" type="int" summary="delay in milliseconds"/>
+    </request>
+
+    <request name="set_scroll_factor">
+      <description summary="set scroll factor">
+        Set the scroll factor for a pointer input device. Has no effect if the
+        device is not a pointer.
+
+        For example, a factor of 0.5 will make scrolling twice as slow while a
+        factor of 3.0 will make scrolling 3 times as fast.
+
+        Negative values for either rate or delay are illegal. A rate of zero
+        will disable any repeating (regardless of the value of delay).
+      </description>
+      <arg name="factor" type="fixed"/>
+    </request>
+
+    <request name="map_to_output">
+      <description summary="map input device to the given output">
+        Map the input device to the given output. Has no effect if the device is
+        not a pointer, touch, or tablet device.
+
+        If mapped to both an output and a rectangle, the rectangle has priority.
+
+        Passing null clears an existing mapping.
+      </description>
+      <arg name="output" type="object" interface="wl_output" allow-null="true"/>
+    </request>
+
+    <request name="map_to_rectangle">
+      <description summary="map input device to the given rectangle">
+        Map the input device to the given rectangle in the global compositor
+        coordinate space. Has no effect if the device is not a pointer, touch,
+        or tablet device.
+
+        If mapped to both an output and a rectangle, the rectangle has priority.
+
+        Width and height must be greater than or equal to 0.
+
+        Passing 0 for width or height clears an existing mapping.
+      </description>
+      <arg name="x" type="int"/>
+      <arg name="y" type="int"/>
+      <arg name="width" type="int"/>
+      <arg name="height" type="int"/>
+    </request>
+  </interface>
+</protocol>
diff --git a/river/Config.zig b/river/Config.zig
index 73fe9b4..06dc795 100644
--- a/river/Config.zig
+++ b/river/Config.zig
@@ -27,15 +27,6 @@ const util = @import("util.zig");
 
 const Server = @import("Server.zig");
 
-/// Color of background in RGBA with premultiplied alpha (alpha should only affect nested sessions)
-background_color: [4]f32 = [_]f32{ 0.0, 0.16862745, 0.21176471, 1.0 }, // Solarized base03
-
-/// Keyboard repeat rate in characters per second
-repeat_rate: u31 = 25,
-
-/// Keyboard repeat delay in milliseconds
-repeat_delay: u31 = 600,
-
 xkb_context: *xkb.Context,
 /// The xkb keymap used for all keyboards
 keymap: *xkb.Keymap,
diff --git a/river/Cursor.zig b/river/Cursor.zig
index 6b61d4c..ad9b899 100644
--- a/river/Cursor.zig
+++ b/river/Cursor.zig
@@ -528,15 +528,7 @@ pub fn processAxis(cursor: *Cursor, event: *const wlr.Pointer.event.Axis) void {
         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.
-            @as(f32, @floatFromInt(math.minInt(i32) / 2)),
-            @as(f32, @floatFromInt(math.maxInt(i32) / 2)),
-        )),
+        math.lossyCast(i32, @as(f64, @floatFromInt(event.delta_discrete)) * device.config.scroll_factor),
         event.source,
         event.relative_direction,
     );
diff --git a/river/InputDevice.zig b/river/InputDevice.zig
index d42a6ee..0a5c66f 100644
--- a/river/InputDevice.zig
+++ b/river/InputDevice.zig
@@ -1,6 +1,6 @@
 // This file is part of river, a dynamic tiling wayland compositor.
 //
-// Copyright 2022 The River Developers
+// Copyright 2022 - 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
@@ -17,10 +17,11 @@
 const InputDevice = @This();
 
 const std = @import("std");
+const assert = std.debug.assert;
 const mem = std.mem;
-const ascii = std.ascii;
 const wlr = @import("wlroots");
 const wl = @import("wayland").server.wl;
+const river = @import("wayland").server.river;
 
 const c = @import("c.zig").c;
 const server = &@import("main.zig").server;
@@ -34,69 +35,71 @@ const log = std.log.scoped(.input);
 
 seat: *Seat,
 wlr_device: *wlr.InputDevice,
+objects: wl.list.Head(river.InputDeviceV1, null),
 
-destroy: wl.Listener(*wlr.InputDevice) = .init(handleDestroy),
-
-/// Careful: The identifier is not unique! A physical input device may have
-/// multiple logical input devices with the exact same vendor id, product id
-/// and name. However identifiers of InputConfigs are unique.
-identifier: []const u8,
+remove: wl.Listener(*wlr.InputDevice) = .init(handleRemove),
 
 config: struct {
-    scroll_factor: f32 = 1.0,
+    scroll_factor: f64 = 1.0,
+    map_to_output: ?*wlr.Output = null,
+    map_to_rectangle: wlr.Box = .{ .x = 0, .y = 0, .width = 0, .height = 0 },
 } = .{},
 
 /// InputManager.devices
 link: wl.list.Link,
 
-pub fn init(device: *InputDevice, seat: *Seat, wlr_device: *wlr.InputDevice) !void {
-    var vendor: c_uint = 0;
-    var product: c_uint = 0;
-
-    if (wlr_device.getLibinputDevice()) |d| {
-        vendor = c.libinput_device_get_id_vendor(@ptrCast(d));
-        product = c.libinput_device_get_id_product(@ptrCast(d));
-    }
-
-    const identifier = try std.fmt.allocPrint(
-        util.gpa,
-        "{s}-{}-{}-{s}",
-        .{
-            @tagName(wlr_device.type),
-            vendor,
-            product,
-            mem.trim(u8, mem.sliceTo(wlr_device.name orelse "unknown", 0), &ascii.whitespace),
-        },
-    );
-    errdefer util.gpa.free(identifier);
-
-    for (identifier) |*char| {
-        if (!ascii.isPrint(char.*) or ascii.isWhitespace(char.*)) {
-            char.* = '_';
-        }
-    }
-
+pub fn init(
+    device: *InputDevice,
+    seat: *Seat,
+    wlr_device: *wlr.InputDevice,
+    virtual: bool,
+) !void {
     device.* = .{
         .seat = seat,
         .wlr_device = wlr_device,
-        .identifier = identifier,
+        .objects = undefined,
         .link = undefined,
     };
+    device.objects.init();
+    server.input_manager.devices.append(device);
 
     wlr_device.data = device;
+    wlr_device.events.destroy.add(&device.remove);
 
-    wlr_device.events.destroy.add(&device.destroy);
+    log.debug("new input device: {s}-{s}", .{
+        @tagName(wlr_device.type),
+        wlr_device.name orelse "unknown",
+    });
 
-    server.input_manager.devices.append(device);
-    seat.updateCapabilities();
+    if (!virtual) {
+        var it = server.input_manager.objects.safeIterator(.forward);
+        while (it.next()) |im_v1| device.createObject(im_v1);
+    }
+}
 
-    log.debug("new input device: {s}", .{identifier});
+pub fn createObject(device: *InputDevice, im_v1: *river.InputManagerV1) void {
+    const device_type: river.InputDeviceV1.Type = switch (device.wlr_device.type) {
+        .keyboard => .keyboard,
+        .pointer => .pointer,
+        .touch => .touch,
+        .tablet => .tablet,
+        .@"switch", .tablet_pad => return,
+    };
+    const object = river.InputDeviceV1.create(im_v1.getClient(), im_v1.getVersion(), 0) catch {
+        log.err("out of memory", .{});
+        im_v1.postNoMemory();
+        return;
+    };
+    im_v1.sendInputDevice(object);
+    device.objects.append(object);
+    object.setHandler(*InputDevice, handleRequest, handleDestroy, device);
+    object.sendType(device_type);
+    object.sendName(device.wlr_device.name orelse "");
 }
 
 pub fn deinit(device: *InputDevice) void {
-    device.destroy.link.remove();
-
-    util.gpa.free(device.identifier);
+    assert(device.objects.empty());
+    device.remove.link.remove();
 
     device.link.remove();
     device.seat.updateCapabilities();
@@ -106,10 +109,31 @@ pub fn deinit(device: *InputDevice) void {
     device.* = undefined;
 }
 
-fn handleDestroy(listener: *wl.Listener(*wlr.InputDevice), _: *wlr.InputDevice) void {
-    const device: *InputDevice = @fieldParentPtr("destroy", listener);
+pub fn assignToSeat(device: *InputDevice, new: *Seat) void {
+    const old = device.seat;
+    if (new == old) return;
+    old.detachDevice(device);
+    new.attachDevice(device);
+    old.updateCapabilities();
+    new.updateCapabilities();
+}
 
-    log.debug("removed input device: {s}", .{device.identifier});
+fn handleRemove(listener: *wl.Listener(*wlr.InputDevice), _: *wlr.InputDevice) void {
+    const device: *InputDevice = @fieldParentPtr("remove", listener);
+
+    log.debug("removed input device: {s}-{s}", .{
+        @tagName(device.wlr_device.type),
+        device.wlr_device.name orelse "unknown",
+    });
+
+    {
+        var it = device.objects.iterator(.forward);
+        while (it.next()) |object| {
+            object.getLink().remove();
+            object.sendRemoved();
+            object.setHandler(?*anyopaque, handleRequestInert, null, null);
+        }
+    }
 
     switch (device.wlr_device.type) {
         .keyboard => {
@@ -127,3 +151,87 @@ fn handleDestroy(listener: *wl.Listener(*wlr.InputDevice), _: *wlr.InputDevice)
         .@"switch", .tablet_pad => unreachable,
     }
 }
+
+fn handleRequestInert(
+    object: *river.InputDeviceV1,
+    request: river.InputDeviceV1.Request,
+    _: ?*anyopaque,
+) void {
+    if (request == .destroy) object.destroy();
+}
+
+fn handleDestroy(object: *river.InputDeviceV1, _: *InputDevice) void {
+    object.getLink().remove();
+}
+
+fn handleRequest(
+    object: *river.InputDeviceV1,
+    request: river.InputDeviceV1.Request,
+    device: *InputDevice,
+) void {
+    switch (request) {
+        .destroy => object.destroy(),
+        .assign_to_seat => |args| {
+            var it = server.input_manager.seats.iterator(.forward);
+            while (it.next()) |seat| {
+                if (mem.orderZ(u8, args.name, seat.wlr_seat.name) == .eq) {
+                    device.assignToSeat(seat);
+                }
+            }
+            log.info("client requested input device be assigned to non-existant seat '{s}'", .{args.name});
+        },
+        .set_repeat_info => |args| {
+            if (args.rate < 0 or args.delay < 0) {
+                object.postError(.invalid_repeat_info, "negative rate/delay");
+                return;
+            }
+            if (device.wlr_device.type == .keyboard) {
+                const keyboard: *Keyboard = @fieldParentPtr("device", device);
+                keyboard.setRepeatInfo(@intCast(args.rate), @intCast(args.delay));
+            }
+        },
+        .set_scroll_factor => |args| {
+            const factor = args.factor.toDouble();
+            if (factor < 0) {
+                object.postError(.invalid_scroll_factor, "negative scroll factor");
+                return;
+            }
+            device.config.scroll_factor = factor;
+        },
+        .map_to_output => |args| {
+            switch (device.wlr_device.type) {
+                .pointer, .touch, .tablet => {},
+                .keyboard, .@"switch", .tablet_pad => return,
+            }
+            if (args.output) |output| {
+                device.config.map_to_output = wlr.Output.fromWlOutput(output) orelse return;
+            } else {
+                device.config.map_to_output = null;
+            }
+            device.seat.cursor.wlr_cursor.mapInputToOutput(
+                device.wlr_device,
+                device.config.map_to_output,
+            );
+        },
+        .map_to_rectangle => |args| {
+            if (args.width < 0 or args.height < 0) {
+                object.postError(.invalid_map_to_rectangle, "negative rectangle width/height");
+                return;
+            }
+            switch (device.wlr_device.type) {
+                .pointer, .touch, .tablet => {},
+                .keyboard, .@"switch", .tablet_pad => return,
+            }
+            device.config.map_to_rectangle = .{
+                .x = args.x,
+                .y = args.y,
+                .width = args.width,
+                .height = args.height,
+            };
+            device.seat.cursor.wlr_cursor.mapInputToRegion(
+                device.wlr_device,
+                &device.config.map_to_rectangle,
+            );
+        },
+    }
+}
diff --git a/river/InputManager.zig b/river/InputManager.zig
index 8163dad..dea333e 100644
--- a/river/InputManager.zig
+++ b/river/InputManager.zig
@@ -1,6 +1,6 @@
 // This file is part of river, a dynamic tiling wayland compositor.
 //
-// Copyright 2020 - 2021 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
@@ -22,6 +22,7 @@ const assert = std.debug.assert;
 const mem = std.mem;
 const wlr = @import("wlroots");
 const wl = @import("wayland").server.wl;
+const river = @import("wayland").server.river;
 
 const server = &@import("main.zig").server;
 const util = @import("util.zig");
@@ -37,6 +38,9 @@ const default_seat_name = "default";
 
 const log = std.log.scoped(.input);
 
+global: *wl.Global,
+objects: wl.list.Head(river.InputManagerV1, null),
+
 new_input: wl.Listener(*wlr.InputDevice) = .init(handleNewInput),
 
 idle_notifier: *wlr.IdleNotifierV1,
@@ -60,6 +64,7 @@ new_text_input: wl.Listener(*wlr.TextInputV3) = .init(handleNewTextInput),
 
 pub fn init(input_manager: *InputManager) !void {
     input_manager.* = .{
+        .global = try wl.Global.create(server.wl_server, river.InputManagerV1, 1, *InputManager, input_manager, bind),
         // These are automatically freed when the display is destroyed
         .idle_notifier = try wlr.IdleNotifierV1.create(server.wl_server),
         .relative_pointer_manager = try wlr.RelativePointerManagerV1.create(server.wl_server),
@@ -71,9 +76,11 @@ pub fn init(input_manager: *InputManager) !void {
         .text_input_manager = try wlr.TextInputManagerV3.create(server.wl_server),
         .tablet_manager = try wlr.TabletManagerV2.create(server.wl_server),
 
+        .objects = undefined,
         .devices = undefined,
         .seats = undefined,
     };
+    input_manager.objects.init();
     input_manager.devices.init();
     input_manager.seats.init();
 
@@ -94,7 +101,10 @@ pub fn init(input_manager: *InputManager) !void {
 }
 
 pub fn deinit(input_manager: *InputManager) void {
+    input_manager.global.destroy();
+
     // This function must be called after the backend has been destroyed
+    assert(input_manager.objects.empty());
     assert(input_manager.devices.empty());
 
     input_manager.new_virtual_pointer.link.remove();
@@ -108,6 +118,76 @@ pub fn deinit(input_manager: *InputManager) void {
     }
 }
 
+fn bind(client: *wl.Client, im: *InputManager, version: u32, id: u32) void {
+    const im_v1 = river.InputManagerV1.create(client, version, id) catch {
+        client.postNoMemory();
+        log.err("out of memory", .{});
+        return;
+    };
+    im_v1.setHandler(*InputManager, handleRequest, handleDestroy, im);
+    im.objects.append(im_v1);
+    {
+        var it = im.devices.iterator(.forward);
+        while (it.next()) |device| device.createObject(im_v1);
+    }
+}
+
+fn handleRequestInert(
+    im_v1: *river.InputManagerV1,
+    request: river.InputManagerV1.Request,
+    _: ?*anyopaque,
+) void {
+    if (request == .destroy) im_v1.destroy();
+}
+
+fn handleDestroy(im_v1: *river.InputManagerV1, _: *InputManager) void {
+    im_v1.getLink().remove();
+}
+
+fn handleRequest(
+    im_v1: *river.InputManagerV1,
+    request: river.InputManagerV1.Request,
+    im: *InputManager,
+) void {
+    switch (request) {
+        .stop => {
+            im_v1.getLink().remove();
+            im_v1.sendFinished();
+            im_v1.setHandler(?*anyopaque, handleRequestInert, null, null);
+        },
+        .destroy => {
+            im_v1.postError(.invalid_destroy, "destroy before finished event sent");
+        },
+        .create_seat => |args| {
+            var it = im.seats.iterator(.forward);
+            while (it.next()) |seat| {
+                if (mem.orderZ(u8, args.name, seat.wlr_seat.name) == .eq) {
+                    break;
+                }
+            } else {
+                Seat.create(args.name) catch |err| switch (err) {
+                    error.OutOfMemory => {
+                        im_v1.getClient().postNoMemory();
+                        log.err("out of memory", .{});
+                        return;
+                    },
+                };
+            }
+        },
+        .destroy_seat => |args| {
+            var it = im.seats.iterator(.forward);
+            _ = it.next(); // skip default seat
+            while (it.next()) |seat| {
+                if (mem.orderZ(u8, args.name, seat.wlr_seat.name) == .eq) {
+                    seat.destroying = true;
+                    server.wm.dirtyWindowing();
+                    break;
+                }
+            }
+        },
+    }
+}
+
 pub fn defaultSeat(input_manager: *InputManager) *Seat {
     return input_manager.seats.first().?;
 }
@@ -121,19 +201,10 @@ pub fn processEvents(input_manager: *InputManager) void {
     }
 }
 
-/// Reconfigures all devices' libinput configuration as well as their output mapping.
-/// This is called on outputs being added or removed and on the input configuration being changed.
-pub fn reconfigureDevices(input_manager: *InputManager) void {
-    var it = input_manager.devices.iterator(.forward);
-    while (it.next()) |device| {
-        _ = device;
-    }
-}
-
 fn handleNewInput(listener: *wl.Listener(*wlr.InputDevice), wlr_device: *wlr.InputDevice) void {
     const input_manager: *InputManager = @fieldParentPtr("new_input", listener);
 
-    input_manager.defaultSeat().addDevice(wlr_device, false);
+    input_manager.defaultSeat().attachNewDevice(wlr_device, false);
 }
 
 fn handleNewVirtualPointer(
@@ -151,7 +222,7 @@ fn handleNewVirtualPointer(
         log.debug("Ignoring output suggestion from virtual pointer", .{});
     }
 
-    input_manager.defaultSeat().addDevice(&event.new_pointer.pointer.base, true);
+    input_manager.defaultSeat().attachNewDevice(&event.new_pointer.pointer.base, true);
 }
 
 fn handleNewVirtualKeyboard(
@@ -175,10 +246,10 @@ fn handleNewVirtualKeyboard(
 /// Yes, wlroots should probably do this for us.
 const NoKeymapVirtKeyboard = struct {
     virtual_keyboard: *wlr.VirtualKeyboardV1,
-    destroy: wl.Listener(*wlr.InputDevice) = .init(handleDestroy),
+    destroy: wl.Listener(*wlr.InputDevice) = .init(handleVirtKeyboardDestroy),
     keymap: wl.Listener(*wlr.Keyboard) = .init(handleKeymap),
 
-    fn handleDestroy(listener: *wl.Listener(*wlr.InputDevice), _: *wlr.InputDevice) void {
+    fn handleVirtKeyboardDestroy(listener: *wl.Listener(*wlr.InputDevice), _: *wlr.InputDevice) void {
         const no_keymap: *NoKeymapVirtKeyboard = @fieldParentPtr("destroy", listener);
 
         no_keymap.destroy.link.remove();
@@ -191,10 +262,10 @@ const NoKeymapVirtKeyboard = struct {
         const no_keymap: *NoKeymapVirtKeyboard = @fieldParentPtr("keymap", listener);
         const virtual_keyboard = no_keymap.virtual_keyboard;
 
-        handleDestroy(&no_keymap.destroy, &virtual_keyboard.keyboard.base);
+        handleVirtKeyboardDestroy(&no_keymap.destroy, &virtual_keyboard.keyboard.base);
 
         const seat: *Seat = @ptrCast(@alignCast(virtual_keyboard.seat.data));
-        seat.addDevice(&virtual_keyboard.keyboard.base, true);
+        seat.attachNewDevice(&virtual_keyboard.keyboard.base, true);
     }
 };
 
diff --git a/river/Keyboard.zig b/river/Keyboard.zig
index 4d58844..c68451e 100644
--- a/river/Keyboard.zig
+++ b/river/Keyboard.zig
@@ -31,48 +31,52 @@ const Seat = @import("Seat.zig");
 
 const log = std.log.scoped(.input);
 
+pub const Config = struct {
+    keymap: ?*xkb.Keymap,
+    /// Repeat rate in characters per second
+    repeat_rate: u31 = 80,
+    /// Repeat delay in milliseconds
+    repeat_delay: u31 = 300,
+
+    pub fn eql(a: *const Config, b: *const Config) bool {
+        // TODO this probably isn't a sufficient way to compare keymaps?
+        return a.keymap == b.keymap and
+            a.repeat_rate == b.repeat_rate and
+            a.repeat_delay == b.repeat_delay;
+    }
+};
+
 device: InputDevice,
 device_destroyed: bool = false,
 queued_events: u32 = 0,
 
-group: *KeyboardGroup,
+virtual: bool,
+config: Config,
+/// Only null during initialization or due to allocation failure.
+group: ?*KeyboardGroup = null,
 
 key: wl.Listener(*wlr.Keyboard.event.Key) = .init(queueKey),
 modifiers: wl.Listener(*wlr.Keyboard) = .init(queueModifiers),
 keymap: wl.Listener(*wlr.Keyboard) = .init(queueKeymap),
 
 pub fn create(seat: *Seat, wlr_device: *wlr.InputDevice, virtual: bool) !*Keyboard {
+    const wlr_keyboard = wlr_device.toKeyboard();
+
     const keyboard = try util.gpa.create(Keyboard);
     errdefer util.gpa.destroy(keyboard);
 
     keyboard.* = .{
+        .virtual = virtual,
+        .config = .{
+            .keymap = if (virtual) wlr_keyboard.keymap else server.config.keymap,
+        },
         .device = undefined,
-        .group = undefined,
     };
-    try keyboard.device.init(seat, wlr_device);
+    try keyboard.device.init(seat, wlr_device, virtual);
     errdefer keyboard.device.deinit();
 
-    const wlr_keyboard = keyboard.device.wlr_device.toKeyboard();
     wlr_keyboard.data = keyboard;
 
-    keyboard.group = blk: {
-        if (virtual) {
-            // Virtual keyboards set their own keymap and require independent modifier state.
-            // Therefore, they are always placed in their own group of one.
-            break :blk try KeyboardGroup.create(seat, wlr_keyboard.keymap, true);
-        } else {
-            var it = seat.keyboard_groups.iterator(.forward);
-            while (it.next()) |group| {
-                // TODO input configuration will require sorting keyboards into
-                // groups based on keymap and repeat info.
-                if (true) {
-                    break :blk group.ref();
-                }
-            }
-            break :blk try KeyboardGroup.create(seat, server.config.keymap, false);
-        }
-    };
-
     wlr_keyboard.events.key.add(&keyboard.key);
     wlr_keyboard.events.modifiers.add(&keyboard.modifiers);
     wlr_keyboard.events.keymap.add(&keyboard.keymap);
@@ -80,6 +84,47 @@ pub fn create(seat: *Seat, wlr_device: *wlr.InputDevice, virtual: bool) !*Keyboa
     return keyboard;
 }
 
+pub fn setGroup(keyboard: *Keyboard) void {
+    assert(keyboard.group == null);
+    const seat = keyboard.device.seat;
+    if (keyboard.virtual) {
+        // Virtual keyboards set their own keymap and require independent modifier state.
+        // Therefore, they are always placed in their own group of one.
+        keyboard.group = KeyboardGroup.create(seat, keyboard.config, true) catch |err| switch (err) {
+            error.OutOfMemory => blk: {
+                log.err("out of memory", .{});
+                break :blk null;
+            },
+        };
+    } else {
+        var it = seat.keyboard_groups.iterator(.forward);
+        while (it.next()) |group| {
+            if (keyboard.config.eql(&group.config)) {
+                keyboard.group = group.ref();
+                break;
+            }
+        } else {
+            keyboard.group = KeyboardGroup.create(seat, keyboard.config, false) catch |err| switch (err) {
+                error.OutOfMemory => blk: {
+                    log.err("out of memory", .{});
+                    break :blk null;
+                },
+            };
+        }
+    }
+}
+
+pub fn setRepeatInfo(keyboard: *Keyboard, rate: u31, delay: u31) void {
+    assert(!keyboard.virtual);
+    keyboard.config.repeat_rate = rate;
+    keyboard.config.repeat_delay = delay;
+    if (keyboard.group) |group| {
+        group.unref();
+        keyboard.group = null;
+    }
+    keyboard.setGroup();
+}
+
 pub fn deviceDestroy(keyboard: *Keyboard) void {
     assert(!keyboard.device_destroyed);
     keyboard.device_destroyed = true;
@@ -98,28 +143,30 @@ fn maybeDestroy(keyboard: *Keyboard) void {
         return;
     }
 
-    keyboard.group.unref();
+    if (keyboard.group) |group| group.unref();
 
     util.gpa.destroy(keyboard);
 }
 
-pub fn processKey(keyboard: *Keyboard, key: *const wlr.Keyboard.event.Key) void {
-    keyboard.group.processKey(key);
+pub fn dropEvent(keyboard: *Keyboard) void {
     keyboard.queued_events -= 1;
     keyboard.maybeDestroy();
 }
 
+pub fn processKey(keyboard: *Keyboard, key: *const wlr.Keyboard.event.Key) void {
+    if (keyboard.group) |group| group.processKey(key);
+    keyboard.dropEvent();
+}
+
 pub fn processModifiers(keyboard: *Keyboard, modifiers: wlr.Keyboard.Modifiers) void {
-    keyboard.group.processModifiers(modifiers);
-    keyboard.queued_events -= 1;
-    keyboard.maybeDestroy();
+    if (keyboard.group) |group| group.processModifiers(modifiers);
+    keyboard.dropEvent();
 }
 
 pub fn processKeymap(keyboard: *Keyboard, keymap: *xkb.Keymap) void {
     defer keymap.unref();
-    keyboard.group.processKeymap(keymap);
-    keyboard.queued_events -= 1;
-    keyboard.maybeDestroy();
+    if (keyboard.group) |group| group.processKeymap(keymap);
+    keyboard.dropEvent();
 }
 
 fn queueKey(listener: *wl.Listener(*wlr.Keyboard.event.Key), event: *wlr.Keyboard.event.Key) void {
diff --git a/river/KeyboardGroup.zig b/river/KeyboardGroup.zig
index 23ab6fa..72af126 100644
--- a/river/KeyboardGroup.zig
+++ b/river/KeyboardGroup.zig
@@ -25,6 +25,7 @@ const xkb = @import("xkbcommon");
 const server = &@import("main.zig").server;
 const util = @import("util.zig");
 
+const Keyboard = @import("Keyboard.zig");
 const Seat = @import("Seat.zig");
 const XkbBinding = @import("XkbBinding.zig");
 
@@ -66,6 +67,8 @@ link: wl.list.Link,
 
 virtual: bool,
 
+config: Keyboard.Config,
+
 /// This is the keyboard that actually gets passed to wlr_seat functions for
 /// setting keyboard focus.
 state: wlr.Keyboard,
@@ -77,12 +80,13 @@ pressed: std.AutoArrayHashMapUnmanaged(u32, Press) = .empty,
 key: wl.Listener(*wlr.Keyboard.event.Key) = .init(handleKey),
 modifiers: wl.Listener(*wlr.Keyboard) = .init(handleModifiers),
 
-pub fn create(seat: *Seat, keymap: ?*xkb.Keymap, virtual: bool) !*KeyboardGroup {
+pub fn create(seat: *Seat, config: Keyboard.Config, virtual: bool) !*KeyboardGroup {
     const group = try util.gpa.create(KeyboardGroup);
     errdefer util.gpa.destroy(group);
     group.* = .{
         .seat = seat,
         .virtual = virtual,
+        .config = config,
         .state = undefined,
         .link = undefined,
     };
@@ -99,8 +103,8 @@ pub fn create(seat: *Seat, keymap: ?*xkb.Keymap, virtual: bool) !*KeyboardGroup
     group.state.data = group;
 
     // wlroots will log an error on failure, there's not much we can do to recover unfortunately.
-    _ = group.state.setKeymap(keymap);
-    group.state.setRepeatInfo(server.config.repeat_rate, server.config.repeat_delay);
+    _ = group.state.setKeymap(config.keymap);
+    group.state.setRepeatInfo(config.repeat_rate, config.repeat_delay);
 
     group.state.events.key.add(&group.key);
     group.state.events.modifiers.add(&group.modifiers);
diff --git a/river/Output.zig b/river/Output.zig
index 450d67d..763939d 100644
--- a/river/Output.zig
+++ b/river/Output.zig
@@ -234,6 +234,15 @@ fn handleDestroy(listener: *wl.Listener(*wlr.Output), wlr_output: *wlr.Output) v
             }
         }
     }
+    {
+        var it = server.input_manager.devices.iterator(.forward);
+        while (it.next()) |device| {
+            if (device.config.map_to_output == wlr_output) {
+                device.config.map_to_output = null;
+                device.seat.cursor.wlr_cursor.mapInputToOutput(device.wlr_device, null);
+            }
+        }
+    }
 
     output.destroy.link.remove();
     output.request_state.link.remove();
diff --git a/river/OutputManager.zig b/river/OutputManager.zig
index 0e6e472..88cd1eb 100644
--- a/river/OutputManager.zig
+++ b/river/OutputManager.zig
@@ -282,8 +282,6 @@ pub fn commitOutputState(om: *OutputManager) void {
         }
     }
 
-    server.input_manager.reconfigureDevices();
-
     const need_modeset = blk: {
         var it = wm.sent.outputs.iterator(.forward);
         while (it.next()) |output| {
diff --git a/river/PointerBinding.zig b/river/PointerBinding.zig
index 4da340c..26bb395 100644
--- a/river/PointerBinding.zig
+++ b/river/PointerBinding.zig
@@ -88,6 +88,19 @@ pub fn create(
     seat.pointer_bindings.append(binding);
 }
 
+pub fn destroy(binding: *PointerBinding) void {
+    binding.object.setHandler(?*anyopaque, handleRequestInert, null, null);
+    handleDestroy(binding.object, binding);
+}
+
+fn handleRequestInert(
+    pointer_binding_v1: *river.PointerBindingV1,
+    request: river.PointerBindingV1.Request,
+    _: ?*anyopaque,
+) void {
+    if (request == .destroy) pointer_binding_v1.destroy();
+}
+
 fn handleDestroy(_: *river.PointerBindingV1, binding: *PointerBinding) void {
     if (binding.seat.cursor.pressed.getPtr(binding.button)) |value_ptr| {
         // It is possible for the window manager to create duplicate pointer bindings.
diff --git a/river/Seat.zig b/river/Seat.zig
index 8f8da4c..1087d26 100644
--- a/river/Seat.zig
+++ b/river/Seat.zig
@@ -197,7 +197,6 @@ pub fn create(name: [*:0]const u8) !void {
     errdefer event_queue.deinit(util.gpa);
 
     seat.* = .{
-        // This will be automatically destroyed when the display is destroyed
         .wlr_seat = try wlr.Seat.create(server.wl_server, name),
         .event_queue = event_queue,
         .link = undefined,
@@ -229,10 +228,44 @@ pub fn create(name: [*:0]const u8) !void {
 }
 
 pub fn destroy(seat: *Seat) void {
+    while (seat.event_queue.popFront()) |event| {
+        switch (event) {
+            .keyboard_key => |data| data.keyboard.dropEvent(),
+            .keyboard_modifiers => |data| data.keyboard.dropEvent(),
+            .keyboard_keymap => |data| {
+                data.keyboard.dropEvent();
+                data.keymap.unref();
+            },
+            .pointer_motion_relative,
+            .pointer_motion_absolute,
+            .pointer_button,
+            .pointer_axis,
+            .pointer_frame,
+            .pointer_swipe_begin,
+            .pointer_swipe_update,
+            .pointer_swipe_end,
+            .pointer_pinch_begin,
+            .pointer_pinch_update,
+            .pointer_pinch_end,
+            => {},
+        }
+    }
+    {
+        var it = server.input_manager.devices.iterator(.forward);
+        while (it.next()) |device| {
+            if (device.seat == seat) {
+                device.assignToSeat(server.input_manager.defaultSeat());
+            }
+        }
+    }
     {
         var it = server.input_manager.devices.iterator(.forward);
         while (it.next()) |device| assert(device.seat != seat);
     }
+    assert(seat.keyboard_groups.empty());
+
+    while (seat.xkb_bindings.first()) |binding| binding.destroy();
+    while (seat.pointer_bindings.first()) |binding| binding.destroy();
 
     seat.link.remove();
     seat.link_sent.remove();
@@ -245,6 +278,8 @@ pub fn destroy(seat: *Seat) void {
     seat.start_drag.link.remove();
     if (seat.drag != .none) seat.drag_destroy.link.remove();
     seat.request_set_primary_selection.link.remove();
+
+    seat.wlr_seat.destroy();
 }
 
 pub fn queueEvent(seat: *Seat, event: Event) !void {
@@ -738,36 +773,68 @@ pub fn opEnd(seat: *Seat) void {
     }
 }
 
-pub fn addDevice(seat: *Seat, wlr_device: *wlr.InputDevice, virtual: bool) void {
-    seat.tryAddDevice(wlr_device, virtual) catch |err| switch (err) {
-        error.OutOfMemory => log.err("out of memory", .{}),
+pub fn attachNewDevice(seat: *Seat, wlr_device: *wlr.InputDevice, virtual: bool) void {
+    const device = seat.createDevice(wlr_device, virtual) catch |err| switch (err) {
+        error.OutOfMemory => {
+            log.err("out of memory", .{});
+            return;
+        },
     };
+    if (device) |d| {
+        seat.attachDevice(d);
+        seat.updateCapabilities();
+    }
 }
 
-fn tryAddDevice(seat: *Seat, wlr_device: *wlr.InputDevice, virtual: bool) !void {
+fn createDevice(seat: *Seat, wlr_device: *wlr.InputDevice, virtual: bool) !?*InputDevice {
     switch (wlr_device.type) {
         .keyboard => {
             const keyboard = try Keyboard.create(seat, wlr_device, virtual);
-
-            seat.wlr_seat.setKeyboard(&keyboard.group.state);
-            if (seat.wlr_seat.keyboard_state.focused_surface) |wlr_surface| {
-                seat.keyboardNotifyEnter(wlr_surface);
-            }
+            return &keyboard.device;
         },
         .pointer, .touch => {
             const device = try util.gpa.create(InputDevice);
             errdefer util.gpa.destroy(device);
-
-            try device.init(seat, wlr_device);
-
-            seat.cursor.wlr_cursor.attachInputDevice(wlr_device);
+            try device.init(seat, wlr_device, virtual);
+            return device;
         },
         .tablet => {
-            try Tablet.create(seat, wlr_device);
-            seat.cursor.wlr_cursor.attachInputDevice(wlr_device);
+            const tablet = try Tablet.create(seat, wlr_device, virtual);
+            return &tablet.device;
+        },
+        .@"switch", .tablet_pad => return null, // unsupported
+    }
+}
+
+pub fn attachDevice(seat: *Seat, device: *InputDevice) void {
+    device.seat = seat;
+    switch (device.wlr_device.type) {
+        .keyboard => {
+            const keyboard: *Keyboard = @fieldParentPtr("device", device);
+            keyboard.setGroup();
+            if (keyboard.group) |group| {
+                seat.wlr_seat.setKeyboard(&group.state);
+                if (seat.wlr_seat.keyboard_state.focused_surface) |wlr_surface| {
+                    seat.keyboardNotifyEnter(wlr_surface);
+                }
+            }
         },
-        // TODO Support these types of input devices.
-        .@"switch", .tablet_pad => {},
+        .pointer, .touch, .tablet => {
+            seat.cursor.wlr_cursor.attachInputDevice(device.wlr_device);
+        },
+        .@"switch", .tablet_pad => unreachable, // unsupported
+    }
+}
+
+pub fn detachDevice(seat: *Seat, device: *InputDevice) void {
+    seat.cursor.wlr_cursor.detachInputDevice(device.wlr_device);
+
+    if (device.wlr_device.type == .keyboard) {
+        const keyboard: *Keyboard = @fieldParentPtr("device", device);
+        if (keyboard.group) |group| {
+            group.unref();
+            keyboard.group = null;
+        }
     }
 }
 
diff --git a/river/Server.zig b/river/Server.zig
index fa4a051..8b65066 100644
--- a/river/Server.zig
+++ b/river/Server.zig
@@ -335,6 +335,7 @@ fn blocklist(server: *Server, global: *const wl.Global) bool {
         global == server.om.wlr_output_manager.global or
         global == server.om.power_manager.global or
         global == server.om.gamma_control_manager.global or
+        global == server.input_manager.global or
         global == server.input_manager.idle_notifier.global or
         global == server.input_manager.virtual_pointer_manager.global or
         global == server.input_manager.virtual_keyboard_manager.global or
diff --git a/river/Tablet.zig b/river/Tablet.zig
index 8d3f0da..e3d97ae 100644
--- a/river/Tablet.zig
+++ b/river/Tablet.zig
@@ -25,14 +25,11 @@ const util = @import("util.zig");
 
 const InputDevice = @import("InputDevice.zig");
 const Seat = @import("Seat.zig");
-const TabletTool = @import("TabletTool.zig");
 
 device: InputDevice,
 wp_tablet: *wlr.TabletV2Tablet,
 
-output_mapping: ?*wlr.Output = null,
-
-pub fn create(seat: *Seat, wlr_device: *wlr.InputDevice) !void {
+pub fn create(seat: *Seat, wlr_device: *wlr.InputDevice, virtual: bool) !*Tablet {
     assert(wlr_device.type == .tablet);
 
     const tablet = try util.gpa.create(Tablet);
@@ -44,8 +41,10 @@ pub fn create(seat: *Seat, wlr_device: *wlr.InputDevice) !void {
         .device = undefined,
         .wp_tablet = try tablet_manager.createTabletV2Tablet(seat.wlr_seat, wlr_device),
     };
-    try tablet.device.init(seat, wlr_device);
+    try tablet.device.init(seat, wlr_device, virtual);
     errdefer tablet.device.deinit();
+
+    return tablet;
 }
 
 pub fn destroy(tablet: *Tablet) void {
diff --git a/river/TabletTool.zig b/river/TabletTool.zig
index 34b106d..7abaa2b 100644
--- a/river/TabletTool.zig
+++ b/river/TabletTool.zig
@@ -126,9 +126,21 @@ fn handleSetCursor(
     }
 }
 
-pub fn axis(tool: *TabletTool, tablet: *Tablet, event: *wlr.Tablet.event.Axis) void {
+/// Must be called before moving/warping TabletTool.wlr_cursor
+/// Must call detach() after attach() is called before returning.
+fn attach(tool: *TabletTool, tablet: *Tablet) void {
     tool.wlr_cursor.attachInputDevice(tablet.device.wlr_device);
-    tool.wlr_cursor.mapInputToOutput(tablet.device.wlr_device, tablet.output_mapping);
+    tool.wlr_cursor.mapInputToOutput(tablet.device.wlr_device, tablet.device.config.map_to_output);
+    tool.wlr_cursor.mapInputToRegion(tablet.device.wlr_device, &tablet.device.config.map_to_rectangle);
+}
+
+fn detach(tool: *TabletTool, tablet: *Tablet) void {
+    tool.wlr_cursor.detachInputDevice(tablet.device.wlr_device);
+}
+
+pub fn axis(tool: *TabletTool, tablet: *Tablet, event: *wlr.Tablet.event.Axis) void {
+    tool.attach(tablet);
+    defer tool.detach(tablet);
 
     if (event.updated_axes.x or event.updated_axes.y) {
         // I don't own all these different types of tablet tools to test that this
@@ -185,8 +197,8 @@ pub fn axis(tool: *TabletTool, tablet: *Tablet, event: *wlr.Tablet.event.Axis) v
 pub fn proximity(tool: *TabletTool, tablet: *Tablet, event: *wlr.Tablet.event.Proximity) void {
     switch (event.state) {
         .in => {
-            tool.wlr_cursor.attachInputDevice(tablet.device.wlr_device);
-            tool.wlr_cursor.mapInputToOutput(tablet.device.wlr_device, tablet.output_mapping);
+            tool.attach(tablet);
+            defer tool.detach(tablet);
 
             tool.wlr_cursor.warpAbsolute(tablet.device.wlr_device, event.x, event.y);
 
diff --git a/river/WindowManager.zig b/river/WindowManager.zig
index 92da31b..fb90d81 100644
--- a/river/WindowManager.zig
+++ b/river/WindowManager.zig
@@ -165,6 +165,7 @@ fn handleRequest(
             wm_v1.sendFinished();
             wm_v1.setHandler(?*anyopaque, handleRequestInert, null, null);
         },
+        // TODO send protocol error to avoid leak on race
         .destroy => wm_v1.destroy(),
         .manage_finish => {
             if (wm.state != .manage) {
diff --git a/river/XkbBinding.zig b/river/XkbBinding.zig
index 6e0cb8a..2dcb75a 100644
--- a/river/XkbBinding.zig
+++ b/river/XkbBinding.zig
@@ -96,6 +96,19 @@ pub fn create(
     seat.xkb_bindings.append(binding);
 }
 
+pub fn destroy(binding: *XkbBinding) void {
+    binding.object.setHandler(?*anyopaque, handleRequestInert, null, null);
+    handleDestroy(binding.object, binding);
+}
+
+fn handleRequestInert(
+    xkb_binding_v1: *river.XkbBindingV1,
+    request: river.XkbBindingV1.Request,
+    _: ?*anyopaque,
+) void {
+    if (request == .destroy) xkb_binding_v1.destroy();
+}
+
 fn handleDestroy(_: *river.XkbBindingV1, binding: *XkbBinding) void {
     {
         var it = binding.seat.keyboard_groups.iterator(.forward);