A simple colouring grid

(0 comments)

Just a simple Python app to try out the TkInter interface to the Tk GUI toolkit and to keep my children occupied. It shows a window with a square grid of cells which can be coloured by selecting from a palette. Run with

python grid.py <n>

where <n> is an optional integer (1 – 26) denoting the grid side length.

The grid can be saved as a list of the coordinates of the cells filled with each colour. An additional program generates an SVG image of a empty grid which can be patiently coloured in (in silence) by your grateful progeny.

python make_grid.py <n>

The window (on a Mac) looks like this:

enter image description here

and this particular image saves as the following text file:

orange
------
A1, A5, B2, B4, D2, D4, E1, E5

red
---
C3

yellow
------
A3, B3, C1, C2, C4, C5, D3, E3

This code is also on Github.

grid.py:

import sys
from tkinter import *
from tkinter import filedialog

# A simple colouring grid app, with load/save functionality.
# Christian Hill, August 2018.

# Maximum and default grid size
MAX_N, DEFAULT_N = 26, 10
# The "default" colour for an unfilled grid cell
UNFILLED = '#fff'

class GridApp:
    """The main class representing a grid of coloured cells."""

    # The colour palette
    colours = (UNFILLED, 'red', 'green', 'blue', 'cyan', 'orange', 'yellow',
               'magenta', 'brown', 'black')
    ncolours = len(colours)

    def __init__(self, master, n, width=600, height=600, pad=5):
        """Initialize a grid and the Tk Frame on which it is rendered."""

        # Number of cells in each dimension.
        self.n = n
        # Some dimensions for the App in pixels.
        self.width, self.height = width, height
        palette_height = 40
        # Padding stuff: xsize, ysize is the cell size in pixels (without pad).
        npad = n + 1
        self.pad = pad
        xsize = (width - npad*pad) / n
        ysize = (height - npad*pad) / n
        # Canvas dimensions for the cell grid and the palette.
        c_width, c_height = width, height
        p_pad = 5
        p_width = p_height = palette_height - 2*p_pad

        # The main frame onto which we draw the App's elements.
        frame = Frame(master)
        frame.pack()

        # The palette for selecting colours.
        self.palette_canvas = Canvas(master, width=c_width,
                                     height=palette_height)
        self.palette_canvas.pack()

        # Add the colour selection rectangles to the palette canvas.
        self.palette_rects = []
        for i in range(self.ncolours):
            x, y = p_pad * (i+1) + i*p_width, p_pad
            rect = self.palette_canvas.create_rectangle(x, y,
                            x+p_width, y+p_height, fill=self.colours[i])
            self.palette_rects.append(rect)
        # ics is the index of the currently selected colour.
        self.ics = 0
        self.select_colour(self.ics)

        # The canvas onto which the grid is drawn.
        self.w = Canvas(master, width=c_width, height=c_height)
        self.w.pack()

        # Add the cell rectangles to the grid canvas.
        self.cells = []
        for iy in range(n):
            for ix in range(n):
                xpad, ypad = pad * (ix+1), pad * (iy+1) 
                x, y = xpad + ix*xsize, ypad + iy*ysize
                rect = self.w.create_rectangle(x, y, x+xsize,
                                           y+ysize, fill=UNFILLED)
                self.cells.append(rect)

        # Load and save image buttons
        b_load = Button(frame, text='open', command=self.load_image)
        b_load.pack(side=RIGHT, padx=pad, pady=pad)
        b_save = Button(frame, text='save', command=self.save_by_colour)
        b_save.pack(side=RIGHT, padx=pad, pady=pad)
        # Add a button to clear the grid
        b_clear = Button(frame, text='clear', command=self.clear_grid)
        b_clear.pack(side=LEFT, padx=pad, pady=pad)

        def palette_click_callback(event):
            """Function called when someone clicks on the palette canvas."""
            x, y = event.x, event.y

            # Did the user click a colour from the palette?
            if p_pad < y < p_height + p_pad:
                # Index of the selected palette rectangle (plus padding)
                ic = x // (p_width + p_pad)
                # x-position with respect to the palette rectangle left edge
                xp = x - ic*(p_width + p_pad) - p_pad
                # Is the index valid and the click within the rectangle?
                if ic < self.ncolours and 0 < xp < p_width:
                    self.select_colour(ic)
        # Bind the palette click callback function to the left mouse button
        # press event on the palette canvas.
        self.palette_canvas.bind('<ButtonPress-1>', palette_click_callback)

        def w_click_callback(event):
            """Function called when someone clicks on the grid canvas."""
            x, y = event.x, event.y

            # Did the user click a cell in the grid?
            # Indexes into the grid of cells (including padding)
            ix = int(x // (xsize + pad))
            iy = int(y // (ysize + pad))
            xc = x - ix*(xsize + pad) - pad
            yc = y - iy*(ysize + pad) - pad
            if ix < n and iy < n and 0 < xc < xsize and 0 < yc < ysize:
                i = iy*n+ix
                self.w.itemconfig(self.cells[i], fill=self.colours[self.ics])
        # Bind the grid click callback function to the left mouse button
        # press event on the grid canvas.
        self.w.bind('<ButtonPress-1>', w_click_callback)

    def select_colour(self, i):
        """Select the colour indexed at i in the colours list."""

        self.palette_canvas.itemconfig(self.palette_rects[self.ics],
                                       outline='black', width=1)
        self.ics = i
        self.palette_canvas.itemconfig(self.palette_rects[self.ics],
                                       outline='black', width=5)

    def _get_cell_coords(self, i):
        """Get the <letter><number> coordinates of the cell indexed at i."""

        # The horizontal axis is labelled A, B, C, ... left-to-right;
        # the vertical axis is labelled 1, 2, 3, ... bottom-to-top.
        iy, ix = divmod(i, self.n)
        return '{}{}'.format(chr(ix+65), self.n-iy)

    def save_by_colour(self):
        """Output a list of cell coordinates, sorted by cell colour."""

        # When we save the list of coordinates with each colour it looks
        # better if we limit the number of coordinates on each line of output.
        MAX_COORDS_PER_ROW = 12

        def _get_coloured_cells_dict():
            """Return a dictionary of cell coordinates and their colours."""

            coloured_cell_cmds = {}
            for i, rect in enumerate(self.cells):
                c = self.w.itemcget(rect, 'fill')
                if c == UNFILLED:
                    continue
                coloured_cell_cmds[self._get_cell_coords(i)] = c
            return coloured_cell_cmds

        def _output_coords(coords):
            """Sort the coords into column (by letter) and row (by int)."""

            coords.sort(key=lambda e: (e[0], int(e[1:])))
            nrows = len(coords) // MAX_COORDS_PER_ROW + 1
            for i in range(nrows):
                print(', '.join(
                      coords[i*MAX_COORDS_PER_ROW:(i+1)*MAX_COORDS_PER_ROW]),
                      file=fo)

        # Create a dictionary of colours (the keys) and a list of cell
        # coordinates with that colour (the value).
        coloured_cell_cmds = _get_coloured_cells_dict()
        cell_colours = {}
        for k, v in coloured_cell_cmds.items():
            cell_colours.setdefault(v, []).append(k)

        # Get a filename from the user and open a file with that name.
        with filedialog.asksaveasfile(mode='w',defaultextension=".grid") as fo:
            if not fo:
                return

            self.filename = fo.name
            print('Writing file to', fo.name)
            for colour, coords in cell_colours.items():
                print('{}\n{}'.format(colour,'-'*len(colour)), file=fo)
                _output_coords(coords)
                print('\n', file=fo)

    def clear_grid(self):
        """Reset the grid to the background "UNFILLED" colour."""

        for cell in self.cells:
            self.w.itemconfig(cell, fill=UNFILLED)

    def load_image(self):
        """Load an image from a provided file."""

        def _coords_to_index(coords):
            """
            Translate from the provided coordinate (e.g. 'A1') to an index
            into the grid cells list.
            """

            ix = ord(coords[0])-65
            iy = self.n - int(coords[1:])
            return iy*self.n + ix

        self.filename = filedialog.askopenfilename(filetypes=(
                ('Grid files', '.grid'),
                ('Text files', '.txt'),
                ('All files', '*.*')))
        if not self.filename:
            return
        print('Loading file from', self.filename)
        self.clear_grid()
        # Open the file and read the image, setting the cell colours as we go.
        with open(self.filename) as fi:
            for line in fi.readlines():
                line = line.strip()
                if line in self.colours:
                    this_colour = line
                    continue
                if not line or line.startswith('-'):
                    continue
                coords = line.split(',')
                if not coords:
                    continue
                for coord in coords:
                    i = _coords_to_index(coord.strip())
                    self.w.itemconfig(self.cells[i], fill=this_colour)

# Get the grid size from the command line, if provided
try:
    n = int(sys.argv[1])
except IndexError:
    n = DEFAULT_N
except ValueError:
    print('Usage: {} <n>\nwhere n is the grid size.'.format(sys.argv[0]))
    sys.exit(1)
if n < 1 or n > MAX_N:
    print('Minimum n is 1, Maximum n is {}'.format(MAX_N))
    sys.exit(1)

# Set the whole thing running
root = Tk()
grid = GridApp(root, n, 600, 600, 5)
root.mainloop()

make_grid.py:

import sys

# Make an SVG image of a blank, indexed grid.
# Christian Hill, August 2018.

# SIZE is the grid image size, including padding around all sides of PAD.
SIZE = 600
PAD = 40

try:
    n = int(sys.argv[1])
except (IndexError, ValueError):
    print('Usage: {} n\nwhere n is the grid size.'.format(sys.argv[0]))
    sys.exit(1)

# output SVG image filename, size of the grid within the image.
filename = 'blank_grid-{n}x{n}.svg'.format(n=n)
c_size = (SIZE - 2*PAD) / n

def _get_letter_coord(i):
    """Return the letter A, B, C, ... corresponding to i = 1, 2, 3, ..."""
    return chr(i+64)

with open(filename, 'w') as fo:
    # The SVG preamble and styles.
    print('<?xml version="1.0" encoding="utf-8"?>\n'

    '<svg xmlns="http://www.w3.org/2000/svg"\n' + ' '*5 +
         'xmlns:xlink="http://www.w3.org/1999/xlink" width="{}" height="{}" >'
            .format(SIZE, SIZE), file=fo)
    print("""
    <defs>
    <style type="text/css"><![CDATA[

    line {
        stroke-width: 1px;
        stroke: #888;
    }

    ]]></style>
    </defs>
    """, file=fo)

    # We need n+1 lines to mark out n cells and two nested loops; write both
    # coordinate labels in the outer loop.
    v = PAD // 2
    for i in range(n+1):
        if i:
            u = PAD + c_size*(i-0.5)
            print('<text x="{}" y="{}" text-anchor="middle" '
                  'dominant-baseline="central">{}</text>'.format(u, SIZE-v,
                  _get_letter_coord(i)), file=fo)
            print('<text x="{}" y="{}" text-anchor="middle" '
                  'dominant-baseline="central">{}</text>'.format(v, SIZE-u,
                  str(i)), file=fo)
        for j in range(n+1):
            x1 = x2 = PAD + i*c_size
            y1, y2 = PAD, SIZE-PAD
            print('<line x1="{}" y1="{}" x2="{}" y2="{}"/>'.format(
                  x1, y1, x2, y2), file=fo)
            print('<line x1="{}" y1="{}" x2="{}" y2="{}"/>'.format(
                  y1, x1, y2, x2), file=fo)
    print('</svg>', file=fo)
Currently unrated

Comments

There are currently no comments

New Comment

required

required (not published)

optional

required