The things you have to do for an interactive terminal interface

Programming language libraries usually expect terminal interaction to take place one print line or read line at a time, and don’t have much support for detecting keypresses or changing what’s already written on the screen. Still, POSIX and other generally followed standards allow more dynamic interaction techniques that are compatible across many systems, and this post demonstrates how some of them can be made to work in Java with a little use of JNI, the Java native interface to call C code (so you can also read it as a short introduction JNI). If you work in C, you can skip the Java parts and figure out what to do by looking at the C fragments.

The final source files that we arrive at can be downloaded as a zip file.

My own reason for looking into this was improving interaction of the kliptrack player and adding the kliptune for audio capture in Klipspringer version 4.1, and adding tracksplit for splitting a recording into tracks in version 4.2.

Going backwards in the output

The programs I write are usually either are non-interactive Unix command style programs or something that has an HTTP interface (like the Klipspringer hub) so I can control it from a web browser, which is a much less painful way to a GUI than with a complex API. But for some use, a 1980s style terminal interface is more convenient, which doesn’t necessarily mean settling for regarding the output as if it was just a line printer.

If you look around the internet for ways to more flexible terminal output, you may find something like ncurses, designed to take over the terminal window with something that can end up sort of like a primitive-look GUI. But if you want earlier output in the terminal to stay as it was, and your program to just go backwards and replace what it itself has printed, ncurses is not the tool you’re looking for, and you don’t need to learn a whole API. All you need a couple of escape sequences, supported by virtually all terminal windows (and which ncurses also depends on).

An escape sequence starts with the escape character code, 27, or 1b in hexadecimal, and the ones we are going to use continue with a [ and then a capital letter, where A means cursor up and K means erase line. Those two are all we need for this post, although you may want to look into what else is available (and how you can add numeric parameters).

To combine the A and K escape sequences you can write "\u001b[A\u001b[K" in Java ("\x1b[A\x1b[K" in C), which makes a string that is interpreted by the terminal as “go up one line and erase to the end of the line”.

Let’s try it out. Here’s a Runnable Java class that loops, continuously updating the output with how many seconds have passed, until quit is called. The methods are synchronized since a private member variable is changed and inspected in different threads.

public class Display implements Runnable {
    private static java.io.Console cons = System.console();
    private boolean quit = false;

    public synchronized void quit() {
        quit = true;
        notifyAll();
    }

    public synchronized void run() {
        String erase = "";                      // nothing to erase at first
        long start = System.currentTimeMillis();
        while (!quit) {
            long passed = System.currentTimeMillis() - start;
            cons.printf("%s%d seconds%n", erase, passed/1000);
            erase = "\u001b[A\u001b[K";         // now something to erase
            try { wait(1000); } catch (InterruptedException ex) { }
        }
    }
}

We add a class with a main method that gets a Display going, and then waits for the user to hit Enter, at which point it quits the display and exits.

public class TerminalExample {
    public static void main(String[] args) throws java.io.IOException {
        Display disp = new Display();
        new Thread(disp, "Display").start();
        System.in.read();                       // wait for Enter
        disp.quit();
    }
}

It probably doesn’t work to run this from an IDE, you should run it in an ordinary terminal. If you also compile in the terminal, you use the following commands:

javac Display.java TerminalExample.java
java TerminalExample

So that was the output, without any need for JNI. But we want to be able to directly react on other keypresses than Enter.

Reading keypresses

Java has an API for listening to key events, but as far as I understand it only works in a GUI, and that’s not what we’re doing. Another approach would be to read directly from the Linux device handle for the keyboard, like you can do to get remote control keypresses, but it would only work in Linux, only when the keyboard is physically connected to the machine (not when you logged in via ssh), and it would be difficult to combine with other terminal input.

So instead, we are simply going to change the terminal input settings to do what we want, which is just a simple system call in a native method. Let’s add a class Terminal:

public class Terminal {
    static {
        System.loadLibrary("termex");
        Runtime.getRuntime().addShutdownHook(new Thread() {
                public void run() { stopKeyListen(); }
            });
    }

    private static boolean nonCanonical = false;

    public static synchronized void startKeyListen() {
        if (nonCanonical) { return; }
        setupNonCanonical();
        nonCanonical = true;
    }

    public static synchronized void stopKeyListen() {
        if (!nonCanonical) { return; }
        cancelNonCanonical();
        nonCanonical = false;
    }

    public static native int getChar();

    private static native void setupNonCanonical();
    private static native void cancelNonCanonical();
}

The public methods startKeyListen and stopKeyListen check that the member boolean nonCanonical is in the right state, and if so call their respective native methods to do the system call, and finally set/reset nonCanonical. Since they are synchronized, their native counterparts don’t have to worry about simultaneous memory access. The public native method getChar gets one character from standard input, which means just the next keypress if startKeyListen has been called to activate key listening. Calling stopKeyListen returns the terminal to canonical (normal line input) mode.

The static section at the top of Terminal starts with a loadLibrary call to load the dynamic library that’s going to contain the actual native methods. Then there’s an addShutdownHook which makes stopKeyListen be called when the program exits for whatever reason. It’s important not to exit in non-canonical mode, because the terminal would stay that way and make the shell act weirdly.

We change TerminalExample to look like this:

public class TerminalExample {
    public static void main(String[] args) {
        Display disp = new Display();
        new Thread(disp, "Display").start();
        Terminal.startKeyListen();
        while (true) {
            int ch = Terminal.getChar();
            if (ch < 0 || ch == 'q') { break; }
        }
        Terminal.stopKeyListen();
        disp.quit();
    }
}

It calls startKeyListen1 before entrering a while loop that reads a keypress at a time, breaking the loop only when the pressed key is q or if there was an error that made getChar return −1. It then calls stopKeyListen to restore input to normal, quits the display and exits.

Now the implementation of the native methods, which we place in Terminal.c.2

#include "Terminal.h"                           // generated by javac -h
#include <stdbool.h>
#include <termios.h>
#include <unistd.h>

#define METHOD(name) JNICALL Java_Terminal_ ## name

static struct termios orig_termios;

JNIEXPORT void
METHOD(setupNonCanonical) (JNIEnv *env,
                           jclass jthisClass)
{
    struct termios ios;
    tcgetattr(STDIN_FILENO, &ios);
    orig_termios = ios;

    ios.c_lflag &= ~ICANON & ~ECHO;
    ios.c_cc[VMIN] = 1;
    ios.c_cc[VTIME] = 0;
    tcsetattr(STDIN_FILENO, TCSANOW, &ios);
}

JNIEXPORT void
METHOD(cancelNonCanonical) (JNIEnv *env,
                            jclass jthisClass)
{
    tcsetattr(STDIN_FILENO, TCSANOW, &orig_termios);
}

JNIEXPORT jint
METHOD(getChar) (JNIEnv *env,
                 jclass jthisClass)
{
    return getchar();
}

The file starts with including Terminal.h, a generated header file with native method declarations, and some other header files we need. Then a METHOD macro, which is my preferred way to make JNI method heads more readable, and the struct orig_termios, where we will save the original state of the terminal.

Then we get to the implementation of setupNonCanonical. I peeked in the generated TerminalExample.h to get the return type and parameter list right. The method calls tcgetattr to get the current terminal state into ios, copies it to orig_termios and then changes ios to the desired non-canonical mode without echoing (read the man page for termios(4) on your system for details). It then calls tcsetattr so the modified settings take.

The cancelNonCanonical just restores the saved terminal settings, and getChar calls the normal function to read a character from stdin.3

Compiling the native code

Compiling and using JNI code takes some commands that are hard to type and remember. I like to put them all together in a makefile that compiles the source code and creates an executable script to run the program. In the zip of the files for this post you find Makefile.prep which looks like this:

CFLAGS = -O3 -Wall -lc
JNI_INCLUDE = -I$(JAVA_HOME)/include -I$(JAVA_HOME)/include/$(OS)

all: TerminalExample.class Terminal.class Display.class libtermex.$(SOEXT) termex

clean:
        rm -rf *.class Terminal.h libtermex.$(SOEXT) termex

Terminal.h Terminal.class: Terminal.java
        javac -h . Terminal.java

TerminalExample.class Display.class: TerminalExample.java Terminal.java Display.java
        javac TerminalExample.java Display.java

libtermex.$(SOEXT): Terminal.c Terminal.h
        $(CC) $(CFLAGS) $(JNI_INCLUDE) $(JNIFLAGS) -o libtermex.$(SOEXT) Terminal.c

termex:
        echo '#!/bin/sh' > termex
        echo 'java -cp $(BINDIR) --enable-native-access=ALL-UNNAMED -Djava.library.path=$(BINDIR) TerminalExample "$$@"' >> termex
        chmod 755 termex

It’s not quite a working makefile (hence the .prep) because it lacks some system specific macro definitions which we’ll add at the top, but the targets are there: standard all and clean targets, targets for Java compilation output files, a target for the libtermex dynamic library (which is compiled with the standard C compiler on your system), and finally a target for a termex shell script which starts the program by invoking java TerminalExample with the correct parameters.

To add the system specific macros to the top, the zip also contains a small configure script:

#!/bin/sh

SCRIPT=$(readlink -f "$0")
BINDIR=$(dirname "$SCRIPT")
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
JAVA_HOME=$(java -XshowSettings:properties -version 2>&1 | grep java\.home | sed 's/[[:blank:]]*java.home[[:blank:]]*\=[[:blank:]]*//')

if [ "$OS" = "darwin" ]
then
    JNIFLAGS="-dynamiclib -fPIC"
    SOEXT="dylib"
else
    JNIFLAGS="-shared -fPIC"
    SOEXT="so"
fi

{
    echo "JAVA_HOME = $JAVA_HOME"
    echo "BINDIR = $BINDIR"
    echo "OS = $OS"
    echo "JNIFLAGS = $JNIFLAGS"
    echo "SOEXT = $SOEXT"
} > Makefile

cat Makefile.prep >> Makefile

When you run ./configure, it should4 find the correct values for JAVA_HOME, BINDIR. OS, JNIFLAGS, and SOEXT for your system, put their definitions in Makefile, and then append the contents of Makefile.prep.

So to sum up: see that configure and Makefile.prep are in the current directory along with TerminalExample.java, Terminal.java, Terminal.c, and Display.java, and do the following to compile and run:

./configure
make
./termex

Interrupting getchar

You may want to break out of the keypress-listening while loop for some other reason than the user hitting a specific key. For instance, say that our display example should stop after five seconds if the user hasn’t hit q before that. It’s simple to modify Display.run to drop out of its while loop after five seconds, but it’s trickier to break out of the main loop, since it’s stuck in getChar, waiting for a keypress. We need a way to interrupt getChar.

Looking around, I found that one solution might be a Unix signal. The man page for sigaction(2) on my system says:

If a signal is caught during the system calls listed below, the call may be forced to terminate with the error EINTR, the call may return with a data transfer shorter than requested, or the call may be restarted. Restart of pending calls is requested by setting the SA_RESTART bit in sa_flags. The affected system calls include open(2), read(2), write(2), sendto(2), recvfrom(2), sendmsg(2) and recvmsg(2) on a communications channel or a slow device (such as a terminal, but not a regular file) and during a wait(2) or ioctl(2).

The getchar function probably calls read(2) to fetch from stdin, so does that mean that if we set up a signal action without SA_RESTART, the signal will make getchar return? It turns out that it does.

But let’s start with the Java parts. We expand Terminal.java with another boolean member,

private static boolean interruptEnabled = false;

three more private native methods declarations,

private static native void setupSigaction();
private static native void cancelSigaction();
private static native void sigThread();

and synchronized public Java methods to call the native ones:

    public static synchronized void enableInterruptGetChar() {
        if (interruptEnabled) { return; }
        setupSigaction();
        interruptEnabled = true;
    }

    public static synchronized void disableInterruptGetChar() {
        if (!interruptEnabled) { return; }
        cancelSigaction();
        interruptEnabled = false;
    }

    public static synchronized void interruptGetChar() {
        if (!interruptEnabled) { throw new IllegalStateException("Not in interruptable state"); }
        sigThread();
    }

Then we add a five second limit to Display.run, and call interruptGetChar when it’s reached.

public synchronized void run() {
    String erase = "";                      // nothing to erase at first
    long start = System.currentTimeMillis();
    while (!quit) {
        long passed = System.currentTimeMillis() - start;
        if (passed > 5000) {               // time to stop
            Terminal.interruptGetChar();
        }
        cons.printf("%s%d seconds%n", erase, passed/1000);
        erase = "\u001b[A\u001b[K";         // now something to erase
        try { wait(1000); } catch (InterruptedException ex) { }
    }
}

Then the implementation of the new native methods, and a new version of getchar, in Terminal.c. (You find the complete new version in the zip.) These static globals are added:

static struct sigaction orig_sigact;
static pthread_t getchar_thread;
static bool getchar_active = false;
static pthread_mutex_t getchar_mutex = PTHREAD_MUTEX_INITIALIZER;

The orig_sigact struct is for saving the original signal handling state to be able to restore it, getchar_thread will hold the thread where getchar is called (because the signal has to be specifically sent to that thread), getchar_active will be true when when getchar is in progress, and getchar_mutex is used for thread synchronization (because access to getchar_active and getchar_thread need to be mutex-protected).

The biggest chunk of code is the modified getChar implementation:

JNIEXPORT jint
METHOD(getChar) (JNIEnv *env,
                 jclass jthisClass)
{
    pthread_t me = pthread_self();
    bool success = false;

    pthread_mutex_lock(&getchar_mutex);
    if (!getchar_active) {                      // there can be only one
        getchar_thread = me;
        getchar_active = true;
        success = true;
    }
    pthread_mutex_unlock(&getchar_mutex);
    if (!success) { return -1; }

    int ch = getchar();

    pthread_mutex_lock(&getchar_mutex);
    getchar_active = false;
    pthread_mutex_unlock(&getchar_mutex);

    return ch;
}

In the first mutex section, it sets getchar_thread to the current thread and getchar_active to true (unless there is already a getChar in progress, which results in an error). Then comes the actual call to get_char, followed by another mutex section which sets getchar_active false.5

For the signal handling functions, we have to decide which signal to use for interrupting getchar. I’ve picked SIGHUP, “terminal line hangup”, which normally has no use on today’s systems, but I expect that any signal except SIGKILL would do the trick.

static void handle_sig(int signum) {
    // do nothing
}

JNIEXPORT void
METHOD(setupSigaction) (JNIEnv *env,
                        jclass jthisClass)
{
    struct sigaction sh;

    sh.sa_handler = handle_sig;
    sigemptyset(&sh.sa_mask);
    sh.sa_flags = 0;
    sigaction(SIGHUP, &sh, &orig_sigact);
}

JNIEXPORT void
METHOD(cancelSigaction) (JNIEnv *env,
                         jclass jthisClass)
{
    sigaction(SIGHUP, &orig_sigact, NULL);
}

JNIEXPORT void
METHOD(sigThread) (JNIEnv *env,
                   jclass jthisClass)
{
    pthread_t it;

    pthread_mutex_lock(&getchar_mutex);
    if (!getchar_active) { return; }
    it = getchar_thread;
    pthread_mutex_unlock(&getchar_mutex);

    pthread_kill(it, SIGHUP);
}

The handle_sig function is what we tell sigaction to call when the signal is caught, but it doesn’t have to do anything.6 In setupSigaction, we call sigaction to set up the signal catching, and in cancelSigaction we also call sigaction, to return handling of SIGHUP to whatever it was before. Finally, what sigThread does is to send SIGHUP to getchar_thread using pthread_kill, unless getchar_active is false, because that means nobody’s waiting for a keypress right now and getchar_thread doesn’t have a sensible value.

Compile and run the modified version just like before:

./configure                     # not needed if you did it already
make
./termex

Command line editing

When you do terminal input in the normal canonical mode way (one command line a time entered at a prompt), it’s nice if you can use the ▲ and ▼ keys to walk around in the commands you entered before, and pick one to edit and enter again. I anticipated spending hours integrating GNU Readline or something to get that to work, but to my surprise it just worked by itself! Try this:

java.io.Console cons = System.console();
while (true) {
    cons.readLine("prompt> ");
}

Type something at the prompt and press enter. Then if you press ▲, if you are using JDK 22 or later, what you entered should reappear. It works on my Mac, where I’m using JDK version 23. If it doesn’t work on your system, you can probably get it to work by upgrading your Java installation to the latest version.

Notes

  1. It matters that this call comes after the display has been created, because otherwise the call to System.console in Display might block until the first keypress
  2. For readability, the C code in this post doesn’t check the return values of any system calls for error condition. You can look at the Klipspringer Terminal.c for hints of how to do conservative error handling. Note that since its method implementations raise java.io.IOException on error, the native declarations have throws clauses.
  3. Actually, getChar isn’t necessary in this case, we could use something in java.io to read a character, but we need our own keypress reading method in the next example.
  4. The script should work on current Linux, macOS, BSD, and possibly other systems but isn’t guaranteed to work in all situations on all Unix variants through history. For instace, the readlink -f syntax was only adopted by macOS and BSD around 2022. Writing shell scripts that are widely compatible is a rabbit hole that you can spend days on and wind up with incomprehensibly complex commands, but I decided to keep it simple here.
  5. Again, I’m not doing proper error handling here, in the interest of readability.
  6. In the Klipspringer implementation, the function sets a boolean value to handle the case that the signal was caught after getchar exited normally because of a key press, but I don’t know if this unlikely case ever happens in practice.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.