Readline Hell

Posted on Sunday, 04 May 2008 at 16:24.

My GHC’s default getLine behaviour isn’t very pretty — pressing arrow keys or even backspace during input results in question marks and escape characters instead of cursor movement or deletion of characters. That’s why I decided to use readline for my MUD client, because readline handles these key presses nicely, comes preinstalled with GHC and has plenty of other features, such as a history. But boy, was it hard to get readline to do what I wanted!

The first problem was that readline wants to know exactly what the current prompt is. When communicating with a MUD, the MUD decides what the prompt is at any time, if any. If I didn’t tell readline explicitly about the prompt whenever it changed, it would delete the entire prompt whenever the user hit Control-U to remove input. This shouldn’t be hard to fix; it’s fairly easy to determine what part of the MUD’s output is the prompt. But in the Haskell wrapper library System.Console.Readline, there is no function that maps to rl_set_prompt. Instead, a prompt can only be specified at the readline call site, and that function blocks until the user has finished writing. In other words, it was impossible to change the prompt in between readline calls. It took me a while to discover that I could abuse rl_message instead of rl_set_prompt.

Then emptying the line buffer proved troublesome; simply calling rl_delete_text(0, n) would leave the user unable to type any new characters. After about three hours’ full-time debugging I found out this is because the cursor location (rl_point) isn’t automatically set to 0 when removing all text. In other words, the line buffer would be the empty string, but the cursor would still be (internally, not visibly) at, say, position 10.

Here is the final code for writing text to the terminal, preserving input the user hasn’t confirmed yet, and pushing it forward whenever new text is written:

module WriteToTTY where

import System.Console.Readline
import Control.Monad (when)
import Data.List (elemIndices)

writeToTTY :: String -> IO ()
writeToTTY msg = do
  -- Find out which part of the message is the prompt.
  let (prePrompt, prompt) = splitAtPrompt msg

  -- Empty buffer.
  buf <- getLineBuffer
 pt <- getPoint
 setLineBuffer ""
 setPoint 0 -- AAARGH

 Print prePrompt.
 when (prePrompt > (String, String)
splitAtPrompt cs = case elemIndices 'n' cs of
  [] -> ("", cs)
  is -> splitAt (last is + 1) cs

Here is a program to test module WriteToTTY. Run it with runhaskell. You can also remove the newline character in the let msg; then the prompt will replace itself. Pretty cool!

module Main where

import System.Console.Readline
import Control.Concurrent
import WriteToTTY

main :: IO ()
main = do
  forkIO (output 0)

output :: Int -> IO ()
output n = do
  let msg = ("n" ++ show n ++ "> ")
  writeToTTY msg
  threadDelay 1000000
  output (n + 1)

input :: IO ()
input = do
  maybeLine <- readline ""
 putStrLn ("Read " ++ show maybeLine)
 case maybeLine of
 Nothing -> return ()
    Just line -> do
      addHistory line

Leave a comment!

Martijn loves to receive comments! Add yours by filling out the fields below.