Half-Height Console Graphics with Haskell and ncurses
Note: Since writing this post, I’ve written this technique up as a standalone library.
Motivation
Most terminals print characters twice as long as they are wide. This means that any character-based graphics that naively treat a character as a pixel are going to end up distorted. There are plenty of ways around this, but I wanted to write up the ncurses-based solution I came up with when writing a terminal-only roguelike engine in Haskell recently with support for simulating square pixels in an otherwise rectangular terminal.
A debug run showing the end result: full-height text at the bottom of the terminal, and dense half-height ncurses graphics at the top.
Problem and Solution
The best examples of character per pixel ncurses rendering are those using the libtcod engine:
This looks good, but note that each cell is the same height as the text around it (in the above screenshot, this effect is minimised by using a shorter monospaced font). We’d like two modes: one for denser cell-based graphics and another for full-sized text, both in the same terminal window.
Our solution will be to represent the canvas as a (W, (H/2)) dimension 2D array of Upper Half Block (▀) characters, where (for example) if we take the top-left cell in (x, y) coordinates as (0, 0), we set the foreground colour to our canvas’ (0, 0) colour and the background colour to our canvas’ (0, 1) colour, simulating two rows of half-height pixels using only one row of characters.
I’ll omit most of the setup - here we have a simple 2D array graphics buffer (each cell is one of 15 colours), and an environment that contains a camera position and viewport width and height. This function draws the buffer to the screen as half-height block characters. We pair up rows in chunks of two, and render within the Update
monad of UI.NCurses
.
We lose two things by taking this approach:
- Cells can only be block colour and can’t contain text (they already contain the half-block character).
- The colour pallette must be limited, as we’ll now see.
Colour Representation in ncurses
There’s a problem for colour-hungry use-cases. ncurses allows you to use the predefined terminal colour library, and also allows you to overwrite these built-in colours and extend to a table of 255 custom colours (colour 0 cannot be overridden) - but the snag is that a “custom colour” is really a combination of background and foreground colour.
With our condensed representation, if we have a pallette of say 4 custom colours, we would need 16 custom fg/bg pairs to represent each possibility that might arise. With our available 255 slots, then, we can only represent 15 unique colours.
Using the Color
type from UI.NCurses
, we first define a pair type for our block cells and implement Ord
so we can look up the 1-255 colour key for a given pair:
Colour definition itself is stateful: we need to register our 255-colour pallette inside the Curses
monad. The ColorMap
itself is just a convenience lookup keeping track of which logical colour-pairs map on to which terminal colour ID. The library requires colours in RGB-1000 format - we include a quick-and-dirty method for constructing these from hex strings.
And finally, in order to both set the colours and track the pairwise mapping, we call the following init function to get a Curses ColorMap
:
With a fully constructed map (whose keys are foreground/background pairs), we can get the block representation for a two-row cell by simple lookup: