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%
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:
[█████ ]
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)
So, say I want to draw a bar of length 3 and percentage 0.7. Inside make_bar()
:

render
is set to a list of 3 spaces. 
element_pc
is set to 0.33, since each element in the list represents 33% of the bar area.  In iteration
index = 0
:
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. 
block_fill_pc
is set to the percentage fullness of this block. Each block represents 0.33 of the area andblock_pc
is 0.33, so this block needs to be 1.0 (100%) full.  We pass that 1.0 to
make_char()
and  since1.0 > 0.5
 we get a Unicode full block back.

 In iteration
index = 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. 
block_fill_pc
is set to the percentage fullness of this block. Each block represents 0.33 of the area andblock_pc
is 0.33, so this block needs to be 1.0 full.  We pass that 1.0 to
make_char()
and  since1.0 > 0.5
 we get a Unicode full block back.

 In iteration
index = 2
:
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. 
block_fill_pc
is set to the percentage fullness of this block. Each block represents 0.33 of the area andblock_pc
is 0.04, so this block needs to be 0.12 full.  We pass that 0.12 to
make_char()
and  since0.12 < 0.5
 we get an empty space back.

 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 everincreasing percentages with code like this:
if __name__ == "__main__":
"""
Print a series of rows with everincreasing 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}]")
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: [██████]
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
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: [██████]
Tada! 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 everincreasing 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}]")
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: [██]
Now, who said commandline tools can't be beautiful?
Featured photo by Wesley Tingey on Unsplash.
Top comments (1)
Awesome!