Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Disallow input at certain times

Tags:

c

input

scanf

I have got a text based game in c that uses scanf.

There are a few times when the player is supposed to type in things, however, while he isn't, the cursor stays in the game, letting the user type in anything he wants, which ruins future scanfs and the story.

Is there a way to disallow input unless there is a scanf waiting for a response?

like image 389
crassfh Avatar asked Jan 06 '23 13:01

crassfh


2 Answers

I think it would be helpful to step back and think about all the moving parts that exist in the execution environment of your program.

When executed, your program becomes a distinct process running in the multitasking environment of the OS. The terminal is a separate process with an associated GUI window, and which may be running locally or remotely (e.g. someone could theoretically run your game from a remote location by connecting over a network via ssh). The user interacts with the terminal program through their keyboard and screen.

Now, it is actually the terminal process (working closely with the OS kernel) that is responsible for most of the nuances of user input. It is the terminal that prints just-typed characters to its GUI window as soon as it receives them, and it is the terminal that maintains an input buffer of characters that have been typed but that have not yet been read by a foreground process.

Conveniently, terminals allow their behavior to be controlled by a set of configuration settings, and these settings can be changed programmatically during the run-time of the connected program. The C-level API that we can use to read and write these settings is called termios.

There's a great article on terminals I highly recommend: The TTY demystified. For the purposes of this question, the section Configuring the TTY device is most useful. It doesn't demonstrate the termios library directly, but shows how to use the stty utility which uses the termios library internally.

(Note that, although the links I've been giving so far are focused on Linux, they are applicable to all Unix-like systems, which includes Mac OS X.)


Unfortunately there's no way to completely "disallow" input with a single switch, but we can achieve the same effect by toggling a couple of terminal settings and manually discarding buffered input at the right times.

The two terminal settings we need to concern ourselves with are ECHO and ICANON. Both settings are normally on by default.

By turning off ECHO, we can prevent the terminal from printing just-typed characters to the terminal window when it receives them. Hence, while the program is running, any characters the user types will seem to be ignored completely, although they will still be buffered internally by the terminal.

By turning off ICANON, we ensure that the terminal will not wait for an enter keypress to submit a complete line of input before returning input to the program, e.g. when the program makes a read() call. Rather, it will return whatever characters it currently has buffered in its internal input buffer, thereby making it possible for us to discard them immediately and carry on with execution.

The full process will look like this:

1: Disable input, meaning turn off ECHO and ICANON.

2: Run some gameplay with output, not requiring any user input.

3: Enable input, meaning discard any buffered terminal input and then turn on ECHO and ICANON.

4: Read user input.

5: Repeat from step 1. Subsequent gameplay can now make use of the latest user input.

There is a complication in step 3 related to discarding buffered input. We can implement this discarding operation by simply reading input from stdin via read() with a fixed-length buffer until there's no more input to be read. But if there's no input ready to be read at all for the discarding operation, then the first call would block until the user types something. We need to prevent this blocking.

I believe there are two ways this could be done. There's such a thing called a non-blocking read, which can be set up with termios or fcntl() (or by opening a second file descriptor to the same endpoint with the O_NONBLOCK flag, I think) which would cause read() to return immediately with errno set to EAGAIN if it would block. The second way is to poll the file descriptor with poll() or select() to determine if there's data ready to be read; if not, we can avoid the read() call completely.

Here's a working solution that uses select() to avoid blocking:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

#include <unistd.h>
#include <termios.h>

struct termios g_terminalSettings; // global to track and change terminal settings

void disableInput(void);
void enableInput(void);

void discardInputBuffer(void);
void discardInputLine(void);

void setTermiosBit(int fd, tcflag_t bit, int onElseOff );
void turnEchoOff(void);
void turnEchoOn(void);
void turnCanonOff(void);
void turnCanonOn(void);

int main(void) {

    // prevent input immediately
    disableInput();

    printf("welcome to the game\n");

    // infinite game loop
    int line = 1;
    int quit = 0;
    while (1) {

        // print dialogue
        for (int i = 0; i < 3; ++i) {
            printf("line of dialogue %d\n",line++);
            sleep(1);
        } // end for

        // input loop
        enableInput();
        int input;
        while (1) {
            printf("choose a number in 1:3 (-1 to quit)\n");
            int ret = scanf("%d",&input);
            discardInputLine(); // clear any trailing garbage (can do this immediately for all cases)
            if (ret == EOF) {
                if (ferror(stdin)) { fprintf(stderr, "[error] scanf() failed: %s", strerror(errno) ); exit(1); }
                printf("end of input\n");
                quit = 1;
                break;
            } else if (ret == 0) { // invalid syntax
                printf("invalid input\n");
            } else if (input == -1) { // quit code
                quit = 1;
                break;
            } else if (!(input >= 1 && input <= 3)) { // invalid value
                printf("number is out-of-range\n");
            } else { // valid
                printf("you entered %d\n",input);
                break;
            } // end if
        } // end while
        if (quit) break;
        disableInput();

    } // end while

    printf("goodbye\n");

    return 0;

} // end main()

void disableInput(void) {
    turnEchoOff(); // so the terminal won't display all the crap the user decides to type during gameplay
    turnCanonOff(); // so the terminal will return crap characters immediately, so we can clear them later without waiting for a LF
} // end disableInput()

void enableInput(void) {
    discardInputBuffer(); // clear all crap characters before enabling input
    turnCanonOn(); // so the user can type and edit a full line of input before submitting it
    turnEchoOn(); // so the user can see what he's doing as he's typing
} // end enableInput()

void turnEchoOff(void) { setTermiosBit(0,ECHO,0); }
void turnEchoOn(void) { setTermiosBit(0,ECHO,1); }

void turnCanonOff(void) { setTermiosBit(0,ICANON,0); }
void turnCanonOn(void) { setTermiosBit(0,ICANON,1); }

void setTermiosBit(int fd, tcflag_t bit, int onElseOff ) {
    static int first = 1;
    if (first) {
        first = 0;
        tcgetattr(fd,&g_terminalSettings);
    } // end if
    if (onElseOff)
        g_terminalSettings.c_lflag |= bit;
    else
        g_terminalSettings.c_lflag &= ~bit;
    tcsetattr(fd,TCSANOW,&g_terminalSettings);
} // end setTermiosBit()

void discardInputBuffer(void) {
    struct timeval tv;
    fd_set rfds;
    while (1) {
        // poll stdin to see if there's anything on it
        FD_ZERO(&rfds);
        FD_SET(0,&rfds);
        tv.tv_sec = 0;
        tv.tv_usec = 0;
        if (select(1,&rfds,0,0,&tv) == -1) { fprintf(stderr, "[error] select() failed: %s", strerror(errno) ); exit(1); }
        if (!FD_ISSET(0,&rfds)) break; // can break if the input buffer is clean
        // select() doesn't tell us how many characters are ready to be read; just grab a big chunk of whatever is there
        char buf[500];
        ssize_t numRead = read(0,buf,500);
        if (numRead == -1) { fprintf(stderr, "[error] read() failed: %s", strerror(errno) ); exit(1); }
        printf("[debug] cleared %d chars\n",numRead);
    } // end while
} // end discardInputBuffer()

void discardInputLine(void) {
    // assumes the input line has already been submitted and is sitting in the input buffer
    int c;
    while ((c = getchar()) != EOF && c != '\n');
} // end discardInputLine()

I should clarify that the discardInputLine() feature I included is completely separate from the discarding of the input buffer, which is implemented in discardInputBuffer() and called by enableInput(). Discarding of the input buffer is an essential step in the solution of temporarily disallowing user input, while discarding the remainder of the input line that is left unread by scanf() is not exactly essential. But I think it does make sense to prevent residual line input from being scanned on subsequent iterations of the input loop. It's also necessary to prevent infinite loops if the user entered invalid input, so for that reason we can probably call it essential.

Here's a demo of me playing around with the input:

welcome to the game
line of dialogue 1
line of dialogue 2
line of dialogue 3
[debug] cleared 12 chars
choose a number in 1:3 (-1 to quit)
0
number is out-of-range
choose a number in 1:3 (-1 to quit)
4
number is out-of-range
choose a number in 1:3 (-1 to quit)
asdf
invalid input
choose a number in 1:3 (-1 to quit)
asdf 1 2 3
invalid input
choose a number in 1:3 (-1 to quit)
0 1
number is out-of-range
choose a number in 1:3 (-1 to quit)
1 4
you entered 1
line of dialogue 4
line of dialogue 5
line of dialogue 6
choose a number in 1:3 (-1 to quit)
2
you entered 2
line of dialogue 7
line of dialogue 8
line of dialogue 9
[debug] cleared 256 chars
[debug] cleared 256 chars
[debug] cleared 256 chars
[debug] cleared 256 chars
[debug] cleared 256 chars
[debug] cleared 256 chars
[debug] cleared 256 chars
[debug] cleared 238 chars
choose a number in 1:3 (-1 to quit)
-1
goodbye

During the first triplet of dialogue I typed 12 random characters which were discarded afterward. Then I demonstrated various types of invalid input and how the program responds to them. During the second triplet of dialogue I didn't type anything, so no characters were discarded. During the final triplet of dialogue I quickly pasted a large block of text into my terminal several times (using a mouse right-click, which is a quick and easy shortcut for pasting into my particular terminal), and you can see it discarded all of it properly, taking several iterations of the select()/read() loop to complete.

like image 99
bgoldst Avatar answered Jan 09 '23 18:01

bgoldst


On Linux and HP-UX machines, use

to disable display of inputs from keyboard on terminal

stty -echo 

to enable display of inputs from keyboard on terminal

stty echo  
like image 22
Chandan Kumar Avatar answered Jan 09 '23 18:01

Chandan Kumar