Reading a single character from the keyboard using Tcl

DKF 2005-09-08 PYK 2012-11-19:

How to read a single character from the keyboard using just Tcl? (It's pretty easy using a Tk GUI, of course!) Why doesn't just doing [read stdin 1] work?

Well, in fact it does work, but only if the terminal isn't working in line-editing mode. In line-editing mode, the OS terminal engine only sends the text to the applications once the user presses the return key. Guess what mode the terminal is in by default? :^/

In line-editing mode (at least on Unix-like systems), the operating system is doing things like buffering up the data (to make I/O more efficient, providing for functionality like backspacing (perhaps ^H/^?), interruption signaling (perhaps ^C/^?), word deletion, line deletion, line refresh, literal quoting (perhaps ^V), and more. The buffering, for example, means that an application cannot get any input until the user has pressed the Enter/Return type key. Normally, you want this type of pre-processing - these are the things that, at a command line, an application doesn't want to have to worry about. However, often in a text user interface, you want more control than that.

(A little terminology. We're trying to switch things to raw mode here so we can read the "raw" keystrokes, with the default line-editing mode often called cooked mode by contrast.)

Raw Mode on Unix

Use the term module in tcllib

package require term
::term::ansi::ctrl::unix::raw

Alternatively, Unix platforms (e.g. Linux, Solaris, MacOS X, AIX, and even Cygwin, etc.) you can use the stty program to turn raw mode on and off, like this:

exec /bin/stty raw <@stdin
set c [read stdin 1]
exec /bin/stty -raw <@stdin

(We use <@stdin because stty works out what terminal to work with using standard input on some platforms. On others it prefers /dev/tty instead, but putting in the redirection makes the code more portable.)

However, it is usually a good idea to turn off echoing of characters in raw mode. It means that you're responsible for everything, but that's often what you want anyway. Wrapping things up in some procedures, we get this:

proc enableRaw {{channel stdin}} {
   exec /bin/stty raw -echo <@$channel
}
proc disableRaw {{channel stdin}} {
   exec /bin/stty -raw echo <@$channel
}

enableRaw
set c [read stdin 1]
puts -nonewline $c
disableRaw

The following code turns the input keycodes from terminal into more readable form like "ENTER", "PAGEDOWN", "CTRL-C", "F12", etc. Some terminal emulators send additional codes, for example Terminator sends different codes for SHIFT+ARROW and CTRL+ARROW as well. Feel free to extend, most basic keys are included. I cannot figure out how to reliably detect a single ESC keypress, so that doesn't work correctly in the code below. ALT, CTRL and CTRL+ALT is also detected (to some degree). To detect a certain keypress, test what your terminal sends when you press that key.

#!/usr/bin/env tclsh

proc enableRaw {{channel stdin}} {
   exec /bin/stty raw -echo <@$channel
}
proc disableRaw {{channel stdin}} {
   exec /bin/stty -raw echo <@$channel
}

proc put {str} {puts -nonewline $str; flush stdout}

# read char from stdin
proc readchar {} {
        return [read stdin 1]
}

# read key from stdin
proc readkey {} {
        set c [readchar]
        set d [scan $c %c]
        switch $d {
                9   {return TAB}
                10  {return ENTER}
                32  {return SPACE}
                127 {return BACKSPACE}
                27  {
                        set c [readchar]
                        switch $c {
                                \[ {
                                        set c [readchar]
                                        switch $c {
                                                A {return UP}
                                                B {return DOWN}
                                                C {return RIGHT}
                                                D {return LEFT}
                                                F {return HOME}
                                                H {return HOME}
                                                P {return PAUSE}
                                                1 {
                                                        set c [readchar]
                                                        switch $c {
                                                                5 {if {[readchar]=="~"} {return F5}}
                                                                7 {if {[readchar]=="~"} {return F6}}
                                                                8 {if {[readchar]=="~"} {return F7}}
                                                                9 {if {[readchar]=="~"} {return F8}}
                                                                ~ {return HOME}
                                                        }
                                                }
                                                2 {
                                                        set c [readchar]
                                                        switch $c {
                                                                0 {if {[readchar]=="~"} {return F9}}
                                                                1 {if {[readchar]=="~"} {return F10}}
                                                                3 {if {[readchar]=="~"} {return F11}}
                                                                4 {if {[readchar]=="~"} {return F12}}
                                                                ~ {return INSERT}
                                                        }
                                                }
                                                3 {if {[readchar]=="~"} {return DELETE}}
                                                4 {if {[readchar]=="~"} {return END}}
                                                5 {if {[readchar]=="~"} {return PAGEUP}}
                                                6 {if {[readchar]=="~"} {return PAGEDOWN}}
                                                default {return $c}
                                        }
                                }
                                O {
                                        set c [readchar]
                                        switch $c {
                                                P {return F1}
                                                Q {return F2}
                                                R {return F3}
                                                S {return F4}
                                                default {return $c}
                                        }
                                }
                                default {
                                        set d [scan $c %c]
                                        if {$d < 32} {
                                                set d [expr {$d + 64}]
                                                set c [format %c $d]
                                                return CTRL+ALT+$c
                                        }
                                        set c [string toupper $c]
                                        return ALT+$c
                                }
                        }
                }
                default {
                        if {$d < 32} {
                                set k [expr {$d + 64}]
                                set c [format %c $k]
                                return CTRL-$c
                        } else {
                                return [format %c $d]
                        }
                }
        }
}

#-----------------------------------------------------------------------

# test readkey - press 'q' to exit
enableRaw
while 1 {
        set k [readkey]
        if {$k == "q"} break
        put "$k "
}
disableRaw
puts ""

Raw Mode on Windows

A different approach is needed on Microsoft Windows NT-based systems. With Cygwin installed, exec stty works well (tested on Windows XP SP2). Otherwise, this requires the twapi extension.

package require twapi
proc enableRaw {{channel stdin}} {
   set console_handle [twapi::GetStdHandle -10]
   set oldmode [twapi::GetConsoleMode $console_handle]
   set newmode [expr {$oldmode & ~6}] ;# Turn off the echo and line-editing bits
   twapi::SetConsoleMode $console_handle $newmode
}
proc disableRaw {{channel stdin}} {
   set console_handle [twapi::GetStdHandle -10]
   set oldmode [twapi::GetConsoleMode $console_handle]
   set newmode [expr {$oldmode | 6}] ;# Turn on the echo and line-editing bits
   twapi::SetConsoleMode $console_handle $newmode
}

enableRaw
set c [read stdin 1]
puts -nonewline $c
disableRaw

This code was adapted from the Echo-free password entry page with the help of the MSDN reference page for GetConsoleMode .

If you are using TWAPI 0.7 or above, you can use modify_console_input_mode[L1 ] to simplify the above code:

MHo 2021-12-18: Here's my version:

package require twapi

proc enableRawInput {} {
     set console_handle [twapi::GetStdHandle -10]
     set oldmode [twapi::modify_console_input_mode $console_handle -lineinput 0 -echoinput 0]
     proc disableRawInput {} [list twapi::modify_console_input_mode $console_handle {*}$oldmode]
}

enableRawInput
set c [read stdin 1]
puts -nonewline $c
disableRawInput

(Apparently, you're stuck on 95/98/ME.)


schlenk 2005-09-8: The same API exists on Win9x according to the MSDN article referred to above, so TWAPI may work, but it is untested and unsupported on windows 9x.

PWQ 2005-09-10:

Whats wrong with using fileevents:

proc readsingle {} {puts "I read '[read stdin 1]' char"}

fileevent stdin read readsingle

This should work on all platforms

DKF: Once you're in raw mode, using fileevent is exactly the way to do it (assuming you want to keep the event loop active, of course). In cooked mode, the data only ever comes to Tcl in the first place by whole lines, so fileevent works, but isn't too useful for when you want to read less than a line (e.g. a single char).

And if stdin were fconfigured -buffering none?

DKF: That has no effect; the -buffering option is for output not input (and thus isn't useful on stdin at all).

JMN 2005-09-28:

So how would one go about getting the non-ascii keypresses such as arrow keys from a console app under windows?

For Unix systems (I tried it on FreeBSD) the script on determining scan codes for key sequences works ok. On windows however - even if I put the console in raw mode using either the cygwin stty utility or using twapi - various keys such as arrow keys never seem to result in any data on stdin.

2005-10-03

Answering my own question.. I've reached a satisfactory result using Yet another dll caller to call the kernel32 functions: CreateFileA & ReadConsoleInputW. Declared using:

dll::load kernel32 -> k
::k::cmd "int ReadConsoleInputW(int, char *, int, int*)" 
::k::cmd "int CreateFileA(char *, int, int, int *,  int, int, int)"

ReadConsoleInput allows you to supply a buffer which gets filled with various events including keypresses (keyup & keydown) & window focus events. Of course - you have to work out what bytes in the buffer correspond to what parts of the Windows API structures - and you may end up having to map your keyboard scan-codes to ascii values or in the case of arrow-keys etc - to ansi escape sequences.

It's a lot of work because you also have to do things such as track the state of modifier keys such as shift & ctrl to change the values you send. http://vt100.net can be helpful for working out some of the sequences and general ideas behind terminals.

I then use the Memchan package to create a fifo2. One end of the fifo2 can then be transferred to another thread using thread::transfer (Thread package)

This allows you to have the ReadConsoleInput handler running in its own thread, pumping keypress information to your main thread over a channel which you can read instead of stdin. i.e the net result is that you can get basically the same information (and more if needed) you'd get from a raw-mode stdin on a unix platform.

Using CreateFileA on the special file name {conin$} you get a handle for console input (instead of 'GetStdHandle -10' to get stdin). Personally I like the idea of having console data available from a channel other than stdin - so stdin can be used for other data whilst allowing interaction on the console.

Next question... How do I intercept console keypress events on unix platforms so I can keep stdin free?


I tried to use the script given as an example in the paragraph titled Raw Mode on Unix, but I am getting exception like,

/bin/stty: standard input: Invalid argument

Am I missing something? Do I need to add anything to script other than the one given here?

LV: What version of unix are you using?

See Also

1K
the text version of the game provides an example of saving terminal settings, switching into raw mode, and restoring terminal settings at the end of the game, for both *nix and Windows