Chapter 7:
Use a viewport
Another common technique is to use the Canvas as a window (or a viewport) into a larger world. In our case we’ll create a grid of blocks that is larger than can be seen in the Canvas, and create a tool to allow the user to pan the viewport around that larger grid.
Step 1 - Generating the world
Our first step is to make our model cover more area than the Canvas can display. We’ll introduce new variables to tell us how large the world should be, and generate a grid of that size. In the __init__
method change the model generation code to this:
def __init__(self, **properties):
...
self.WORLD_ROWS = 50
self.WORLD_COLS = 50
for row in range(self.WORLD_ROWS):
for col in range(self.WOLRD_COLS):
self.model.append({
'type': self.image_types[random.randint(0, self.num_images-1)],
'y': row * self.IMAGE_SIZE,
'x': col * self.IMAGE_SIZE,
'id': f"{row},{col}"
})
Step 2 - Drawing based on viewport coordinates
Now we need to allow the viewport (the Canvas) to draw any portion of the world. Right now it will only draw the upper-left corner of the world. We need to introduce two new variables that tell us where to draw the Canvas. We’ll set their initial values in the __init__
function, just under where we set the world size:
def __init__(self, **properties):
...
self.WORLD_ROWS = 50
self.WORLD_COLS = 50
self.viewport_x = 0
self.viewport_y = 0
And then in the Canvas reset
handler we’ll use those variables as an offset for drawing the images:
def canvas_1_reset(self, **event_args):
...
# Draw the shapes. Some will be out of the clipping area and won't be visible.
for shape in self.model:
if shape['type'] not in self.images:
continue
x = shape['x'] + self.viewport_x
y = shape['y'] + self.viewport_y
self.canvas_1.draw_image(self.images[shape['type']], x, y)
Step 3 - Allowing a drag to move the viewport
Now we must allow the user to be able to drag the viewport around in the world. Otherwise we’ll never be able to see anything but the upper-left corner of the world. Add a new RadioButton tool. Name it pan_tool
and make the group name the same.

Adding a RadioButton to choose the pan tool
Now in the __init__
function set the initial value for a variable that tells us if the user is panning the Canvas:
def __init__(self, **properties):
...
self.panning = False
Then create a function called do_pan_action
. We want to set the coordinates of where the user clicked to start panning since we’ll need to use that during the mouse_move
handler. We also set the original coordinates of the viewport to use in the mouse_up
handler. Finally we set the self.panning
flag to True
so that we know we’re panning:
def do_pan_action(self, x, y):
self.panning = True
self.pan_x = x
self.pan_y = y
self.original_viewport_x = self.viewport_x
self.original_viewport_y = self.viewport_y
Then in the mouse_down
handler, add another if statement to check if the pan tool has been selected:
def canvas_1_mouse_down(self, x, y, button, keys, **event_args):
...
tool = self.delete_tool.get_group_value()
if tool == 'delete_tool':
self.do_delete_action(obj_x, obj_y)
elif tool == 'drag_tool':
self.do_drag_action(row, col, x, y)
elif tool == 'pan_tool':
self.do_pan_action(x, y)
In the Canvas mouse_move
handler, add the following code to make sure the Canvas gets redrawn at the right coordinates as the user pans:
def canvas_1_mouse_move(self, x, y, **event_args):
...
#Add the following code to the function
if self.panning:
self.viewport_x = self.original_viewport_x - (self.pan_x - x)
self.viewport_y = self.original_viewport_y - (self.pan_y - y)
self.canvas_1.reset_context()
Using self.pan_x - x
and self.pan_y - y
gets us the distance the user has panned the viewport. We then adjust the current viewport coordinates to the right values with respect to where the viewport started.
If it seems like we’re moving the viewport in the wrong direction, it’s because the viewport itself does get moved in the opposite direction of where the user is dragging the world. If the user wants to pan the world to the right (in the positive x direction) that means we’re seeing more of the world on the left and the viewport must move in that direction.
Finally, the Canvas mouse_up
handler must reset the self.panning
flag:
def canvas_1_mouse_up(self, x, y, button, **event_args):
self.panning = False
...
You can now run the app, select the panning tool, and drag the world around to see more of it:

Step 4 - Fixing deleting objects
You may have noticed that after panning, the wrong blocks get deleted when you use the delete block tool. That’s because our calculations for working out which block is selected do not take into account the viewport coordinates. The same issue happens with trying to drag individual blocks.
We’ll look at deleting blocks first. In the Canvas mouse_down
handler, we currently calculate the row and column of the the object using the following code:
row = y // self.IMAGE_SIZE
col = (x - self.canvas_offset) // self.IMAGE_SIZE
That worked when the Canvas was always drawn at the upper-left of the world. Now that the Canvas can be drawn at any location in the world we must take the viewport coordinates into account:
row = (y - self.viewport_y) // self.IMAGE_SIZE
col = (x - self.viewport_x - self.canvas_offset) // self.IMAGE_SIZE
Now run the app and pan the viewport. You should still be able to delete blocks from anywhere and have the blocks above it fall:

Step 5 - Fixing dragging objects
Dragging objects has the same issue as deleting them. In the Canvas mouse_up
handler we need to take the viewport coordinates into account when calculating the row and column of the object:
def canvas_1_mouse_up(self, x, y, button, **event_args):
self.panning = False
if self.drag_obj:
row = (y - self.viewport_y) // self.IMAGE_SIZE
col = (x - self.viewport_x - self.canvas_offset) // self.IMAGE_SIZE
other = self.find_object(row, col, self.drag_obj)
After that dragging objects also works after panning.

We’ve now learned how to use the Canvas as a viewport on a larger world. Next, we’ll see how to access the underlying HTML Canvas element.