Repository: asciinema/asciinema (language: rust)

Issue:
Key bindings for `rec` and `play` commands
As mentioned in #340, #341, #324 the need for several key bindings emerged.

So far we only used <kbd>space</kbd> (pause/resume) and <kbd>.</kbd> (step 1 frame forward) for `asciinema play`.

#340 and #341 brough a need for key bindings for `asciinema rec`. At the moment toggling recording pause is hard-coded to ctrl+p ("p" for "pause capture"), and adding a breakpoint is hard-coded to ctrl+b ("b" for breakpoint).

It is very likely that the both ctrp-p and ctrl-b will conflict with many interactive applications (e.g. vim), so we should either find better, rarely used alternatives, or go with a prefix + key combo (like ctrl+a <key> in screen, ctrl+b <key> in tmux).

I don't believe we'll find a default that will work equally well for everyone, so there should be a way to customize the key bindings (for both `asciinema play` and `asciinema rec`) via configuration file.


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 023f30892..2958a676a 100644
--- a/README.md
+++ b/README.md
@@ -329,6 +329,14 @@ yes = true
 ; Be quiet, suppress all notices/warnings, default: no
 quiet = true
 
+; Define hotkey for pausing recording (suspending capture of output),
+; default: C-\
+pause_key = C-p
+
+; Define hotkey prefix key - when defined other recording hotkeys must
+; be preceeded by it, default: no prefix
+prefix_key = C-a
+
 [play]
 
 ; Playback speed (can be fractional), default: 1
@@ -337,6 +345,14 @@ speed = 2
 ; Limit replayed terminal inactivity to max n seconds, default: off
 idle_time_limit = 1
 
+; Define hotkey for pausing/resuming playback,
+; default: space
+pause_key = p
+
+; Define hotkey for stepping through playback, a frame at a time,
+; default: .
+pause_key = ]
+
 [notifications]
 
 ; Should desktop notifications be enabled, default: yes
diff --git a/asciinema/commands/play.py b/asciinema/commands/play.py
index cf04d9b0b..dd5adf367 100644
--- a/asciinema/commands/play.py
+++ b/asciinema/commands/play.py
@@ -11,11 +11,15 @@ def __init__(self, args, config, env, player=None):
         self.idle_time_limit = args.idle_time_limit
         self.speed = args.speed
         self.player = player if player is not None else Player()
+        self.key_bindings = {
+            'pause': config.play_pause_key,
+            'step': config.play_step_key
+        }
 
     def execute(self):
         try:
             with asciicast.open_from_url(self.filename) as a:
-                self.player.play(a, self.idle_time_limit, self.speed)
+                self.player.play(a, self.idle_time_limit, self.speed, self.key_bindings)
 
         except asciicast.LoadError as e:
             self.print_error("playback failed: %s" % str(e))
diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py
index 904ba5a1d..b91d4726b 100644
--- a/asciinema/commands/record.py
+++ b/asciinema/commands/record.py
@@ -28,6 +28,10 @@ def __init__(self, args, config, env):
         self.writer = raw.writer if args.raw else v2.writer
         self.notifier = notifier.get_notifier(config.notifications_enabled, config.notifications_command)
         self.env = env
+        self.key_bindings = {
+            'prefix': config.record_prefix_key,
+            'pause': config.record_pause_key
+        }
 
     def execute(self):
         upload = False
@@ -78,7 +82,8 @@ def execute(self):
                 capture_env=vars,
                 rec_stdin=self.rec_stdin,
                 writer=self.writer,
-                notifier=self.notifier
+                notifier=self.notifier,
+                key_bindings=self.key_bindings
             )
         except v2.LoadError:
             self.print_error("can only append to asciicast v2 format recordings")
diff --git a/asciinema/config.py b/asciinema/config.py
index 3121a49fd..75703c016 100644
--- a/asciinema/config.py
+++ b/asciinema/config.py
@@ -109,6 +109,14 @@ def record_yes(self):
     def record_quiet(self):
         return self.config.getboolean('record', 'quiet', fallback=False)
 
+    @property
+    def record_prefix_key(self):
+        return self.__get_key('record', 'prefix')
+
+    @property
+    def record_pause_key(self):
+        return self.__get_key('record', 'pause', 'C-\\')
+
     @property
     def play_idle_time_limit(self):
         fallback = self.config.getfloat('play', 'maxwait', fallback=None)  # pre 2.0
@@ -118,6 +126,14 @@ def play_idle_time_limit(self):
     def play_speed(self):
         return self.config.getfloat('play', 'speed', fallback=1.0)
 
+    @property
+    def play_pause_key(self):
+        return self.__get_key('play', 'pause', ' ')
+
+    @property
+    def play_step_key(self):
+        return self.__get_key('play', 'step', '.')
+
     @property
     def notifications_enabled(self):
         return self.config.getboolean('notifications', 'enabled', fallback=True)
@@ -126,6 +142,20 @@ def notifications_enabled(self):
     def notifications_command(self):
         return self.config.get('notifications', 'command', fallback=None)
 
+    def __get_key(self, section, name, default=None):
+        key = self.config.get(section, name + '_key', fallback=default)
+
+        if key:
+            if len(key) == 3:
+                upper_key = key.upper()
+
+                if upper_key[0] == 'C' and upper_key[1] == '-':
+                    return bytes([ord(upper_key[2]) - 0x40])
+                else:
+                    raise ConfigError('invalid {name} key definition \'{key}\' - use: {name}_key = C-x (with control key modifier), or {name}_key = x (with no modifier)'.format(name=name, key=key))
+            else:
+                return key.encode('utf-8')
+
 
 def get_config_home(env=os.environ):
     env_asciinema_config_home = env.get("ASCIINEMA_CONFIG_HOME")
diff --git a/asciinema/player.py b/asciinema/player.py
index e81b5019b..3670a8d7e 100644
--- a/asciinema/player.py
+++ b/asciinema/player.py
@@ -8,16 +8,18 @@
 
 class Player:
 
-    def play(self, asciicast, idle_time_limit=None, speed=1.0):
+    def play(self, asciicast, idle_time_limit=None, speed=1.0, key_bindings={}):
         try:
             stdin = open('/dev/tty')
             with raw(stdin.fileno()):
-                self._play(asciicast, idle_time_limit, speed, stdin)
+                self._play(asciicast, idle_time_limit, speed, stdin, key_bindings)
         except Exception:
-            self._play(asciicast, idle_time_limit, speed, None)
+            self._play(asciicast, idle_time_limit, speed, None, key_bindings)
 
-    def _play(self, asciicast, idle_time_limit, speed, stdin):
+    def _play(self, asciicast, idle_time_limit, speed, stdin, key_bindings):
         idle_time_limit = idle_time_limit or asciicast.idle_time_limit
+        pause_key = key_bindings.get('pause')
+        step_key = key_bindings.get('step')
 
         stdout = asciicast.stdout_events()
         stdout = ev.to_relative_time(stdout)
@@ -42,12 +44,12 @@ def _play(self, asciicast, idle_time_limit, speed, stdin):
                             ctrl_c = True
                             break
 
-                        if 0x20 in data:  # space
+                        if data == pause_key:
                             paused = False
                             base_time = base_time + (time.time() - pause_time)
                             break
 
-                        if 0x2e in data:  # period (dot)
+                        if data == step_key:
                             delay = 0
                             pause_time = time.time()
                             base_time = pause_time - t
@@ -62,7 +64,7 @@ def _play(self, asciicast, idle_time_limit, speed, stdin):
                         ctrl_c = True
                         break
 
-                    if 0x20 in data:  # space
+                    if data == pause_key:
                         paused = True
                         pause_time = time.time()
                         slept = t - (pause_time - base_time)
diff --git a/asciinema/pty.py b/asciinema/pty.py
index 8489bab58..e5d66a632 100644
--- a/asciinema/pty.py
+++ b/asciinema/pty.py
@@ -15,10 +15,13 @@
 from asciinema.term import raw
 
 
-def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0, notifier=None):
+def record(command, writer, env=os.environ, rec_stdin=False, time_offset=0, notifier=None, key_bindings={}):
     master_fd = None
     start_time = None
     pause_time = None
+    prefix_mode = False
+    prefix_key = key_bindings.get('prefix')
+    pause_key = key_bindings.get('pause')
 
     def _notify(text):
         if notifier:
@@ -64,20 +67,30 @@ def _handle_stdin_read(data):
 
         nonlocal pause_time
         nonlocal start_time
+        nonlocal prefix_mode
 
-        if data == b'\x10':  # ctrl+p
-            if pause_time:
-                start_time = start_time + (time.time() - pause_time)
-                pause_time = None
-                _notify('Resumed recording')
-            else:
-                pause_time = time.time()
-                _notify('Paused recording')
-        else:
-            _write_master(data)
+        if not prefix_mode and prefix_key and data == prefix_key:
+            prefix_mode = True
+            return
+
+        if prefix_mode or (not prefix_key and data in [pause_key]):
+            prefix_mode = False
+
+            if data == pause_key:
+                if pause_time:
+                    start_time = start_time + (time.time() - pause_time)
+                    pause_time = None
+                    _notify('Resumed recording')
+                else:
+                    pause_time = time.time()
+                    _notify('Paused recording')
+
+            return
+
+        _write_master(data)
 
-            if rec_stdin and not pause_time:
-                writer.write_stdin(time.time() - start_time, data)
+        if rec_stdin and not pause_time:
+            writer.write_stdin(time.time() - start_time, data)
 
     def _signals(signal_list):
         old_handlers = []
diff --git a/asciinema/recorder.py b/asciinema/recorder.py
index 41a5202b8..1c02d4971 100644
--- a/asciinema/recorder.py
+++ b/asciinema/recorder.py
@@ -9,7 +9,8 @@
 
 def record(path, command=None, append=False, idle_time_limit=None,
            rec_stdin=False, title=None, metadata=None, command_env=None,
-           capture_env=None, writer=v2.writer, record=pty.record, notifier=None):
+           capture_env=None, writer=v2.writer, record=pty.record, notifier=None,
+           key_bindings={}):
     if command is None:
         command = os.environ.get('SHELL') or 'sh'
 
@@ -53,7 +54,8 @@ def record(path, command=None, append=False, idle_time_limit=None,
                 command_env,
                 rec_stdin,
                 time_offset,
-                n
+                n,
+                key_bindings
             )
 
 


Test patch: