03 January 2011

Creating fancy pure Bash tui-mode selection menu methods





Are you bored of just text running over your terminal?
Bash, simple as it is, also enables you creating tui-mode menus!
When I tried to do that five or six years ago it was a real nightmare.
Well I am not promise you heaven, but I can promise you pretty much nice and usable selection menu.
It is not easy to build a menu under Bash, because it lacks a lot of functions that some advanced languages have.
In addition everybody will try to discourage you: "Use ncurses or dialog - it is the only way".
Well if I look back, they were all correct - it is much faster, shorter, easier but I still wanted pure Bash!
Now you will ask: "Why is that? It is easy just to use some library and create menus!"
But I have at least two reasons for that:

- I want my programs to be fancy but not depend on some external libraries

- Sometimes it is hard to install or even compile all libraries you need, especially on some tiny Linux distributions
To be able to do some eye-candy programming under Bash, you will need to use some functions that almost look like a binary code.
There is nothing very complicated down there, you just be sure to type down all that code correctly.
If you are looking for detailed explanation on functions below, please see a Linux Journal article
'The power of echo command: Bash console drawing methods and some useful tput-like functions'.
Some 'strange' functions
Put functions below (or the ones you need) to the beginning of your bash script and you are ready to move forward.
#!/bin/bash
e='echo -en'                                     # shortened echo command variable
   ESC=$( $e "\033")                             # variable containing escaped value 
 CLEAR(){ $e "\033c";}                           # clear screen
 CIVIS(){ $e "\033[?25l";}                       # hide cursor
 CNORM(){ $e "\033[?12l\033[?25h";}              # show cursor
  TPUT(){ $e "\033[${1};${2}H";}                 # terminal put (x and y position)
COLPUT(){ $e "\033[${1}G";}                      # put text in the same line as the specified column
  MARK(){ $e "\033[7m";}                         # select current line text
UNMARK(){ $e "\033[27m";}                        # normalize current line text
  DRAW(){ $e "\033%@";echo -en "\033(0";}        # switch to 'garbage' mode to be able to draw
 WRITE(){ $e "\033(B";}                          # return to normal (reset)
  BLUE(){ $e "\033c\033[0;1m\033[37;44m\033[J";} # clear screen, set background to blue and font to white
The challenge
Not to make things very complicated, I would like just a simple script that responds to arrow-keys.
But how to convince arrows to produce some actions at the Bash console?
Those are keys responsible for moving around the screen (mostly just back and forward).
So I created a simple experiment: if I just type 'read' at the Bash command prompt and press 'enter', cursor will wait for me to enter some input.
If I type some letters, they will appear as normal characters.
But if I press one of the arrow-keys, I will get more than one character for each arrow-key pressed:
up:     ^[[A
down:   ^[[B
left:   ^[[D
right:  ^[[C
Things are not getting any easier when you find out that first two characters '^[' are actually a single character that represents ESCAPE.
How to intercept that?
How to complicate simple things
Until I did not know 'read' command better I found on the Internet many articles that tried to get a single characters from pressed arrow-keys.
The most wild idea which turned into my humble experiment is using 'dd' command instead of 'read' which is able to intercept a single character if you define it correctly of course.
So my main problem as I found later was how to catch or display 'ESCAPE' character.
Along with 'dd' command you do not have to use 'read' as well.
So on the Internet I've got an idea, just to let all characters to try show up when certain key is pressed but intercept them as a stream.
Take a look below.
The price of universality is complexity.
Not acceptable for a beginner at all!
Also not too functional.
This was just crazy!
At least 23 lines to intercept a single key?
To make characters 'capturing' and actions function properly, you also need to adjust stty - terminal line settings.
And this is different for different terminals on different operating systems!
You can try this script, but complexity in compare to result will just not impress you.
And it is slow!
It is even nicer if you run such script in a loop.
If you then press a down or up arrow-key, script will put all your continuous actions
into queue and it will run your menu as long as many key pressed actions were executed.
Not too usable you need to agree.
#!/bin/bash
# constructed by oTo
# mentioned just to respond to one arrow-key (up or down) and exit
# and to work on different UNIX systems
ESC=$(echo -en "\033")
                                 ####### for LINUX and AIX this is the same
                                 sttyvar="stty -echo  cbreak"
if [ "`uname`" = "HP-UX" ]; then sttyvar="stty  echo -icanon"; fi
if [ "`uname`" = "SunOS" ]; then sttyvar="stty -echo  icanon"; fi

GetKey(){                                                               # type dd, wait for input and...
    first=`dd bs=1 count=1 2>/dev/null`; case "$first" in $ESC)         # intercept first character
   second=`dd bs=1 count=1 2>/dev/null`; case "$second" in '['|'0'|'O') # intercept second one (different for some consoles)
    third=`dd bs=1 count=1 2>/dev/null`; case "$third" in               # intercept the third character in a string
                        A|OA) first=UP;;                                # decision for up
                        B|OB) first=DN;;                                # decision for down arrow-keys
                          *) first="$first$second$third";; esac;;       # all the other combinations send to eternity ;)
                          *) first="$first$second";;
esac ;; esac; echo "$first";}
ARROW(){ stty -echo cbreak;Key=`GetKey`
           case "$Key" in
               UP) echo "UP";;                                          # on key up print UP
               DN) echo "DN";;                                          # on key down print DN
                *) echo "`echo \"$Key\" | dd  2>/dev/null`";;           # ignore the rest
           esac;}
cursor=$(ARROW)
reset                                                                   # screen reset is required or you will notice
     if [[ "$cursor" = "UP" ]]; then echo UP; fi                        # some confused behavior after script ends.
     if [[ "$cursor" = "DN" ]]; then echo DN; fi


Can you do it shorter?
Yes I can! Here you have shortened version.
It will do the same but if you put it into a loop, you will not be able to get out.
Even a 'trap' command will not save you!
You will also notice some strange behaviour this way.
After every loop-run, shell will add some aditional spaces in front of our predefined output.
So this one is not usable for our needs.
But you can play with it and perhaps construct a game that responds to cursor movements?
stty_state=`stty -g`
  stty raw
  stty -echo
  keycode=`dd bs=1 count=1 2>/dev/null`
  keycode=`dd bs=1 count=1 2>/dev/null`
  keycode=`dd bs=1 count=1 2>/dev/null`
  if [ "$keycode" = "A" ];then echo up;fi
  if [ "$keycode" = "B" ];then echo dn;fi


Simplified arrow-keys reader engine using advanced 'read' options
Command 'read' which is Bash internal, also hides some goodies inside.
But I didn't know that at the beginning.
It
is not just able to read some value into some variable - it can also read-in certain amount of characters from input.
If -n is supplied it reads 'n' characters from supplied input into variable and stops reading
If -s is specified, operation will be done quietly even if input comes from the terminal.
Those additional functionalities of 'read' command can drastically simplify our script.
Especially if you know how to serve ESCAPE characters properly.
Yes 'properly'!.
As you can see below 'ESC' is not a function but a variable that already contains an escaped value.
If 'ESC' would be a function, it would start executing when program would already expect a value.
And you - as I was - would just observe strange behavior not knowing what is going wrong.
Below is simplified Bash script, that will display to you which arrow-key was pressed.
Simplicity also has some drawbacks.
Command 'read' can respond slow and you will produce some garbage on the screen.
It might also not respond on other terminals or UNIX system the way you will expect.
Script as simple as it is, enables you to continuously press arrow-keys and get an info on each pressed key.
Well, anyway we have some progress now don't we? In only 9 lines!
But this is just a beginning.
#!/bin/bash
ESC=$(echo -en "\033")                     # define ESC
while :;do                                 # infinite loop
read -s -n3 key 2>/dev/null >&2            # read quietly three characters of served input
  if [ "$key" = "$ESC[A" ];then echo up;fi # if A is the result, print up
  if [ "$key" = "$ESC[B" ];then echo dn;fi #    B                      dn
  if [ "$key" = "$ESC[C" ];then echo ri;fi #    C                      ri
  if [ "$key" = "$ESC[D" ];then echo le;fi #    D                      le
done
What's next?
We can handle our cursor/arrow-keys now but where is the menu?
Well here it is.
Yes I know, it looks more like an ugly tree than a fancy program.
It contains no real programming clarity.
The only thing that makes it worth trying is 55 lines.
I just did not have a heart to serve you with a hundred lines or more and take away from you all enthusiasm before you even start.
So program is optimized to smallest lines number not to greater readability.
This way perhaps you will try to make some improvements.
#/bin/bash
       E='echo -e';e='echo -en';trap "R;exit" 2
     ESC=$( $e "\033")
    TPUT(){ $e "\033[${1};${2}H";}
   CLEAR(){ $e "\033c";}
   CIVIS(){ $e "\033[?25l";}
    DRAW(){ $e "\033%@\033(0";}
   WRITE(){ $e "\033(B";}
    MARK(){ $e "\033[7m";}
  UNMARK(){ $e "\033[27m";}
    BLUE(){ $e "\033c\033[H\033[J\033[37;44m\033[J";};BLUE
       C(){ CLEAR;BLUE;}
    HEAD(){ MARK;TPUT 1 4
            $E "BASH SELECTION MENU                       ";UNMARK
            DRAW
            for each in $(seq 1 9);do
             $E "   x                                        x"
            done;WRITE;}
            i=0; CLEAR; CIVIS;NULL=/dev/null
    FOOT(){ MARK;TPUT 11 4
            printf "ENTER=SELECT, UP/DN=NEXT OPTION           ";UNMARK;}
   ARROW(){ read -s -n3 key 2>/dev/null >&2
            if [[ $key = $ESC[A ]];then echo up;fi
            if [[ $key = $ESC[B ]];then echo dn;fi;}
POSITION(){ if [[ $cur = up ]];then ((i--));fi
            if [[ $cur = dn ]];then ((i++));fi
            if [[ i -lt 0   ]];then i=$LM;fi
            if [[ i -gt $LM ]];then i=0;fi;}
 REFRESH(){ after=$((i+1)); before=$((i-1))
            if [[ $before -lt 0  ]];then before=$LM;fi
            if [[ $after -gt $LM ]];then after=0;fi
            if [[ $j -lt $i      ]];then UNMARK;M$before;else UNMARK;M$after;fi
            if [[ $after -eq 0   ]] || [[ $before -eq $LM ]];then
            UNMARK; M$before; M$after;fi;j=$i;UNMARK;M$before;M$after;}
      M0(){ TPUT 3 20; $e "Option0";}
      M1(){ TPUT 4 20; $e "Option1";}
      M2(){ TPUT 5 20; $e "Option2";}
      M3(){ TPUT 6 20; $e "Option3";}
      M4(){ TPUT 7 20; $e "Option4";}
      M5(){ TPUT 8 20; $e "ABOUT  ";}
      M6(){ TPUT 9 20; $e "EXIT   ";}
     LM=6    #Last Menu number
    MENU(){ for each in $(seq 0 $LM);do M${each};done;}
    INIT(){ BLUE;HEAD;FOOT;MENU;}
      SC(){ REFRESH;MARK;$S;cur=`ARROW`;}
      ES(){ MARK;$e "\nENTER = main menu ";$b;read;INIT;};INIT
while [[ "$O" != " " ]]; do case $i in
      0) S=M0;SC;if [[ $cur = "" ]];then C;$e "o0:\n$(w        )\n";ES;fi;;
      1) S=M1;SC;if [[ $cur = "" ]];then C;$e "o1:\n$(ifconfig )\n";ES;fi;;
      2) S=M2;SC;if [[ $cur = "" ]];then C;$e "o2:\n$(df -h    )\n";ES;fi;;
      3) S=M3;SC;if [[ $cur = "" ]];then C;$e "o3:\n$(route -n )\n";ES;fi;;
      4) S=M4;SC;if [[ $cur = "" ]];then C;$e "o4:\n$(date     )\n";ES;fi;;
      5) S=M5;SC;if [[ $cur = "" ]];then C;$e "o5:\n$($e by oTo)\n";ES;fi;;
      6) S=M6;SC;if [[ $cur = "" ]];then C;exit 0;fi;;
esac;POSITION;done
Let me also explain some crucial sections/function that I've used.
Function HEAD
To have at least some minimal frame around your menu, custom function HEAD will draw a header and side lines.
It will not give you an exact frame, because it will took me a few more lines of "empty coding".
You will get left and right border. As you already noticed some commands are separated with too much spaces.
Those spaces are responsible for your program layout.
So adjust them the way you like.
Function FOOT
To have frame closed, we need to add also something at the bottom.
There should be short usage description as you can see it from a picture below.
In both cases MARK and UNMARK custom functions are used to reverse background and foreground colors.
So you do not need too much 'drawing'.
Function POSITION
This function locates and manages menu selection positions.
Without this one you will get an error when cursor keys will be pressed too many times.
Instead of that, this function gives you a nice rotating effect.
When you come to the end of menus you can press down arrow-key one more time and you will be returned to the start.
The same applies if you go from another direction.
Function REFRESH
To prevent too much refreshing, I constructed a simple hack that will refresh only menus at the neighbors positions.
Without that full screen might need to be refreshed for each your action. What it actually does is the following:
for every pressed up or down arrow-key, it 'repaints' menu options this way: sets normal layout for options in front
and after selected option, paints selected option with the inverted color scheme.
'while' loop
At the end of script, you will find numbered lines, where can you put your commands or even functions.
If you will use custom functions, they need to be defined at the start of this script.
Or you can also call them from another file this way (also at the beginning of menu script):
. /path/to/functions-file
'Dot' should be separated from 'slash'. This way you will call all custom settings into current environment.
What you can expect if you typed all lines as you should is something similar to the picture below.
You will not see border around your program as presented on the picture, you will see border disappear under header and footer.
So I challenge you to try to create the exact look as you can see below.

BASH SELECTION MENU



Option0


Option1


Option2


Option3


Option4


ABOUT


EXIT
ENTER=SELECT, UP/DN=NEXT OPTION

How to expand it?
If you need to add more menu options or perhaps take some of them away, you need to adjust some variables.
This is not nice I admit but it is doable.
First you need to find lines that start with M0, M1, ...
Those lines describe menu values.
Add a line here, increase a 'TPUT' line number, and enter custom description.
Adjust 'LM' variable, which is just a number of all menus.
Under 'while' loop add an aditional line nad adjust starting number '0-5)' and 'M0-5' number.
If you will add too much options, you will also need to adjust 'HEAD' and 'FOOT' options for 'TPUT'.
And this is it.
Conclusion
I do not expect you to say: "What a great script!"
I am expecting you to improve it, make it even shorter and faster and more usable.
Perhaps you can try with arrays?
Surprise me and surprise yourself.
If you will have troubles, try to use complicated 'dd' engine, which will assure you to be multi-system compatible.
But I sincerely hope you will survive with the simple version.

6 comments:

  1. Just what I was looking for :)

    ReplyDelete
  2. I modified ur script and create a project here to add more functionalities: to allow multiple selection, to have scroll selection and so on: https://github.com/dxj19831029/bash_menu_ui

    ReplyDelete
  3. I'm new to unix and bash scripting. How would I go about adding a sub-menu? Would that be most easily accomplished by a function?

    Also, could anyone tell me how I could incorporate text entry fields?

    ReplyDelete
  4. for your purpose use DIALOG
    http://invisible-island.net/dialog/dialog.html

    ReplyDelete
    Replies
    1. your page has restrict access from Argentina, why? could you give me access, pls. Thx!!

      Delete
  5. Was just playing with this and notice that LEFT, RIGHT or any random 3 letter will act as if you pressed the ENTER key which is not what is intended. However looks at this needs to clean that up before using it for real. Otherwise, nice starter menu script.

    ReplyDelete