Announcing `safe-coloured-text`

Date 2021-03-07

This post announces the new safe-coloured-text library. The prompt to make this library was that the maintainer of the rainbow package didn't want to get rid of the lens dependency to decrease its dependency footprint.

I learnt a lot while writing this library, and it has produced some of the prettiest output that I have ever seen in my terminal. It turns out that most "coloured text" libraries have quite nasty failure modes like environment-dependent crashes, so I made sure to make the library safe, wherever possible.

Once I had gotten started, I figured I would implement things the way I like them.

Comparison to other libraries

There are a few "coloured text" libraries on hackage, but they all had bits that I didn't like.

Why not use ansi-terminal?

The ansi-terminal library is the oldest library I considered, so it is probably the most-used, however:

  • It uses String and does not support Text.
  • It doesn't have the concept of a Chunk, but is rather more low-level.
  • Nit: It spells "colour" as "color".

Why not use ansi-wl-pprint?

  • It uses String and does not support Text.
  • It has a rather large dependency footprint.
  • It does not support 8-bit or 24-bit colours.
  • It has first-class colours, but they are in the Internal module.
  • Nit: It spells "colour" as "color".

Why not use rainbow?

The rainbow library is what I would have recommended until recently. It is still a fine library, however:

  • It depends on lens, which has an enormous dependency footprint.
  • It does not support 24-bit colours.
  • Nit: It outputs rather more ANSI escape codes than necessary.
  • Nit: It uses the concept of a byteStringMaker function instead of having a printable value that represents how many colours the terminal supports.
  • Nit: It spells "colour" as "color".

Why not use colourista?

There is a nice library by the lovely folks at Kowainik that also deals with coloured text in the terminal: colourista. It is very nicely documented, of course, but it differs from safe-coloured-text

  • It uses the unsafe (and slower) Data.Text.IO.putStrLn. See Haskell, the bad parts
  • It does not support 8-bit or 24-bit colours.
  • It does not determine whether where you want to print to supports colours.
  • It doesn't have the concept of a Chunk, but is rather more low-level.
  • Nit: It has constants for colour codes, rather than typed colours.
  • Nit: It has more dependencies.
  • Nit: It spells "colour" as "color" sometimes.

Why not use pretty-terminal?

Why use safe-coloured-text?

Safe outputting

The number one reason for me to prefer the safe-coloured-text library is that it uses the safer Data.ByteString.Builder.hPutBuilder to output the rendered colourful text.

-- | Print a list of chunks to the given 'Handle' with given 'TerminalCapabilities'.
hPutChunksWith :: TerminalCapabilities -> Handle -> [Chunk] -> IO ()
hPutChunksWith tc h cs = SBB.hPutBuilder h $ renderChunks tc cs

See Haskell, the bad parts for a more detailed explanation of why this is important.

It is also probably more performant, but I don't really care about that.

Tests

The safe-coloured-text library is well-tested. It has functional tests and a large amount of golden tests to make sure that the library keeps working.

The golden tests take advantage of sydtest's built-in golden test support and they are beautiful:

A very colourful golden test

Chunks

The safe-coloured-text library defines a Chunk as a piece of text that is styled the same way throughout. It is built using chunk :: Text -> Chunk or using the IsString instance, and can be styled using combinators that set different style attributes.

When determining how to output a chunk, the library throws away any the unsupported colours. This way one build up a Chunk-based data structure without worrying about terminal emulator colour support, and only render it when necessary.

String types

The library is rather opinionated about the string types it uses, on purpose. There is no built-in support for String, building coloured text uses Text, and the output is a ByteString Builder. There's distinctly more difficult to 'accidentally' use System.IO.putStr or Data.Text.IO.putStr and have code that works in development but crashes in production, than it is to just use the safe ByteString Builder output functions.

Considering coloured text "bytes" rather than "text" could be contentious because the necessary ANSI escape codes still fit in the ASCII. However, the fact that there is extra work required to circumvent this decision and get a Text value is entirely on purpose. Bite me.

Minimal dependencies

The only dependencies are bytestring, terminfo and text:

library:
  source-dirs: src
  dependencies:
  - bytestring
  - terminfo
  - text

If the terminfo dependency ever gets us into trouble, we can factor it out as well.

Minimal output

This is not a big deal, but safe-coloured-text takes some care to not output more than necessary. For example, these two strings look the same, but the second is almost twice as long:

safe-coloured-text:

\ESC[34mTests:\ESC[m




  Passed:                   \ESC[32m0\ESC[m
  Failed:                   \ESC[32m0\ESC[m
  Test suite took  \ESC[33m         0.00 seconds\ESC[m

rainbow:

\ESC[0m\ESC[38;5;4mTests:\ESC[0m
\ESC[0m\ESC[0m
\ESC[0m\ESC[0m
\ESC[0m\ESC[0m
\ESC[0m\ESC[0m
\ESC[0m  \ESC[0m\ESC[0mPassed:                   \ESC[0m\ESC[0m\ESC[38;5;2m0\ESC[0m
\ESC[0m  \ESC[0m\ESC[0mFailed:                   \ESC[0m\ESC[0m\ESC[38;5;2m0\ESC[0m
\ESC[0m  \ESC[0m\ESC[0mTest suite took  \ESC[0m\ESC[0m\ESC[38;5;3m         0.00 seconds\ESC[0m
\ESC[0m\ESC[0m

The safe-coloured-text does not emit sequences if a chunk is completely plain, but it does not deal with inter-chunk inefficiencies.

8-bit and 24-bit colours

There are three types of colours that are regularly supported in terminal emulators:

  • The standard 8 terminal colours:
    • Black
    • Red
    • Green
    • Yellow
    • Blue
    • Magenta
    • Cyan
    • White
  • 8-bit colours:
    • the above 8, with a "bright" version of each
    • 24 shades of grey
    • 216 colours of the emulator's choice
  • 24-bit "true" colours

The safe-coloured-text library has support for all of these as first-class values:

data Colour
  = Colour8 !ColourIntensity !TerminalColour
  | Colour8Bit !Word8 -- The 8-bit colour
  | Colour24Bit !Word8 !Word8 !Word8
  deriving (Show, Eq, Generic)

It also has support for the colour-part of CSI codes and SGR parameters:

-- https://en.wikipedia.org/wiki/Escape_character#ASCII_escape_character
newtype CSI
  = SGR [SGR]
  deriving (Show, Eq, Generic)

-- https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters
data SGR
  = Reset
  | SetItalic !Bool
  | SetUnderlining !Underlining
  | SetConsoleIntensity !ConsoleIntensity
  | SetColour !ColourIntensity !ConsoleLayer !TerminalColour
  | Set8BitColour !ConsoleLayer !Word8
  | Set24BitColour
      !ConsoleLayer
      !Word8 -- Red
      !Word8 -- Green
      !Word8 -- Blue
  deriving (Show, Eq, Generic)

Terminal support for colours

Not every terminal supports all colour variants. The getTerminalCapabilitiesFromEnv and getTerminalCapabilitiesFromHandle functions produce a value that represents how many colours are supported:

-- Note that the order of these constructors matters!
data TerminalCapabilities
  = -- | No colours
    WithoutColours
  | -- | Only 8 colours
    With8Colours
  | -- | Only 8-bit colours
    With8BitColours
  | -- | All 24-bit colours
    With24BitColours
  deriving (Show, Eq, Ord, Generic)

A value like this can then be used to output as many colours as are supported by the terminal emulator.

Spelling

This is such a minor thing, and it shouldn't bother me as much as it does, but it does. As such, the library uses "colour" in the docs, and has both "colour" and "color" versions for in the functions:

-- | Build an 8-bit RGB Colour
--
-- This will not be rendered unless 'With8BitColours' is used.
colour256 :: Word8 -> Colour
colour256 = Colour8Bit

-- | Alias for 'colour256', bloody americans...
color256 :: Word8 -> Colour
color256 = colour256

References

Thanks to whomever wrote the Wikipedia article about these escape codes! It was extremely helpful and surprisingly interesting, history-wise.

The code for the library is available online:

Previous
The ci.nix pattern

Start your Haskell project from a template

Haskell templates
Next
Watching changes in Yesod