DEV Community

loading...
Cover image for Drawing blocks on the command-line with Python

Drawing blocks on the command-line with Python

Cariad Eccleston
Freelance DevOps engineer. ❀️ AWS + Python. πŸ³οΈβ€πŸŒˆπŸ³οΈβ€βš§οΈ
・5 min read

Hey folks! I just joined the community here, and I thought I'd share an interesting little rabbit hole I recently fell down.

The goal

A couple of weeks ago, I released progrow; a Python package for drawing progress bars on the command line:

apple harvest   β–ˆβ–Ž                     1 /   9 β€’  11%
banana harvest  β–ˆβ–ˆ                     9 /  99 β€’   9%
caramel harvest β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‰ 100 / 100 β€’ 100%
Enter fullscreen mode Exit fullscreen mode

The package does a couple of cool things, but I'm going to focus on my approach to drawing the bar.

The first shot

My original plan for the bar was just to calculate the amount of space available then fill the correct percentage of it with the Unicode "full block" (β–ˆ) character.

So, for example, a bar of length 10 and percentage 0.5 (with 0.0 being 0% and 1.0 being 100%) would look like this:

[β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ     ]
Enter fullscreen mode Exit fullscreen mode

Here's some code that fulfills that rule:

from decimal import Decimal

EMPTY = " "

def make_bar(length: int, pc: Decimal) -> str:
    """
    Returns a string describing a bar "length" characters long
    and filled to "pc" percent (0.0 to 1.0).
    """
    render = list(EMPTY * length)
    # Percentage of the length that each element represents:
    element_pc = Decimal(1) / length

    for index in range(length):
        # Take a slice of the percentage for this element:
        block_pc = (pc - (index * element_pc)).min(element_pc)
        # Calculate how full this block needs to be:
        block_fill_pc = (1 / element_pc) * block_pc
        # Add an appropriate character to the render:
        render[index] = make_char(block_fill_pc)

    return "".join(render)


def make_char(pc: Decimal) -> str:
    """
    Gets a character that represents "pc" percent (0.0 - 1.0).
    """
    FULL_BLOCK = 0x2588
    return EMPTY if pc < Decimal(0.5) else chr(FULL_BLOCK)
Enter fullscreen mode Exit fullscreen mode

So, say I want to draw a bar of length 3 and percentage 0.7. Inside make_bar():

  1. render is set to a list of 3 spaces.
  2. element_pc is set to 0.33, since each element in the list represents 33% of the bar area.
  3. In iteration index = 0:
    1. block_pc is set to the amount of the bar to be described by the first block. The calculation for this is (the full bar percentage - the percentage already rendered by previous indexes).min(the maximum percentage that each block can represent). That .min(...) prevents us from biting off more than we can chew; each block can describe only as much as its maximum, and any remainder needs to be described by subsequent blocks. So, block_pc is set to (0.7 - (0 * 0.33)).min(0.33), or 0.33.
    2. block_fill_pc is set to the percentage full-ness of this block. Each block represents 0.33 of the area and block_pc is 0.33, so this block needs to be 1.0 (100%) full.
    3. We pass that 1.0 to make_char() and -- since 1.0 > 0.5 -- we get a Unicode full block back.
  4. In iteration index = 1:
    1. block_pc is set to the amount of the bar to be described by the second block: (0.7 - (1 * 0.33)).min(0.33), or 0.33.
    2. block_fill_pc is set to the percentage full-ness of this block. Each block represents 0.33 of the area and block_pc is 0.33, so this block needs to be 1.0 full.
    3. We pass that 1.0 to make_char() and -- since 1.0 > 0.5 -- we get a Unicode full block back.
  5. In iteration index = 2:
    1. block_pc is set to the amount of the bar to be described by the third block: (0.7 - (2 * 0.33)).min(0.33), or 0.04.
    2. block_fill_pc is set to the percentage full-ness of this block. Each block represents 0.33 of the area and block_pc is 0.04, so this block needs to be 0.12 full.
    3. We pass that 0.12 to make_char() and -- since 0.12 < 0.5 -- we get an empty space back.
  6. Finally, we join render together to get the bar as a string containing two Unicode full blocks and an empty string.

We can run this for a number of rows of ever-increasing percentages with code like this:

if __name__ == "__main__":
    """
    Print a series of rows with ever-increasing percentages.
    """
    ROW_COUNT = 11
    iteration_pc = (Decimal(1) / (ROW_COUNT - 1))
    for index in range(ROW_COUNT):
        pc = iteration_pc * index
        bar = make_bar(length=6, pc=pc)
        print(f"{pc:0.2f}: [{bar}]")
Enter fullscreen mode Exit fullscreen mode

And here's our beautiful output!

0.00: [      ]
0.10: [β–ˆ     ]
0.20: [β–ˆ     ]
0.30: [β–ˆβ–ˆ    ]
0.40: [β–ˆβ–ˆ    ]
0.50: [β–ˆβ–ˆβ–ˆ   ]
0.60: [β–ˆβ–ˆβ–ˆβ–ˆ  ]
0.70: [β–ˆβ–ˆβ–ˆβ–ˆ  ]
0.80: [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ ]
0.90: [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ ]
1.00: [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ]
Enter fullscreen mode Exit fullscreen mode

Well, not THAT beautiful

It's fine, but it's not beautiful. According to those bars, 0.10 and 0.20 are the same, as are 0.30 and 0.40, 0.60 and 0.70, and 0.80 and 0.90.

Wouldn't it be nice to add some more granularity?

Wouldn't be nice if we could draw blocks that were less than a full character width across?

Well, we can!

While the Unicode character 0x2588 represents a full block, there's also a series of Unicode characters that describe eighths of a block:

Hex String Description
0x2588 β–ˆ Left 8/8 (full block)
0x2589 β–‰ Left 7/8
0x258A β–Š Left 6/8
0x258B β–‹ Left 5/8
0x258C β–Œ Left 4/8
0x258D ▍ Left 3/8
0x258E β–Ž Left 2/8
0x258F ▏ Left 1/8

Make it beautiful!

So, let's update make_char() to return one of these characters instead of a binary "on or off" for each block:

from math import ceil

def make_char(pc: Decimal) -> str:
    """
    Gets a character that represents "pc" percent (0.0 - 1.0).
    """
    eighths = ceil(pc * 8)
    return chr(0x2590 - eighths) if eighths > 0 else EMPTY
Enter fullscreen mode Exit fullscreen mode

Essentially, we can calculate how many eighths a percentage is by multiplying it by 8:

  • 0.000 (0.000 * 8) is 0 eighths.
  • 0.125 (0.125 * 8) is 1 eighth.
  • 0.250 (0.250 * 8) is 2 eighths.
  • 0.375 (0.375 * 8) is 3 eighths.
  • 0.500 (0.500 * 8) is 4 eighths.
  • 0.625 (0.625 * 8) is 5 eighths.
  • 0.750 (0.750 * 8) is 6 eighths.
  • 0.875 (0.875 * 8) is 7 eighths.
  • 1.000 (1.000 * 8) is 8 eighths.

When we know how many eighths a percentage is, we can subtract that offset from 0x2590 to get the correct symbol:

  • 0x2590 - 1 = 0x258F (1/8)
  • 0x2590 - 2 = 0x258E (2/8)
  • 0x2590 - 3 = 0x258D (3/8)
  • 0x2590 - 4 = 0x258C (4/8)
  • 0x2590 - 5 = 0x258B (5/8)
  • 0x2590 - 6 = 0x258A (6/8)
  • 0x2590 - 7 = 0x2589 (7/8)
  • 0x2590 - 8 = 0x2588 (8/8)

Now if we run the code with the updated make_char() function:

0.00: [      ]
0.10: [β–‹     ]
0.20: [β–ˆβ–Ž    ]
0.30: [β–ˆβ–‰    ]
0.40: [β–ˆβ–ˆβ–Œ   ]
0.50: [β–ˆβ–ˆβ–ˆ   ]
0.60: [β–ˆβ–ˆβ–ˆβ–‹  ]
0.70: [β–ˆβ–ˆβ–ˆβ–ˆβ–Ž ]
0.80: [β–ˆβ–ˆβ–ˆβ–ˆβ–‰ ]
0.90: [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–Œ]
1.00: [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ]
Enter fullscreen mode Exit fullscreen mode

Ta-da! Every row now has a much more accurate bar!

In fact, with one quick change to the script, we can create a row for every possible partial block:

if __name__ == "__main__":
    """
    Print a series of rows with ever-increasing percentages.
    """
    LENGTH = 2
    ROW_COUNT = (8 * LENGTH) + 1
    iteration_pc = (Decimal(1) / (ROW_COUNT - 1))
    for index in range(ROW_COUNT):
        pc = iteration_pc * index
        bar = make_bar(length=LENGTH, pc=pc)
        print(f"{pc:0.2f}: [{bar}]")
Enter fullscreen mode Exit fullscreen mode
0.00: [  ]
0.06: [▏ ]
0.12: [β–Ž ]
0.19: [▍ ]
0.25: [β–Œ ]
0.31: [β–‹ ]
0.38: [β–Š ]
0.44: [β–‰ ]
0.50: [β–ˆ ]
0.56: [β–ˆβ–]
0.62: [β–ˆβ–Ž]
0.69: [β–ˆβ–]
0.75: [β–ˆβ–Œ]
0.81: [β–ˆβ–‹]
0.88: [β–ˆβ–Š]
0.94: [β–ˆβ–‰]
1.00: [β–ˆβ–ˆ]
Enter fullscreen mode Exit fullscreen mode

Now, who said command-line tools can't be beautiful?

Featured photo by Wesley Tingey on Unsplash.

Discussion (0)