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

commit2ce58d950042383667a466cc56b8ba96e100d1cb
parentca36f5be3f
authorKeith Hubbard <[email protected]>
date2021-08-15 08:49
command: support repeating keyboard mappings

Repeating mappings are created using the -repeat option to the map
command:

    % riverctl map normal $mod+Mod1 K -repeat move up 10

- repeating is only supported for key press (not -release) mappings
- unlike -release, -repeat does not create distinct mappings: mapping a
  key with -repeat will replace an existing bare mapping and vice-versa

Resolves #306

 completions/bash/riverctl      |  3 +-
 completions/fish/riverctl.fish |  2 +-
 completions/zsh/_riverctl      |  2 +-
 doc/riverctl.1.scd             |  4 ++-
 river/Keyboard.zig             |  2 ++
 river/Mapping.zig              |  5 +++
 river/Seat.zig                 | 76 ++++++++++++++++++++++++++++++++----------
 river/command.zig              |  2 ++
 river/command/map.zig          | 18 +++++++++-
 9 files changed, 92 insertions(+), 22 deletions(-)

diff --git a/completions/bash/riverctl b/completions/bash/riverctl
index b5fb34b..3475386 100644
--- a/completions/bash/riverctl
+++ b/completions/bash/riverctl
@@ -51,7 +51,8 @@ function __riverctl_completion ()
 			"focus-output"|"focus-view"|"send-to-output"|"swap") OPTS="next previous" ;;
 			"move"|"snap") OPTS="up down left right" ;;
 			"resize") OPTS="horizontal vertical" ;;
-			"map"|"unmap") OPTS="-release" ;;
+			"map") OPTS="-release -repeat" ;;
+			"unmap") OPTS="-release" ;;
 			"attach-mode") OPTS="top bottom" ;;
 			"focus-follows-cursor") OPTS="disabled normal" ;;
 			"set-cursor-warp") OPTS="disabled on-output-change" ;;
diff --git a/completions/fish/riverctl.fish b/completions/fish/riverctl.fish
index 3609e57..88be779 100644
--- a/completions/fish/riverctl.fish
+++ b/completions/fish/riverctl.fish
@@ -90,7 +90,7 @@ complete -c riverctl -x -n '__fish_seen_subcommand_from resize'               -a
 complete -c riverctl -x -n '__fish_seen_subcommand_from snap'                 -a 'up down left right'
 complete -c riverctl -x -n '__fish_seen_subcommand_from send-to-output'       -a 'next previous'
 complete -c riverctl -x -n '__fish_seen_subcommand_from swap'                 -a 'next previous'
-complete -c riverctl -x -n '__fish_seen_subcommand_from map'                  -a '-release'
+complete -c riverctl -x -n '__fish_seen_subcommand_from map'                  -a '-release -repeat'
 complete -c riverctl -x -n '__fish_seen_subcommand_from unmap'                -a '-release'
 complete -c riverctl -x -n '__fish_seen_subcommand_from attach-mode'          -a 'top bottom'
 complete -c riverctl -x -n '__fish_seen_subcommand_from focus-follows-cursor' -a 'disabled normal'
diff --git a/completions/zsh/_riverctl b/completions/zsh/_riverctl
index f606565..d782340 100644
--- a/completions/zsh/_riverctl
+++ b/completions/zsh/_riverctl
@@ -133,7 +133,7 @@ _riverctl()
                 snap) _alternative 'arguments:args:(up down left right)' ;;
                 send-to-output) _alternative 'arguments:args:(next previous)' ;;
                 swap) _alternative 'arguments:args:(next previous)' ;;
-                map) _alternative 'arguments:optional:(-release)' ;;
+                map) _alternative 'arguments:optional:(-release -repeat)' ;;
                 unmap) _alternative 'arguments:optional:(-release)' ;;
                 attach-mode) _alternative 'arguments:args:(top bottom)' ;;
                 focus-follows-cursor) _alternative 'arguments:args:(disabled normal)' ;;
diff --git a/doc/riverctl.1.scd b/doc/riverctl.1.scd
index a589c45..d1d4666 100644
--- a/doc/riverctl.1.scd
+++ b/doc/riverctl.1.scd
@@ -185,11 +185,13 @@ A complete list may be found in _/usr/include/linux/input-event-codes.h_
 *enter-mode* _name_
 	Switch to given mode if it exists.
 
-*map* [_-release_] _mode_ _modifiers_ _key_ _command_
+*map* [_-release_|_-repeat_] _mode_ _modifiers_ _key_ _command_
 	Run _command_ when _key_ is pressed while _modifiers_ are held down
 	and in the specified _mode_.
 
 	- _-release_: if passed activate on key release instead of key press
+	- _-repeat_: if passed activate repeatedly until key release; may not
+	  be used with -release
 	- _mode_: name of the mode for which to create the mapping
 	- _modifiers_: one or more of the modifiers listed above, separated
 	  by a plus sign (+).
diff --git a/river/Keyboard.zig b/river/Keyboard.zig
index f0a9d71..ba82180 100644
--- a/river/Keyboard.zig
+++ b/river/Keyboard.zig
@@ -81,6 +81,8 @@ fn handleKey(listener: *wl.Listener(*wlr.Keyboard.event.Key), event: *wlr.Keyboa
 
     self.seat.handleActivity();
 
+    self.seat.clearRepeatingMapping();
+
     // Translate libinput keycode -> xkbcommon
     const keycode = event.keycode + 8;
 
diff --git a/river/Mapping.zig b/river/Mapping.zig
index 280c79f..bb284e1 100644
--- a/river/Mapping.zig
+++ b/river/Mapping.zig
@@ -30,10 +30,14 @@ command_args: []const [:0]const u8,
 /// When set to true the mapping will be executed on key release rather than on press
 release: bool,
 
+/// When set to true the mapping will be executed repeatedly while key is pressed
+repeat: bool,
+
 pub fn init(
     keysym: xkb.Keysym,
     modifiers: wlr.Keyboard.ModifierMask,
     release: bool,
+    repeat: bool,
     command_args: []const []const u8,
 ) !Self {
     const owned_args = try util.gpa.alloc([:0]u8, command_args.len);
@@ -46,6 +50,7 @@ pub fn init(
         .keysym = keysym,
         .modifiers = modifiers,
         .release = release,
+        .repeat = repeat,
         .command_args = owned_args,
     };
 }
diff --git a/river/Seat.zig b/river/Seat.zig
index 7b61827..0a5db7d 100644
--- a/river/Seat.zig
+++ b/river/Seat.zig
@@ -31,6 +31,7 @@ const DragIcon = @import("DragIcon.zig");
 const Cursor = @import("Cursor.zig");
 const InputManager = @import("InputManager.zig");
 const Keyboard = @import("Keyboard.zig");
+const Mapping = @import("Mapping.zig");
 const LayerSurface = @import("LayerSurface.zig");
 const Output = @import("Output.zig");
 const SeatStatus = @import("SeatStatus.zig");
@@ -60,6 +61,12 @@ mode_id: usize = 0,
 /// ID of previous keymap mode, used when returning from "locked" mode
 prev_mode_id: usize = 0,
 
+/// Timer for repeating keyboard mappings
+mapping_repeat_timer: *wl.EventSource,
+
+/// Currently repeating mapping, if any
+repeating_mapping: ?*const Mapping = null,
+
 /// Currently focused output, may be the noop output if no real output
 /// is currently available for focus.
 focused_output: *Output,
@@ -83,10 +90,15 @@ request_set_primary_selection: wl.Listener(*wlr.Seat.event.RequestSetPrimarySele
     wl.Listener(*wlr.Seat.event.RequestSetPrimarySelection).init(handleRequestSetPrimarySelection),
 
 pub fn init(self: *Self, name: [*:0]const u8) !void {
+    const event_loop = server.wl_server.getEventLoop();
+    const mapping_repeat_timer = try event_loop.addTimer(*Self, handleMappingRepeatTimeout, self);
+    errdefer mapping_repeat_timer.remove();
+
     self.* = .{
         // This will be automatically destroyed when the display is destroyed
         .wlr_seat = try wlr.Seat.create(server.wl_server, name),
         .focused_output = &server.root.noop_output,
+        .mapping_repeat_timer = mapping_repeat_timer,
     };
     self.wlr_seat.data = @ptrToInt(self);
 
@@ -100,6 +112,7 @@ pub fn init(self: *Self, name: [*:0]const u8) !void {
 
 pub fn deinit(self: *Self) void {
     self.cursor.deinit();
+    self.mapping_repeat_timer.remove();
 
     while (self.keyboards.pop()) |node| {
         node.data.deinit();
@@ -318,32 +331,61 @@ pub fn handleMapping(
     released: bool,
 ) bool {
     const modes = &server.config.modes;
-    for (modes.items[self.mode_id].mappings.items) |mapping| {
+    for (modes.items[self.mode_id].mappings.items) |*mapping| {
         if (std.meta.eql(modifiers, mapping.modifiers) and keysym == mapping.keysym and released == mapping.release) {
-            // Execute the bound command
-            const args = mapping.command_args;
-            var out: ?[]const u8 = null;
-            defer if (out) |s| util.gpa.free(s);
-            command.run(util.gpa, self, args, &out) catch |err| {
-                const failure_message = switch (err) {
-                    command.Error.Other => out.?,
-                    else => command.errToMsg(err),
-                };
-                std.log.scoped(.command).err("{s}: {s}", .{ args[0], failure_message });
-                return true;
-            };
-            if (out) |s| {
-                const stdout = std.io.getStdOut().writer();
-                stdout.print("{s}", .{s}) catch |err| {
-                    std.log.scoped(.command).err("{s}: write to stdout failed {}", .{ args[0], err });
+            if (mapping.repeat) {
+                self.repeating_mapping = mapping;
+                self.mapping_repeat_timer.timerUpdate(server.config.repeat_delay) catch {
+                    log.err("failed to update mapping repeat timer", .{});
                 };
             }
+            self.runMappedCommand(mapping);
             return true;
         }
     }
     return false;
 }
 
+fn runMappedCommand(self: *Self, mapping: *const Mapping) void {
+    var out: ?[]const u8 = null;
+    defer if (out) |s| util.gpa.free(s);
+    const args = mapping.command_args;
+    command.run(util.gpa, self, args, &out) catch |err| {
+        const failure_message = switch (err) {
+            command.Error.Other => out.?,
+            else => command.errToMsg(err),
+        };
+        std.log.scoped(.command).err("{s}: {s}", .{ args[0], failure_message });
+        return;
+    };
+    if (out) |s| {
+        const stdout = std.io.getStdOut().writer();
+        stdout.print("{s}", .{s}) catch |err| {
+            std.log.scoped(.command).err("{s}: write to stdout failed {}", .{ args[0], err });
+        };
+    }
+}
+
+pub fn clearRepeatingMapping(self: *Self) void {
+    self.mapping_repeat_timer.timerUpdate(0) catch {
+        log.err("failed to clear mapping repeat timer", .{});
+    };
+    self.repeating_mapping = null;
+}
+
+/// Repeat key mapping
+fn handleMappingRepeatTimeout(self: *Self) callconv(.C) c_int {
+    if (self.repeating_mapping) |mapping| {
+        const rate = server.config.repeat_rate;
+        const ms_delay = if (rate > 0) 1000 / rate else 0;
+        self.mapping_repeat_timer.timerUpdate(ms_delay) catch {
+            log.err("failed to update mapping repeat timer", .{});
+        };
+        self.runMappedCommand(mapping);
+    }
+    return 0;
+}
+
 /// Add a newly created input device to the seat and update the reported
 /// capabilities.
 pub fn addDevice(self: *Self, device: *wlr.InputDevice) void {
diff --git a/river/command.zig b/river/command.zig
index c67f15b..d32101e 100644
--- a/river/command.zig
+++ b/river/command.zig
@@ -103,6 +103,7 @@ pub const Error = error{
     InvalidRgba,
     InvalidValue,
     UnknownOption,
+    ConflictingOptions,
     OutOfMemory,
     Other,
 };
@@ -136,6 +137,7 @@ pub fn errToMsg(err: Error) [:0]const u8 {
         Error.NoCommand => "no command given",
         Error.UnknownCommand => "unknown command",
         Error.UnknownOption => "unknown option",
+        Error.ConflictingOptions => "options conflict",
         Error.NotEnoughArguments => "not enough arguments",
         Error.TooManyArguments => "too many arguments",
         Error.Overflow => "value out of bounds",
diff --git a/river/command/map.zig b/river/command/map.zig
index 96e1d25..0ef834a 100644
--- a/river/command/map.zig
+++ b/river/command/map.zig
@@ -44,19 +44,25 @@ pub fn map(
     const offset = optionals.i;
     if (args.len - offset < 5) return Error.NotEnoughArguments;
 
+    if (optionals.release and optionals.repeat) return Error.ConflictingOptions;
+
     const mode_id = try modeNameToId(allocator, seat, args[1 + offset], out);
     const modifiers = try parseModifiers(allocator, args[2 + offset], out);
     const keysym = try parseKeysym(allocator, args[3 + offset], out);
 
     const mode_mappings = &server.config.modes.items[mode_id].mappings;
 
-    const new = try Mapping.init(keysym, modifiers, optionals.release, args[4 + offset ..]);
+    const new = try Mapping.init(keysym, modifiers, optionals.release, optionals.repeat, args[4 + offset ..]);
     errdefer new.deinit();
 
     if (mappingExists(mode_mappings, modifiers, keysym, optionals.release)) |current| {
         mode_mappings.items[current].deinit();
         mode_mappings.items[current] = new;
     } else {
+        // Repeating mappings borrow the Mapping directly. To prevent a
+        // possible crash if the Mapping ArrayList is reallocated, stop any
+        // currently repeating mappings.
+        seat.clearRepeatingMapping();
         try mode_mappings.append(new);
     }
 }
@@ -200,6 +206,7 @@ fn parseModifiers(
 const OptionalArgsContainer = struct {
     i: usize,
     release: bool,
+    repeat: bool,
 };
 
 /// Parses optional args (such as -release) and return the index of the first argument that is
@@ -212,6 +219,7 @@ fn parseOptionalArgs(args: []const []const u8) OptionalArgsContainer {
         // i is the number of arguments consumed
         .i = 0,
         .release = false,
+        .repeat = false,
     };
 
     var i: usize = 0;
@@ -219,6 +227,9 @@ fn parseOptionalArgs(args: []const []const u8) OptionalArgsContainer {
         if (std.mem.eql(u8, arg, "-release")) {
             parsed.release = true;
             i += 1;
+        } else if (std.mem.eql(u8, arg, "-repeat")) {
+            parsed.repeat = true;
+            i += 1;
         } else {
             // Break if the arg is not an option
             parsed.i = i;
@@ -251,6 +262,11 @@ pub fn unmap(
     const mode_mappings = &server.config.modes.items[mode_id].mappings;
     const mapping_idx = mappingExists(mode_mappings, modifiers, keysym, optionals.release) orelse return;
 
+    // Repeating mappings borrow the Mapping directly. To prevent a possible
+    // crash if the Mapping ArrayList is reallocated, stop any currently
+    // repeating mappings.
+    seat.clearRepeatingMapping();
+
     var mapping = mode_mappings.swapRemove(mapping_idx);
     mapping.deinit();
 }