// SPDX-FileCopyrightText: © 2026 mtmn // // SPDX-License-Identifier: BSD-3-Clause // Analyzer does not resolve method-call syntax to free functions. // Module type aliases use PascalCase per Zig convention. // zwanzig-disable: identifier-style, unused-decl const Input = @This(); const std = @import("std"); const river = @import("wayland").client.river; const xkb = @import("xkbcommon"); const PointerBinding = @import("PointerBinding.zig"); const Rule = @import("Rule.zig"); const gpa = std.heap.c_allocator; const log = std.log.scoped(.laguna); /// One river_input_device_v1. river sends input_device as the first event, /// exactly once, on every libinput device and xkb keyboard object, and the /// device it names was already announced on this connection, so those /// objects attach here when that event arrives. pub const Device = struct { owner: *Input, input: *river.InputDeviceV1, libinput: ?*river.LibinputDeviceV1 = null, keyboard: ?*river.XkbKeyboardV1 = null, kind: ?river.InputDeviceV1.Type = null, name: ?[]const u8 = null, }; pub const StoredConfig = struct { pattern: []const u8, category: ?[]const u8, setting: []const u8, value: ?[]const u8, }; input_manager: ?*river.InputManagerV1 = null, libinput_config: ?*river.LibinputConfigV1 = null, xkb_config: ?*river.XkbConfigV1 = null, devices: std.ArrayListUnmanaged(*Device) = .empty, keymap: ?*river.XkbKeymapV1 = null, repeat_rate: ?i32 = null, repeat_delay: ?i32 = null, stored_configs: std.ArrayListUnmanaged(StoredConfig) = .empty, pub fn deinit(input: *Input) void { for (input.devices.items) |device| { destroyDevice(device); gpa.destroy(device); } input.clearStoredConfigs(); input.stored_configs.deinit(gpa); if (input.keymap) |keymap| keymap.destroy(); if (input.input_manager) |object| object.destroy(); if (input.libinput_config) |object| object.destroy(); if (input.xkb_config) |object| object.destroy(); input.input_manager = null; input.libinput_config = null; input.xkb_config = null; input.keymap = null; input.devices.deinit(gpa); } pub fn setGlobals( input: *Input, input_manager: ?*river.InputManagerV1, libinput_config: ?*river.LibinputConfigV1, xkb_config: ?*river.XkbConfigV1, ) void { input.input_manager = input_manager; input.libinput_config = libinput_config; input.xkb_config = xkb_config; if (input_manager) |object| object.setListener(*Input, handleManagerEvent, input); if (libinput_config) |object| object.setListener(*Input, handleConfigEvent, input); if (xkb_config) |object| object.setListener(*Input, handleXkbConfigEvent, input); } fn handleManagerEvent( _: *river.InputManagerV1, event: river.InputManagerV1.Event, input: *Input, ) void { switch (event) { .finished => {}, .input_device => |created| input.addInputDevice(created.id), } } fn handleConfigEvent( _: *river.LibinputConfigV1, event: river.LibinputConfigV1.Event, input: *Input, ) void { switch (event) { .finished => {}, .libinput_device => |created| input.addLibinputDevice(created.id), } } fn handleXkbConfigEvent(_: *river.XkbConfigV1, event: river.XkbConfigV1.Event, input: *Input) void { switch (event) { .finished => {}, .xkb_keyboard => |created| input.addKeyboard(created.id), } } fn addInputDevice(input: *Input, object: *river.InputDeviceV1) void { const device = gpa.create(Device) catch std.process.fatal("out of memory", .{}); device.* = .{ .owner = input, .input = object }; input.devices.append(gpa, device) catch std.process.fatal("out of memory", .{}); object.setListener(*Device, handleInputEvent, device); } fn addLibinputDevice(input: *Input, object: *river.LibinputDeviceV1) void { object.setListener(*Input, handleLibinputEvent, input); } fn addKeyboard(input: *Input, object: *river.XkbKeyboardV1) void { object.setListener(*Input, handleKeyboardEvent, input); if (input.keymap) |keymap| object.setKeymap(keymap); } fn findDevice(input: *Input, object: ?*river.InputDeviceV1) ?*Device { for (input.devices.items) |device| { if (device.input == object) return device; } return null; } fn handleInputEvent( _: *river.InputDeviceV1, event: river.InputDeviceV1.Event, device: *Device, ) void { switch (event) { .removed => device.owner.removeDevice(device), .type => |typed| { device.kind = typed.type; // Keyboards arriving after set-repeat still get the stored rate. if (typed.type == .keyboard) { if (device.owner.repeat_rate) |rate| { if (device.owner.repeat_delay) |delay| device.input.setRepeatInfo(rate, delay); } } }, .name => |named| { if (device.name) |old| gpa.free(old); device.name = gpa.dupe(u8, std.mem.sliceTo(named.name, 0)) catch |err| blk: { log.warn("failed to store input device name: {s}", .{@errorName(err)}); break :blk null; }; if (device.name) |name| log.info("input device: {s}", .{name}); }, .done => {}, } } fn handleLibinputEvent( object: *river.LibinputDeviceV1, event: river.LibinputDeviceV1.Event, input: *Input, ) void { switch (event) { .input_device => |paired| { const device = input.findDevice(paired.device) orelse { log.warn("libinput device without a known input device", .{}); return; }; device.libinput = object; applyStoredConfigs(input, device); }, .removed => { for (input.devices.items) |device| { if (device.libinput == object) device.libinput = null; } object.destroy(); }, else => {}, } } fn handleKeyboardEvent( object: *river.XkbKeyboardV1, event: river.XkbKeyboardV1.Event, input: *Input, ) void { switch (event) { .input_device => |paired| { const device = input.findDevice(paired.device) orelse { log.warn("xkb keyboard without a known input device", .{}); return; }; device.keyboard = object; }, .removed => { for (input.devices.items) |device| { if (device.keyboard == object) device.keyboard = null; } object.destroy(); }, else => {}, } } fn handleResult(_: *river.LibinputResultV1, event: river.LibinputResultV1.Event, _: ?*anyopaque) void { switch (event) { .success => {}, .unsupported => log.warn("input setting unsupported by device", .{}), .invalid => log.warn("input setting invalid for device", .{}), } } fn removeDevice(input: *Input, device: *Device) void { for (input.devices.items, 0..) |candidate, index| { if (candidate != device) continue; _ = input.devices.swapRemove(index); break; } destroyDevice(device); gpa.destroy(device); } /// Destroying the libinput and keyboard objects here is safe even though /// their own removed events may still arrive: libwayland drops events for /// destroyed proxies. fn destroyDevice(device: *Device) void { device.input.destroy(); if (device.libinput) |object| object.destroy(); if (device.keyboard) |object| object.destroy(); if (device.name) |name| gpa.free(name); device.libinput = null; device.keyboard = null; device.name = null; } pub fn setRepeat(input: *Input, rate: i32, delay: i32) void { input.repeat_rate = rate; input.repeat_delay = delay; for (input.devices.items) |device| { if (device.kind != .keyboard) continue; device.input.setRepeatInfo(rate, delay); } } /// Drop all stored input settings. Used before a config reload so removed /// settings stop applying to devices that arrive later. pub fn clearStoredConfigs(input: *Input) void { for (input.stored_configs.items) |stored| { gpa.free(stored.pattern); if (stored.category) |category| gpa.free(category); gpa.free(stored.setting); if (stored.value) |value| gpa.free(value); } input.stored_configs.clearRetainingCapacity(); } /// Remember an input setting so devices arriving later receive it when /// their libinput object pairs. Devices already present are configured by /// the input command that the config queues alongside. A setting with an /// invalid pattern is reported and not stored. Strings are duplicated. The /// input owns them until deinit. pub fn storeConfig( input: *Input, pattern: []const u8, category: ?[]const u8, setting: []const u8, value: ?[]const u8, ) error{OutOfMemory}!void { Rule.validate(pattern) catch |err| { log.warn("ignoring input setting '{s}' with pattern '{s}': {s}", .{ setting, pattern, @errorName(err) }); return; }; const owned_pattern = try gpa.dupe(u8, pattern); errdefer gpa.free(owned_pattern); const owned_category = if (category) |text| try gpa.dupe(u8, text) else null; errdefer if (owned_category) |text| gpa.free(text); const owned_setting = try gpa.dupe(u8, setting); errdefer gpa.free(owned_setting); const owned_value = if (value) |text| try gpa.dupe(u8, text) else null; errdefer if (owned_value) |text| gpa.free(text); try input.stored_configs.append(gpa, .{ .pattern = owned_pattern, .category = owned_category, .setting = owned_setting, .value = owned_value, }); } /// Apply every stored input setting that matches a device whose libinput /// object just paired. storeConfig only keeps valid patterns. fn applyStoredConfigs(input: *Input, device: *Device) void { for (input.stored_configs.items) |stored| { if (!matches(device, stored.pattern, stored.category)) continue; configure(device, stored.setting, stored.value) catch |err| { const name = device.name orelse "(unnamed)"; log.warn("input setting '{s}' failed for {s}: {s}", .{ stored.setting, name, @errorName(err), }); }; } } /// Match a device by glob over its name, with an optional category filter /// such as touchpad, pointer, keyboard or tablet. The pattern must be a /// valid glob, see Rule.validate(). pub fn matches(device: *const Device, pattern: []const u8, category: ?[]const u8) bool { const name = device.name orelse return false; if (!Rule.match(name, pattern)) return false; const wanted = category orelse return true; return matchCategory(device, name, wanted); } fn matchCategory(device: *const Device, name: []const u8, wanted: []const u8) bool { if (std.mem.eql(u8, wanted, "touchpad")) { return std.ascii.findIgnoreCase(name, "touchpad") != null; } if (std.mem.eql(u8, wanted, "pointer") or std.mem.eql(u8, wanted, "mouse")) { return device.kind == .pointer; } if (std.mem.eql(u8, wanted, "keyboard")) { return device.kind == .keyboard; } if (std.mem.eql(u8, wanted, "touch")) { return device.kind == .touch; } if (std.mem.eql(u8, wanted, "tablet")) { return device.kind == .tablet; } return false; } pub const Error = error{ UnknownSetting, InvalidValue, NoLibinputDevice, CannotReadFile, CannotParseFile, OutOfMemory, }; /// Compile a keymap from xkb rule names and push it to all keyboards. pub fn setLayout( input: *Input, rules: ?[]const u8, model: ?[]const u8, layout: []const u8, variant: ?[]const u8, options: ?[]const u8, ) Error!void { const context = xkb.Context.new(.no_flags) orelse return error.OutOfMemory; defer context.unref(); var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const allocator = arena.allocator(); const names = xkb.RuleNames{ .rules = try dupeZOptional(allocator, rules), .model = try dupeZOptional(allocator, model), .layout = try allocator.dupeSentinel(u8, layout, 0), .variant = try dupeZOptional(allocator, variant), .options = try dupeZOptional(allocator, options), }; const keymap = xkb.Keymap.newFromNames(context, &names, .no_flags) orelse { return error.InvalidValue; }; defer keymap.unref(); try input.submitKeymap(keymap); } /// Read a keymap file and push it to all keyboards. pub fn setLayoutFile(input: *Input, path: []const u8) Error!void { const io = std.Io.Threaded.global_single_threaded.io(); const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(1024 * 1024)) catch { return error.CannotReadFile; }; defer gpa.free(bytes); const context = xkb.Context.new(.no_flags) orelse return error.OutOfMemory; defer context.unref(); const keymap = xkb.Keymap.newFromBuffer(context, bytes.ptr, bytes.len, .text_v1, .no_flags) orelse { return error.CannotParseFile; }; defer keymap.unref(); try input.submitKeymap(keymap); } fn dupeZOptional(allocator: std.mem.Allocator, text: ?[]const u8) error{OutOfMemory}!?[*:0]const u8 { const bytes = text orelse return null; return try allocator.dupeSentinel(u8, bytes, 0); } fn submitKeymap(input: *Input, keymap: *xkb.Keymap) Error!void { const config = input.xkb_config orelse return error.InvalidValue; const text = keymap.getAsString(.text_v1) orelse return error.OutOfMemory; defer std.c.free(text); const size = std.mem.len(text); const file_descriptor = std.c.memfd_create( "river-keymap", std.c.MFD.CLOEXEC | std.c.MFD.ALLOW_SEALING, ); if (file_descriptor < 0) return error.OutOfMemory; errdefer _ = std.c.close(file_descriptor); var written: usize = 0; while (written < size) { const bytes_written = std.c.write(file_descriptor, text[written..size].ptr, size - written); if (bytes_written < 0) return error.CannotReadFile; if (bytes_written == 0) return error.CannotReadFile; written += @intCast(bytes_written); } const seals: c_uint = std.c.F.SEAL_SHRINK | std.c.F.SEAL_GROW | std.c.F.SEAL_WRITE; if (std.c.fcntl(file_descriptor, std.c.F.ADD_SEALS, seals) < 0) return error.CannotReadFile; const pending = gpa.create(PendingKeymap) catch return error.OutOfMemory; // SAFETY: The protocol object is assigned immediately after allocation. pending.* = .{ .owner = input, .file_descriptor = file_descriptor, .object = undefined }; const object = config.createKeymap(file_descriptor, .text_v1) catch { gpa.destroy(pending); return error.OutOfMemory; }; pending.object = object; object.setListener(*PendingKeymap, handleKeymapEvent, pending); } const PendingKeymap = struct { owner: *Input, object: *river.XkbKeymapV1, file_descriptor: std.c.fd_t, }; fn handleKeymapEvent(_: *river.XkbKeymapV1, event: river.XkbKeymapV1.Event, pending: *PendingKeymap) void { defer { _ = std.c.close(pending.file_descriptor); gpa.destroy(pending); } switch (event) { .success => pending.owner.adoptKeymap(pending.object), .failure => |failure| { log.err("keymap rejected: {s}", .{failure.error_msg}); pending.object.destroy(); }, } } fn adoptKeymap(input: *Input, object: *river.XkbKeymapV1) void { if (input.keymap) |old| old.destroy(); input.keymap = object; for (input.devices.items) |device| { const keyboard = device.keyboard orelse continue; keyboard.setKeymap(object); } } pub fn configure(device: *Device, raw_setting: []const u8, value: ?[]const u8) Error!void { // Accept underscores alongside dashes so `click_method` and // `click-method` name the same setting. var buffer: [64]u8 = undefined; if (raw_setting.len > buffer.len) return error.UnknownSetting; for (raw_setting, 0..) |char, index| { buffer[index] = if (char == '_') '-' else char; } const setting = buffer[0..raw_setting.len]; const libinput = device.libinput orelse return error.NoLibinputDevice; if (std.mem.eql(u8, setting, "tap")) { try setResult(libinput.setTap(try parseToggle(river.LibinputDeviceV1.TapState, value))); } else if (std.mem.eql(u8, setting, "tap-button-map")) { const map: river.LibinputDeviceV1.TapButtonMap = if (equal(value, "lrm")) .lrm else if (equal(value, "lmr")) .lmr else return error.InvalidValue; try setResult(libinput.setTapButtonMap(map)); } else if (std.mem.eql(u8, setting, "drag")) { try setResult(libinput.setDrag(try parseToggle(river.LibinputDeviceV1.DragState, value))); } else if (std.mem.eql(u8, setting, "drag-lock")) { const state: river.LibinputDeviceV1.DragLockState = if (equal(value, "disabled")) .disabled else if (equal(value, "timeout")) .enabled_timeout else if (equal(value, "sticky")) .enabled_sticky else return error.InvalidValue; try setResult(libinput.setDragLock(state)); } else if (std.mem.eql(u8, setting, "three-finger-drag")) { const state: river.LibinputDeviceV1.ThreeFingerDragState = if (equal(value, "enabled")) .enabled_3fg else if (equal(value, "disabled")) .disabled else return error.InvalidValue; try setResult(libinput.setThreeFingerDrag(state)); } else if (std.mem.eql(u8, setting, "natural-scroll")) { try setResult(libinput.setNaturalScroll( try parseToggle(river.LibinputDeviceV1.NaturalScrollState, value), )); } else if (std.mem.eql(u8, setting, "left-handed")) { try setResult(libinput.setLeftHanded( try parseToggle(river.LibinputDeviceV1.LeftHandedState, value), )); } else if (std.mem.eql(u8, setting, "click-method")) { const method: river.LibinputDeviceV1.ClickMethod = if (equal(value, "none")) .none else if (equal(value, "button-areas")) .button_areas else if (equal(value, "clickfinger")) .clickfinger else return error.InvalidValue; try setResult(libinput.setClickMethod(method)); } else if (std.mem.eql(u8, setting, "clickfinger-button-map")) { const map: river.LibinputDeviceV1.ClickfingerButtonMap = if (equal(value, "lrm")) .lrm else if (equal(value, "lmr")) .lmr else return error.InvalidValue; try setResult(libinput.setClickfingerButtonMap(map)); } else if (std.mem.eql(u8, setting, "middle-emulation")) { try setResult(libinput.setMiddleEmulation( try parseToggle(river.LibinputDeviceV1.MiddleEmulationState, value), )); } else if (std.mem.eql(u8, setting, "scroll-method")) { const method: river.LibinputDeviceV1.ScrollMethod = if (equal(value, "none")) .no_scroll else if (equal(value, "two-finger")) .two_finger else if (equal(value, "edge")) .edge else if (equal(value, "button")) .on_button_down else return error.InvalidValue; try setResult(libinput.setScrollMethod(method)); } else if (std.mem.eql(u8, setting, "scroll-button")) { const text = value orelse return error.InvalidValue; const button = PointerBinding.parseButton(text) catch return error.InvalidValue; try setResult(libinput.setScrollButton(button)); } else if (std.mem.eql(u8, setting, "scroll-button-lock")) { try setResult(libinput.setScrollButtonLock( try parseToggle(river.LibinputDeviceV1.ScrollButtonLockState, value), )); } else if (std.mem.eql(u8, setting, "dwt")) { try setResult(libinput.setDwt(try parseToggle(river.LibinputDeviceV1.DwtState, value))); } else if (std.mem.eql(u8, setting, "dwtp")) { try setResult(libinput.setDwtp(try parseToggle(river.LibinputDeviceV1.DwtpState, value))); } else if (std.mem.eql(u8, setting, "send-events")) { const mode: river.LibinputDeviceV1.SendEventsModes = if (equal(value, "enabled")) .{} else if (equal(value, "disabled")) .{ .disabled = true } else if (equal(value, "disabled-on-external-mouse")) .{ .disabled_on_external_mouse = true } else return error.InvalidValue; try setResult(libinput.setSendEvents(mode)); } else if (std.mem.eql(u8, setting, "pointer-accel") or std.mem.eql(u8, setting, "accel-speed")) { const text = value orelse return error.InvalidValue; const speed = std.fmt.parseFloat(f64, text) catch return error.InvalidValue; if (!std.math.isFinite(speed) or speed < -1.0 or speed > 1.0) return error.InvalidValue; const Fn = @typeInfo(@TypeOf(river.LibinputDeviceV1.setAccelSpeed)).@"fn"; const ArrayT = @typeInfo(Fn.param_types[1] orelse return error.InvalidValue).pointer.child; var storage: [1]f64 = .{speed}; // SAFETY: All fields are assigned before the array is passed to the protocol call. var array: ArrayT = undefined; array.size = @sizeOf(f64); array.alloc = @sizeOf(f64); array.data = @ptrCast(&storage); try setResult(libinput.setAccelSpeed(&array)); } else if (std.mem.eql(u8, setting, "scroll-factor")) { const text = value orelse return error.InvalidValue; const factor = try parseScrollFactor(text); const Fn = @typeInfo(@TypeOf(river.InputDeviceV1.setScrollFactor)).@"fn"; const FixedT = Fn.param_types[1] orelse return error.InvalidValue; const fixed: FixedT = @fromBackingInt(@intCast(@as(i32, @intFromFloat(factor * 256.0)))); device.input.setScrollFactor(fixed); } else if (std.mem.eql(u8, setting, "accel-profile")) { const profile: river.LibinputDeviceV1.AccelProfile = if (equal(value, "none")) .none else if (equal(value, "flat")) .flat else if (equal(value, "adaptive")) .adaptive else return error.InvalidValue; try setResult(libinput.setAccelProfile(profile)); } else return error.UnknownSetting; } fn equal(value: ?[]const u8, expected: []const u8) bool { const text = value orelse return false; return dashed(text, expected); } /// Accept dashes and underscores interchangeably, and enable alongside /// enabled for boolean settings. fn dashed(text: []const u8, expected: []const u8) bool { if (text.len != expected.len) return false; for (text, expected) |a, b| { const left = if (a == '-') '_' else a; const right = if (b == '-') '_' else b; if (left != right) return false; } return true; } /// Parse a scroll factor. Rejects NaN, negatives, and anything past the /// wl_fixed wire range so the intFromFloat at the call site cannot overflow. fn parseScrollFactor(text: []const u8) error{InvalidValue}!f64 { const factor = std.fmt.parseFloat(f64, text) catch return error.InvalidValue; if (!std.math.isFinite(factor) or factor < 0.0 or factor > 8388607.0) return error.InvalidValue; return factor; } test "scroll factor rejects out-of-range values" { const testing = std.testing; try testing.expectEqual(@as(f64, 1.5), try parseScrollFactor("1.5")); try testing.expectEqual(@as(f64, 0.0), try parseScrollFactor("0")); try testing.expectError(error.InvalidValue, parseScrollFactor("-1")); try testing.expectError(error.InvalidValue, parseScrollFactor("nan")); try testing.expectError(error.InvalidValue, parseScrollFactor("inf")); try testing.expectError(error.InvalidValue, parseScrollFactor("1e30")); try testing.expectError(error.InvalidValue, parseScrollFactor("bogus")); } fn parseToggle(comptime T: type, value: ?[]const u8) error{InvalidValue}!T { const text = value orelse return error.InvalidValue; if (equal(text, "enabled") or std.mem.eql(u8, text, "enable")) return .enabled; if (equal(text, "disabled") or std.mem.eql(u8, text, "disable")) return .disabled; return error.InvalidValue; } fn setResult(result: anytype) Error!void { const object = result catch return error.OutOfMemory; object.setListener(?*anyopaque, handleResult, null); }