The Developer’s Cry

Yet another blog by a hobbyist programmer

Textmode box drawing without curses

Back in the DOS days, I would code textmode programs with dialogs and menus that resembled the ones that were common in Novell Netware and Borland IDEs, that used their ‘Turbo Vision’ text UI. I still like that kind of UI because of its simplicity and clear looks. A while ago, I implemented something similar using Python and the curses library. But curses, be damned (forgive me the profanity), it works but it just isn’t a nice API to use. It’s very well possible (and not at all hard) to do without curses and roll your own. Why? Because we can.

A textmode UI starts with drawing a rectangular ‘window’ box in the terminal. The term ‘window’ nowadays belongs to the desktop windowing system, so in code I will simply call it a textmode::Rect. We can draw a plain colored Rect, and we can draw a Rect with a border, using line characters.

Back in the DOS days I would write this kind of stuff in assembly code, poking the characters and the color bytes directly into VGA memory. And it was blazing fast. We can not do that anymore nowadays. In fact, it’s more like traveling back in time. The modern Linux/UNIX terminal is a souped up line printer device from the 1950s. There is no direct access to the screen buffer. The terminal is controlled by archaic ANSI escape sequences. By using those sequences we can move the cursor, select colors, and even clear the screen (oh wow, it’s amazing).

Escape!

The most important sequences for accomplishing our minimal prototype:

The terminal can be controlled by printing these sequences. Doing so shows some screen flicker, which happens because print!() and println!() are line buffered and thus flush the output buffer whenever they encounter a newline. We can prevent screen flicker by using write!() and flushing manually.

pub fn write(msg: &str) {
    write!(stdout(), "{}", msg).expect("unable to write to stdout");
}

pub fn flush() {
    stdout().flush().expect("unable to flush standard output");
}

pub fn clearscreen() {
    write!("\x1b[2J");
}

// move cursor to (x, y)
// home position is (0, 0)
pub fn moveto(column: u16, line: u16) {
    write(format!("\x1b[{};{}H", line, column).as_str());
}

I see your true colors shining through

Textmode colors are selected in pairs, for example: white foreground on a blue background. It is convenient and efficient to implement colors as pairs in code. Additionally, the foreground may be bold/bright. Whether it actually gets rendered that way depends on the terminal settings. Modern terminal programs support 256 colors, but I decided to stick with the classic 16 colors.

pub struct Color {
    pub fgcolor: u8,
    pub bgcolor: u8,
    pub bright: bool,
}

impl Color {
    // Returns the ANSI escape sequence for this Color
    pub fn as_string(&self) -> String { ... }
}

Plain and simple

Let’s implement a Rect. For screen positions we use u16; screen positions won’t be large integers, and they can not be negative when rendering. A more fully fledged UI system would support windows that are partly off-screen, and you would have to do clipping.

Drawing a plain rectangle (in the current color):

pub struct Rect {
    pub x: u16,
    pub y: u16,
    pub w: u16,
    pub h: u16,
}

impl Rect {
    ...

    pub fn draw(&self) {
        let line = format!("{:spaces$}", "", spaces = self.w as usize);

        for yp in self.y..self.y + self.h {
            moveto(self.x, yp);
            write(&line);
        }
    }
}

Borderline

But now we wish to draw a Rect with a border. The border will be made using line characters. In the DOS days, the line characters would be high ALT codes, and they were part of the VGA font. Nowadays we have unicode and UTF-8, and all kinds of fonts. The box drawing characters are starting from unicode point U+2500 and onwards.

I like to pour this into a ‘border definition string’ like so:

String::from("\u{2554}\u{2550}\u{2557}\u{2551} \u{2551}\u{255a}\u{2550}\u{255d}")

We will now draw the border with something akin to this:

pub fn draw(&self) {
    let border_def = ...

    self.draw_top(&border_def);

    for yp in self.y + 1..self.y + self.h - 1 {
        self.draw_middle(self.x, yp, &border_def);
    }

    self.draw_bottom(&border_def);
}

Getting the characters from the border definition string is a bit of a hassle in Rust, resulting in this unreadable bit of code:

pub fn draw_top(&self, border: &str) {
    let top_repeat = border
        .chars()
        .nth(1)
        .unwrap()
        .to_string()
        .repeat((self.w - 2) as usize);
    let top = format!(
        "{}{}{}",
        border.chars().nth(0).unwrap(),
        top_repeat,
        border.chars().nth(2).unwrap()
    );
    moveto(self.x, self.y);
    write(&top);
}

This code can still be improved, but whatever, you get the idea.

It’s quite underwhelming and unimpressive, but the venerable rectangle with border forms the basis of our textmode UI.

Rather than calling it a ‘Window’, I have defined a Panel that has a Rect as rectangular screen area, and a shared pointer to a Style which defines the border style and colors of the Panel. From there on we can go crazy and add Dialog boxes with Buttons, Menus, and so on. The only thing to note is that Rust does not do inheritance; whereas in object-oriented languages a Dialog is a Panel, in Rust a Dialog has a Panel (composition rather than inheritance).

A rusty keyboard

The Linux/UNIX terminal input is also line-buffered. This means that by default a program does not receive any keyboard input until the user hits the return key. What a textmode UI desperately needs is per-character input. In UNIX terminology: raw mode versus cooked mode.

Setting the terminal in raw mode can be done via the tcsetattr() function. I used crate rustix for this:

pub fn raw_terminal() -> Termios {
    let saved_termios = tcgetattr(stdin())
        .expect("unable to obtain termio settings");
    let mut raw = saved_termios.clone();

    raw.c_cflag |= rustix::termios::CREAD | rustix::termios::CLOCAL;
    raw.c_lflag &= !(rustix::termios::ICANON
        | rustix::termios::ECHO
        | rustix::termios::ECHOE
        | rustix::termios::ECHOK
        | rustix::termios::ECHONL
        | rustix::termios::ISIG
        | rustix::termios::IEXTEN);
    raw.c_oflag |= rustix::termios::OPOST;
    raw.c_iflag &= !(rustix::termios::INLCR
        | rustix::termios::IGNCR
        | rustix::termios::ICRNL
        | rustix::termios::IGNBRK);
    raw.c_cc[rustix::termios::VMIN] = 1;
    raw.c_cc[rustix::termios::VTIME] = 0;

    tcsetattr(stdin(), rustix::termios::OptionalActions::Flush, &raw)
        .expect("unable to set the terminal in raw mode");

    saved_termios
}

Now we can read keys from the keyboard one-by-one. Special keys, like the arrow keys again produce escape sequences; for example, the Up arrow key produces ESC[A. Some keys produce even a 4-byte sequence. What I do is simply translate to a u32 integer:

pub fn getch() -> u32 {
    // wait for keyboard input
    // now is a good time to flush any pending output
    flush();

    let mut buf = [0u8; 4];
    let n = stdin().read(&mut buf).unwrap();

    let mut cursor = std::io::Cursor::new(buf);
    let mut key = cursor.read_u32::<BigEndian>().unwrap();
    if n < 4 {
        // this shift makes that cursor keys are 0x001b5b00 (3 bytes)
        // and home/end/pagedown are 0x1b5b0000 (4 bytes)
        key >>= (4 - n) * 8;
    }

    /* debug
        if key <= 0x7f {
            println!("key == '{}'  0x{:02x}", buf[0] as char, key);
        } else {
            println!("key == 0x{:08x}", key);
        }
    */
    key
}

There is a pesky problem, typically with Home and End, that terminals send different codes for special keys depending on termcap settings and terminal emulation. This is something that curses is better at, but do mind that even curses is still known to produce headaches. It’s best to avoid these problems and not use special keys at all. For example, the F-keys (function keys) are typically caught by the windowing system, using them in a textmode UI is plain evil.

For better or worse

The curses library is also better at doing fallbacks like switching to black-and-white mode and substituting box drawing characters with pluses and minuses. Or maybe I should say “better” (between double quotes), because it tends to fallback unexpectedly and then you spend some time fixing your terminal.

I added Panel shadow by plotting dark gray spaces. This actually overwrites any text that was underneath, making very hard shadows. If you wish to have more realistic shadow that only darkens the color while still preserving the text that was there, then this can only be done by emulating the screen buffer. I actually did this in my Python hexview program. It’s a lot more work, but it can be done.

If you really like Turbo Vision, there is a modern port of it in C++ on github.

Back in the DOS days, later versions of Norton Utilities had the most beautiful textmode UI of all; they redefined the character set so that they could have pretty radio buttons, checkboxes, scroll arrows, and tight-looking wide window borders right around the edge of the window. Their blue was a lighter blue, and they even had a smooth mouse pointer, in text mode (!) The exact look is hard to emulate today because it either means using a custom font, or going full into graphics mode to draw the UI. This could be a nice project for some other day.