clartaq
clartaq

Reputation: 5382

Reading Characters from the Terminal in Raw Mode using Scheme

I started reading through the article "Build Your Own Text Editor" about how to build a simple terminal-based text editor in C. I thought it would be fun to see if something similar could be done in Scheme (Chez, Racket, or Chicken).

I am having problems with capturing terminal input in raw mode. I can't figure out how to obtain a character immediately on a keypress. I put together the following monstrosity (which does not work anyway) in Racket based on the answer for Common Lisp at Rosetta Code.

;; echo and work
(define (eaw)
  (let ((acc "")
        (ip (current-input-port))
        (done #f))
    (let loop ()
      (if (char-ready? ip)
          (let ((c (read-char ip)))
            (begin
              (if (char=? c #\q)
                  (begin
                    (set! done #t)
                    acc)
                  (begin
                    (when (not done)
                      (set! acc (string-append acc (string c))))
                    (loop)))))
          (if done
              (begin
                (display (string? acc))
                acc)
              (begin
                ;; do some work
                (display ".")
                (sleep 0.15)
                (loop)))))))

The Racket example on the same page doesn't work for me -- problems with trying to open the terminal.

It looks like Chez's expediter, used in the REPL, is written in C. In the Chicken ncurses library, it calls out to the C runtime to implement the getch function. Not sure where Racket implements it.

Is there no way I can implement raw keyboard input in Scheme (any one of them) so the program can respond immediately to shortcut keys and so on?

Upvotes: 2

Views: 720

Answers (2)

clartaq
clartaq

Reputation: 5382

After seeing how to use the stty.egg for Chicken mentioned by @Shawn, I came up with a way to do it in Racket as well. Here's a complete example.

#lang racket/base

(require ffi/unsafe
         ffi/unsafe/define)

;; If c is a control character, it is converted into a two-character
;; string that indicates its special status by preceding it with a "^".
(define (char->readable c)
  (if (char-iso-control? c)
      (string #\^ (integer->char (+ 64 (char->integer c))))
      (string c)))

;; Return a transform of the input string with embedded control chars converted
;; to human-readable form showing a "^" prepended to it.
(define (string->readable s)
  (let loop ((lst (string->list s))
             (acc ""))
    (if (null? lst)
        acc
        (loop (cdr lst) (string-append acc (char->readable (car lst)))))))
 
;; Display a series of arguments followed by a newline.
(define (println . args)
  (for-each display args)
  (newline))

;; When in raw mode, lines need to be ended with CR/LF pairs to act
;; like normal printing.
(define (println-in-raw . args)
  (for-each display args)
  (display (string #\return #\newline)))

;;------------------------------------------------------------------------------
;; Machinery to interface with C and handle getting in and out of "raw" mode
;; on the terminal.

(define-ffi-definer define-c (ffi-lib #f))

;; int tcgetattr(int fd, struct termios *termios_p);
;; Copy the current attributes into the buffer (termios struct)
;; pointed to. Returns 0 on success, -1 on failure in which case errno
;; will containt the error code.
(define-c tcgetattr (_fun _int _pointer -> _int))

;; int tcsetattr(inf fd, int optional_atcions,
;;               const struct termios *termios_p);
;; Copy the buffer (termios struct) pointed to into the terminal
;; associated the the integer file descriptor. Returns 0 on success,
;; -1 on failure code is copied into errno.
(define-c tcsetattr (_fun _int _int _pointer -> _int))

;; void cfmakeraw(struct termios *termio_p);
(define-c cfmakeraw (_fun _pointer -> _void))

;; Explanation of `termios` and `cfmakeraw`. This is from the `cfmakeraw(3)`
;; Linux man page. Descriptions reflect the result of related flags settings.
;;
;; termios_p->c_iflag &= ~(IGNBRK |  // Do not ignore a BREAK
;;                         BRKINT |  // BREAK will produce a null byte
;;                         PARMRK |  // Ignore parity errors
;;                         ISTRIP |  // Do not strip the eigth bit
;;                         INCLCR |  // Do not translate NL to CR on input
;;                         IGNCR  |  // Do not ignore carriage return on input
;;                         ICRNL  |  // Do not translate a carriage return to newline
;;                         IXON   ); // Disable XON/XOFF flow control on output
;; termios_p->c_oflag &= ~OPOST;     // Disable implementation-defined output processing
;; termios_p->c_lflag &= ~(ECHO   |  // Do not echo characters
;;                         ECHONL |  // Do not echo newline characters
;;                         ICANON |  // Disable canonical mode ("cooked") processing
;;                         ISIG   |  // Do not generate INTR, QUIT, SUSP or DSUSP signals
;;                         IEXTEN ); // Disable implemenation-defined input processing
;; termios_p->c_cflag &= ~(CSIZE  |  // No character size mask
;;                         PARENB ); // Turn off parity generation
;; termios_p->c_cflag |= CS8;        // 8-bit characters
;;
;; NOTE: The use of 8-bit characters makes this a bit incompatible with the `read-char`
;; procedure, which works with UTF-8 characters. Needs more work.

;; The following definitions and structure are from the file
;; /Library/Developer/CommandLineTools/SDKs/MacOSX10.15.sdk/System/Library/Frameworks/Kernel.framework/Headers/sys/termios.h
;; on an iMac.

;; #define NCCS            20
;; typedef unsigned long   tcflag_t;
;; typedef unsigned char   cc_t;
;; typedef unsigned long   speed_t;

(define NCCS 20)

(define-cstruct _termios
  ([c_iflag _ulong] ; input flags
   [c_oflag _ulong] ; output flags
   [c_cflag _ulong] ; control flags
   [c_lflag _ulong] ; local flags
   [c_cc (_array _ubyte NCCS)] ; special control chars
   [c_ispeed _ulong] ; input speed
   [c_ospeed _ulong] ; output speed
   ))

(define (malloc-termios-buffer)
  (malloc 'atomic (ctype-sizeof _termios)))

(define (inner-read-line)
  (define running #true)
  (let ((acc ""))
    (let loop ()
      (let ((c (read-char)))
        (cond
          ;; Look for characters picked to terminate a line. (Choose any
          ;; you want.)
          [(or (eof-object? c)
               (char=? c #\q)
               (char=? c #\newline)
               (char=? c #\return)) (begin
                                      (println-in-raw "Finished because c = "
                                                      (char->readable c))
                                      (set! running #f))]
          [#t (set! acc (string-append acc (string c)))]))
      (if (not running)
          acc
          (loop)))))

(define (enable-raw-mode fd termios-struct)
  (cfmakeraw termios-struct)
  (tcsetattr fd 0 termios-struct))

(define (disable-raw-mode fd attrs)
  (tcsetattr fd 0 attrs))

;; Read a line from the standard input in raw mode and return it. The
;; existing terminal attributes are read and restored before exiting.
(define (read-raw-line)
  (let* ((std-in-fd 0) ; Couldn't find in headers, but is it everywhere else.
         (orig-attrs (malloc-termios-buffer))
         (raw-attrs (malloc-termios-buffer)))
    (tcgetattr std-in-fd orig-attrs)
    (letrec ((get-raw! (lambda ()
                         (enable-raw-mode std-in-fd raw-attrs)))
             (get-cooked! (lambda ()
                            (disable-raw-mode std-in-fd orig-attrs))))
      (let ((a-line (dynamic-wind
                     get-raw!
                     inner-read-line
                     get-cooked!)))
        a-line))))

(println "Enter an invisible line of text:")
(println "result of reading line in raw mode: "
         (string->readable (read-raw-line)))

As mentioned by @Sylwester, this is an implementation-specific function. The example above was created with Racket v7.9[bc]. It won't run correctly in DrRacket since the evaluation pane is not really a terminal. In a terminal opened in the same directory as the file, you can just run racket direct_tty.rkt at the command prompt to play with the program.

It is roughly equivalent to step 6 in the article about building a text editor mentioned in the question.

It has one more nice (IMO) feature in that it ensures the terminal is returned to its original settings before exiting even in the event of an error or "time-traveling" continuations. See the usage of dynamic-wind in the read-raw-line procedure.

There is (at least) one potential problem with this implementation in that the read-char procedure used in inner-read-line can handle Unicode. In the set up of raw mode, any capability for the handling of Unicode is disabled and input is restricted to 8-bit characters. I thought that kept the example simpler rather that having to put in a bunch of bit fiddling to turn that back on.

Also, I don't think all of the stuff to set up the termios structure is really required. If all you need is cfmakeraw to get the behavior you want, I believe you can get by with just passing a big enough buffer to tcgetattr, tcsetattr and cfmakeraw. There doesn't need to be any underlying structure in the buffer unless you need to do additional bit fiddling. But I have not tested this.

Update: 23 Nov 2020: There is now a gist showing how to do this with Chez Scheme.

Upvotes: 1

Sylwester
Sylwester

Reputation: 48775

It is not possible to lister to check if an input port if there is a character to read or not since the Scheme standard doesn't supply such a method. You cannot do this in a portable way with Sceheme.

Most Scheme implementations and related dialects have features beyond the Scheme language. Eg. Racket has char-ready? which can be used together with read-char. If you use these features your program is not longer written in Scheme but the extended language of the implementation. If you are willing to accept that creating a text editor will be in reach.

Upvotes: 1

Related Questions