Search

Loading...

Monday, October 11, 2010

GNU Readline Vi Mode Visualization

GNU readline provides possibility to use VI editing mode rather than standard EMACS one. Based on the VI editor it means that we have two distinct modes while editing a line and a way of switching between them. Insert mode for inserting text and command or normal mode for applying commands on the current line. Of course the key bindings for modes are separated. In terms of readline the key bindings specific to some editing mode are recognized as 'keymap'. After specifying the keymap like 'set keymap vi-insert' any key binding you specify will apply to selected keymap. See my GNU Readline post for details.

For VI editing mode we're interested in 2 keymaps:

  • vi-insert - insert mode
  • vi-command - command mode

Because there are two keymaps it is very handy to always now in which mode you're editing session is. And here the story begins. Right after I've discovered the option to use VI editing mode with readline I wanted some kind of visualization or indicator in which mode I'm currently in. More over it was important to get this for all readline applications. Usage of bash or python interpreter would become much more user friendly for me.

Because it was bugging me for a long time I've started with the investigation over the web with the assumption that it is probably solved somehow already. But surprisingly after a hours of searching and digging I've only found several threads where it was discussed but afterwards discussion turned on how to achieve the similar thing in zsh which can substitute bash (and it's on my list) but what about other readline applications (like i/python interpreter I use).

I was already sick from searching so I've decided to hack readline source. After refreshing my C knowledge and few initial hacks I've quite easily found place where my interim code was conceptually doing exactly what I wanted. After that I was only playing around making things more flexible.

Below I will describe my readline patch, what it provides, how to configure things and after that options for visualization I've tried. Note that the patch works for me, I've intentionally touched only absolutely necessary readline code to leave the patch very simple however I am aware that it can be optimized better or done in more integrated way. It was intended for my personal use and I don't think it is ready for including in mainstream readline.

Readline patch (improved version of the patch is described here)

All changes are done in file 'vi_mode.c'. I've introduced 3 functions:

  • static void _vi_mode_changed (mode_name) const char *mode_name;

    This function is called whenever the mode has been changed and the name of the target mode is passed as string argument ('insert' or 'command').

    static void
    _vi_mode_changed (mode_name)
        const char *mode_name;
    {
        if (!isatty (STDIN_FILENO) || !isatty (STDOUT_FILENO)) {
            return;
        }
        _vi_mode_changed_pipe (mode_name);
        _vi_mode_changed_bin (mode_name);
    }
    
  • static void _vi_mode_changed_pipe (mode_name) const char *mode_name;
    • if the environment variable _READLINE_VI_MODE_CHANGED_PIPE is not defined terminate this function
    • if the file designated by _READLINE_VI_MODE_CHANGED_PIPE doesn't exist create the named pipe under that path
    • open file designated by _READLINE_VI_MODE_CHANGED_PIPE with O_WRONLY | O_NONBLOCK
    • write current mode name to the opened file and close it
  • static void _vi_mode_changed_bin (mode_name) const char *mode_name;
    • if the environment variable _READLINE_VI_MODE_CHANGED_BIN is not defined terminate this function
    • fork current process
    • in new process exec the command designated by _READLINE_VI_MODE_CHANGED_BIN, pass mode name as a first parameter and redirect 'stdin' to '/dev/null' othervise keep 'stdout' and 'stderr' intact
    • in parent process if the environment variable _READLINE_VI_MODE_CHANGED_BIN_SYNC is defined wait for the child process to terminate

Mode indicator

The bash 'PROMPT_COMMAND' is executed every time before the prompt is going to be written and usually contains the escape sequence to write the window title (in archlinux this is set in '/etc/bash.bashrc'). Let's unset it as we don't need it anymore.

excerpt from ~/.bashrc
unset PROMPT_COMMAND

If you specify '_READLINE_VI_MODE_CHANGED_BIN' like below it will be executed on every mode change and as a first parameter the mode name will be passed.

excerpt from ~/.bashrc
export _READLINE_VI_MODE_CHANGED_BIN="$HOME/.inputrc_vi_mode_changed"

Note that the things can run out of sync unless you use '_READLINE_VI_MODE_CHANGED_BIN_SYNC' or inside the visualization script you ignore mode passed as parameter but use mode generated by '_READLINE_VI_MODE_CHANGED_PIPE'.

excerpt from ~/.bashrc [this is setup I use]
export _READLINE_VI_MODE_CHANGED_BIN="$HOME/.inputrc_vi_mode_changed"
export _READLINE_VI_MODE_CHANGED_BIN_SYNC=""

If you want to use '_READLINE_VI_MODE_CHANGED_PIPE' you can specify path to some already existing file and then you will get there always current editing mode. It's good idea to put postfix to the filename designating the current TTY or it can be something generated. For example something like below:

excerpt from ~/.bashrc
TTY="$(tty)"
TTY="${TTY//'/dev/'}"
export _READLINE_VI_MODE_CHANGED_PIPE="$HOME/.inputrc_vi_mode_changed.pipe.${TTY//'/'/_}"
touch $_READLINE_VI_MODE_CHANGED_PIPE

Idea behind named pipe was that there is no need to create new process every time mode changes. Readline just writes new mode name to named pipe. Then some extra process (shell script for example) can read the named pipe and perform logic on each new line.

~/.inputrc_vi_mode_changed
#!/bin/bash

# $ man console_codes
# http://en.wikipedia.org/wiki/ANSI_escape_code
# http://rtfm.etla.org/xterm/ctlseq.html
# http://linuxgazette.net/137/anonymous.html
# http://wiki.archlinux.org/index.php/Color_Bash_Prompt
# http://www.comptechdoc.org/os/linux/howlinuxworks/linux_hlvt100.html

MODE="$1"
#MODE=`cat $_READLINE_VI_MODE_CHANGED_PIPE`
CURSOR_COLOR='\e]12;%s'
HSTATUS="$USER@${HOSTNAME%%.*}:${PWD/$HOME/~}"

cursor_color() {
    printf "$CURSOR_COLOR\e\\" "$@"
}

cursor_color_screen() {
    printf "\eP$CURSOR_COLOR\a\e\\" "$@"
}

cursor_color_linux() {
    printf '\e[?16;0;%sc' "$@"
}

prompt() {
    printf '\e[s\e[G%s\e[u\e\\' "$@"
}

hstatus() {
    printf '\e]0;%s\e\\' "$@"
}

case "$MODE" in
    'insert')
        if [[ "$TERM" =~ ^(xterm|rxvt) ]]; then
            cursor_color '#C0C0C0'
            hstatus "[INS] $HSTATUS"

        elif [[ "$TERM" =~ ^screen ]]; then
            cursor_color_screen '#C0C0C0'
            hstatus "[INS] $HSTATUS"

        elif [[ "${TERM}" =~ ^linux ]]; then
            cursor_color_linux 112

        fi
        ;;
    'command')
        if [[ "$TERM" =~ ^(xterm|rxvt) ]]; then
            cursor_color 'sienna2'
            hstatus "[CMD] $HSTATUS"

        elif [[ "$TERM" =~ ^screen ]]; then
            cursor_color_screen 'sienna2'
            hstatus "[CMD] $HSTATUS"

        elif [[ "$TERM" =~ ^linux ]]; then
            cursor_color_linux 64

        fi
        ;;
esac

Indicator which I like the most is cursor color change. However there are some problems:

  • reseting of cursor color

    Imagine that you switch to command mode which triggers cursor color to red, after that you press CR / LF which triggers 'accept-line' (in bash it runs command in buffer) the cursor color remains set in the running application which is awkward. I've solved this the way that insert mode cursor color is the default one, once I switch to command mode cursor color changes to some other color. I've setup the readline the way that before 'accept-line' is called from command mode I will switch to insert mode which in turns brings my cursor back to default.

  • cursor color in screen

    I've managed to find out escape sequence to change cursor color in screen. However, there is problem with screen and cursor color. The problem is that while switching screen windows the cursor color is not reset to reflect color last set in target window. This may be expected since there in no such terminal capability for setting cursor color.

  • cursor color in tmux

    I was not able to find escape sequence which would work in tmux.

At last, below is the mentioned readline patch in form of unified diff. To make installation easier I've created an Arch Linux PKGBUILD which you can find over here.

vimode-changed-hook.patch
+++ vi_mode.c 2010-10-17 21:35:05.502262516 +0200
@@ -49,6 +49,13 @@
 
 #include <stdio.h>
 
+#if defined (HAVE_FCNTL_H)
+#   include <fcntl.h>
+#endif
+
+#include <sys/stat.h>
+#include <errno.h>
+
 /* Some standard library routines. */
 #include "rldefs.h"
 #include "rlmbutil.h"
@@ -655,6 +662,79 @@
 
 /* Insertion mode stuff. */
 
+/* This is meant to be called after mode changes
+   to $mode_name (e.g. 'insert' or 'command'). */
+static void
+_vi_mode_changed_pipe (mode_name)
+    const char *mode_name;
+{
+    char *env_pipe;
+    int fd_pipe;
+    FILE *pipe;
+
+    env_pipe = getenv ("_READLINE_VI_MODE_CHANGED_PIPE");
+    if (env_pipe == NULL) {
+        return;
+    }
+    if (mknod (env_pipe, S_IRUSR | S_IWUSR | S_IFIFO, 0) < 0 && errno != EEXIST) {
+        return;
+    }
+    fd_pipe = open (env_pipe, O_WRONLY | O_NONBLOCK);
+    if (fd_pipe < 0) {
+        return;
+    }
+    pipe = fdopen (fd_pipe, "w");
+    if (pipe == NULL) {
+        close (fd_pipe);
+        return;
+    }
+    fprintf (pipe, "%s\n", mode_name);
+    fflush (pipe);
+    fclose (pipe);
+}
+
+static void
+_vi_mode_changed_bin (mode_name)
+    const char *mode_name;
+{
+    char *env_bin, *env_bin_sync;
+    pid_t pid;
+    int status, fd_devnull;
+
+    env_bin = getenv ("_READLINE_VI_MODE_CHANGED_BIN");
+    if (env_bin == NULL) {
+        return;
+    }
+    pid = fork ();
+    if (pid < 0) {
+        perror ("_vi_mode_changed_bin: fork failed");
+        return;
+    }
+    else if (pid == 0) {
+        close (STDIN_FILENO);
+        fd_devnull = open ("/dev/null", O_RDONLY);
+        dup2 (fd_devnull, STDIN_FILENO);
+        execl (env_bin, env_bin, mode_name, NULL);
+        perror ("_vi_mode_changed_bin: execv failed");
+        exit (1);
+    }
+    env_bin_sync = getenv ("_READLINE_VI_MODE_CHANGED_BIN_SYNC");
+    if (env_bin_sync != NULL) {
+        waitpid (pid, &status, 0);
+    }
+}
+
+static void
+_vi_mode_changed (mode_name)
+    const char *mode_name;
+{
+    if (!isatty (STDIN_FILENO) || !isatty (STDOUT_FILENO)) {
+        return;
+    }
+    _vi_mode_changed_pipe (mode_name);
+    _vi_mode_changed_bin (mode_name);
+}
+
 /* Switching from one mode to the other really just involves
    switching keymaps. */
 int
@@ -663,6 +743,9 @@
 {
   _rl_keymap = vi_insertion_keymap;
   _rl_vi_last_key_before_insert = key;
+
+  _vi_mode_changed ("insert");
+
   return (0);
 }
 
@@ -747,6 +830,9 @@
     rl_free_undo_list ();
 
   RL_SETSTATE (RL_STATE_VICMDONCE);
+
+  _vi_mode_changed ("command");
+
   return (0);
 }