paintbrush

0.1.3
Light Mode Dark Mode

How It Works

Paintbrush can be broken down into four components:

  1. Context manipulation.
  2. String encoding.
  3. Color code tree construction.
  4. Re-encoding into a colorized string.

Context Manipulation

Paintbrush cares about namespace pollution. It avoids adding methods and constants into a namespace where possible, and it does not overload or extend any other objects. Paintbrush’s methods are only available within the block passed to the #paintbrush method.

To achieve this, Paintbrush duplicates the binding of the current block (i.e. the namespace that invoked #paintbrush), injects a module PaintbrushSupport::Colors (which provides the color methods like #cyan) into the duplicated context’s self, and also adds a @__stack instance variable which is unique to each invocation of #paintbrush.

Each call to #green, #blue, etc. adds a new PaintbrushSupport::ColorElement to the stack, which stores the name of the invoked color method, the string it received, and its index in the current stack.

The ColorElement object is then returned so Ruby can interpolate it, calling ColorElement#to_s which returns an encoded string.

String Encoding

Each encoded string includes the following:

  • An escape code indicating the beginning of the string.
  • The index of the item in the current stack.
  • The original string received to the color method.
  • An escape code indicating the end of the string.

The index is encoded to both the start end end boundaries of the substring, which allows nested colorized strings. The structure is similar to open and close tags in XML, with each tag having a unique identifier attribute.

The raw encoded string looks like this (slightly formatted to allow text wrapping):

subject do
  PaintbrushSupport::ColorizedString.new(colorize: true) { red "red #{green "green #{blue "blue"}"}" }
                                    .send(:escaped_output)
                                    .gsub("CLOSE", "CLOSE ")
end

it { is_expected.to include "green" }
"\e[3;15;17]START_OPEN:2:\e[3;15;17]START_CLOSE red \e[3;15;17]START_OPEN:1:\e[3;15;17]START_CLOSE green \e[3;15;17]START_OPEN:0:\e[3;15;17]START_CLOSE blue\e[17;15;3]END_OPEN:0:\e[17;15;3]END_CLOSE \e[17;15;3]END_OPEN:1:\e[17;15;3]END_CLOSE \e[17;15;3]END_OPEN:2:\e[17;15;3]END_CLOSE "

This encoded string could be represented in XML, for example:

<color code="red" id="2">
  red
  <color code="green" id="1">
    green
    <color code="blue" id="0">
      blue
    </color>
  </color>
</color>

However, since the leaf nodes are generated first, and each leaf node does not know where it will appear in the final string, the tree data is built back-to-front and the tree needs to be constructed from the encoded string. Leaves can’t attach themselves to parents that don’t exist yet, but the resulting string contains information for each node’s start/end points and its index in the stack.

Color Code Tree Construction

Once the encoded string has been generated, a tree structure is built by identifying the start and end points of each substring, finding the largest non-overlapping ranges as 1st-generation children, and then repeating the same algorithm using each parent’s boundaries to identify direct descendants, until no direct descendants exist (i.e. we have found a leaf node).

Re-encoding into a colorized string

Once the final string has been decoded into a tree structure, each leaf node can inspect its parent to identify which color code should be restored. e.g. if a leaf node has color “yellow” and its parent has color “green”, the resulting substring will start with the escape code for yellow, then the string’s original value, then the escape code for green.

This process is repeated recursively back up the tree until the root is found, at which point the color is reset to the terminal’s default.

Summary

Paintbrush provides method resolution by modifying a duplicate of the current context, then creates a series of encoded strings with unlimited (within Ruby’s own stack size limit) nested string interpolation. The intermediary encoded string is parsed into a tree when the result is returned to the main #paintbrush method. The tree is then traversed from the leaf nodes to the root, re-encoding into a string of ANSI color sequences that restore each node’s parent color, providing developers with (hopefully) a more pleasant way of building colorized terminal output than they are used to.

Here’s the result:

subject do
  paintbrush do
    "nesting with #{blue 'foo'} #{green "bar #{cyan "baz"} with #{cyan 'qux'} and quux"} and #{red "corge"}"
  end
end

it { is_expected.to include 'baz' }
nesting with foo bar baz with qux and quux and corge

Try some of the alternatives to compare equivalent functionality across different implementations.

Raise an issue if you want to suggest a feature or report a bug.


Documentation generated by rspec-documentation