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:
- ESC is an escape character
\x1b
ESC[2J
clears the screenESC[ line ; column H
moves the cursor positionESC[37;44;1m
selects color bright white on blueESC[0m
resets colors to default
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.
\u{2550}
═ box drawings double horizontal\u{2554}
╔ box drawings double down and right\u{2557}
╗ box drawings double down and left\u{2551}
║ box drawings double vertical\u{255a}
╚ box drawings double up and right\u{255d}
╝ box drawings double up and left
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 Button
s, Menu
s, 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.