Drawing blocks on the command-line with Python
Tags: python, firstpost, tutorial
Published: 2021-04-11 08:59:14

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](https://github.com/cariad/progrow); a Python package for drawing progress bars on the command line:

```text
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:

```text
[█████     ]
```

Here's some code that fulfills that rule:

```python
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()`:

1. `render` is set to a list of 3 spaces.
1. `element_pc` is set to **0.33**, since each element in the list represents **33%** of the bar area.
1. 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**.
    1. `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.
    1. We pass that **1.0** to `make_char()` and -- since `1.0 > 0.5` -- we get a Unicode full block back.
1. 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**.
    1. `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.
    1. We pass that **1.0** to `make_char()` and -- since `1.0 > 0.5` -- we get a Unicode full block back.
1. 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**.
    1. `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.
    1. We pass that **0.12** to `make_char()` and -- since `0.12 < 0.5` -- we get an empty space back.
1. 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:

```python
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}]")
```

And here's our beautiful output!

```text
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:

```python
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:

```text
0.00: [      ]
0.10: [▋     ]
0.20: [█▎    ]
0.30: [█▉    ]
0.40: [██▌   ]
0.50: [███   ]
0.60: [███▋  ]
0.70: [████▎ ]
0.80: [████▉ ]
0.90: [█████▌]
1.00: [██████]
```

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:

```python
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}]")
```

```text
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 command-line tools can't be beautiful?

_Featured photo by [Wesley Tingey](https://unsplash.com/@wesleyphotography?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on [Unsplash](https://unsplash.com/?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)._