CMU 15-112: Fundamentals of Programming and Computer Science
Class Notes: Event-Based Animations in Tkinter
Part 2: Using the App Class


  1. Installing required modules (PIL/Pillow and Requests)
  2. Subclassing App
  3. Keyboard Shortcuts
  4. Events
  5. Input and Output Methods
  6. Image Methods
    1. loadImage and scaleImage (using url)
    2. loadImage and scaleImage (using local file)
    3. Using image.size
    4. Using transpose to flip an image
    5. getSnapshot and saveSnapshot
    6. Spritesheets using Pillow/PIL methods (such as image.crop)
    7. Caching PhotoImages for increased speed
  7. Subclassing ModalApp and Mode
  8. Example: Sidescrollers 1-3

Notes:
  1. To run these examples, first download cmu_112_graphics.py and be sure it is in the same folder as the file you are running.
  2. That file has version numbers. As we release updates, be sure you are using the most-recent version!
  3. This is a new animation framework. Which means it may have some bugs that we will fix as we go. Also, it is similar to previous semesters, but different in important ways. Be aware of this if you are reviewing previous semesters' materials!
  4. As with Tkinter graphics, the examples here will not run using Brython in your browser.

  1. Installing required modules (PIL/Pillow and Requests)
    To use cmu_112_graphics.py, you need to have some modules installed. If they are not installed, you will see a message like this (or a similar one for "requests" instead of "PIL"):
    **********************************************************
    ** Cannot import PIL -- it seems you need to install pillow
    ** This may result in limited functionality or even a runtime error.
    **********************************************************
    
    You can try to use 'pip' to install the missing modules, but it can be complicated making sure you are installing these modules for the same version of Python that you are running. Here are some more-reliable steps that should work for you:

    • For Windows:
      1. Run this Python code block in your main Python file (it will print the commands you need to paste into your command prompt):
        import sys
        print(f'"{sys.executable}" -m pip install pillow')
        print(f'"{sys.executable}" -m pip install requests')
        
      2. Open Command Prompt as an administrator user (right click - run as administrator)
      3. Copy-paste each of the two commands printed in step 1 into the command prompt you opened in step 2.
      4. Close the command prompt and close Python.
      5. Re-open Python, and you're set (hopefully)!

    • For Mac or Linux:
      1. Run this Python code block in your main Python file (it will print the commands you need to paste into your command prompt):
        import sys
        print(f'sudo "{sys.executable}" -m pip install pillow')
        print(f'sudo "{sys.executable}" -m pip install requests')
        
      2. Open Terminal
      3. Copy-paste each of the two commands printed in step 1 into the command prompt you opened in step 2.
        • If you see a lock and a password is requested, type in the same password that you use to log into your computer.
      4. Close the terminal and close Python.
      5. Re-open Python, and you're set (hopefully)!

    If these steps do not work for you, please go to OH and we will be happy to assist.

  2. Subclassing App
    Let's start with our first example we saw back in part 1 (see here):
    # This version uses top-level functions instead of subclassing App: from cmu_112_graphics import * from tkinter import * def appStarted(app): app.counter = 0 def keyPressed(app, event): app.counter += 1 def redrawAll(app, canvas): canvas.create_text(app.width/2, app.height/2, text=f'{app.counter} keypresses', font='Arial 30 bold') runApp(width=400, height=400)

    To subclass App:
    1. Create a new class (say, MyApp) that subclasses App
    2. Move the functions into the new class, so they are now methods
    3. Instead of runApp(), use MyApp() or MyApp(autorun=False).run()
    Here is the result:
    # This version subclasses App (instead of using top-level functions): from cmu_112_graphics import * from tkinter import * class MyApp(App): def appStarted(app): app.counter = 0 def keyPressed(app, event): app.counter += 1 def redrawAll(app, canvas): canvas.create_text(app.width/2, app.height/2, text=f'{app.counter} keypresses', font='Arial 30 bold') MyApp(width=400, height=400)

    Here it is once more, only using self instead of app, as is more standard for methods:
    from cmu_112_graphics import * from tkinter import * class MyApp(App): def appStarted(self): self.counter = 0 def keyPressed(self, event): self.counter += 1 def redrawAll(self, canvas): canvas.create_text(self.width/2, self.height/2, text=f'{self.counter} keypresses', font='Arial 30 bold') MyApp(width=400, height=400)

  3. Keyboard Shortcuts
    from cmu_112_graphics import * from tkinter import * class MyApp(App): def appStarted(self): self.counter = 0 def timerFired(self): self.counter += 1 def redrawAll(self, canvas): canvas.create_text(200, 50, text='Keyboard Shortcut Demo') canvas.create_text(200, 100, text='Press control-p to pause/unpause') canvas.create_text(200, 150, text='Press control-s to save a snapshot') canvas.create_text(200, 200, text='Press control-q to quit') canvas.create_text(200, 250, text='Press control-x to hard exit') canvas.create_text(200, 300, text=f'{self.counter}') MyApp(width=400, height=400) # quit still runs next one, exit does not MyApp(width=600, height=600)

  4. Events
    from cmu_112_graphics import * from tkinter import * class MyApp(App): def appStarted(self): self.messages = ['appStarted'] def appStopped(self): self.messages.append('appStopped') print('appStopped!') def keyPressed(self, event): self.messages.append('keyPressed: ' + event.key) def keyReleased(self, event): self.messages.append('keyReleased: ' + event.key) def mousePressed(self, event): self.messages.append(f'mousePressed at {(event.x, event.y)}') def mouseReleased(self, event): self.messages.append(f'mouseReleased at {(event.x, event.y)}') def mouseMoved(self, event): self.messages.append(f'mouseMoved at {(event.x, event.y)}') def mouseDragged(self, event): self.messages.append(f'mouseDragged at {(event.x, event.y)}') def sizeChanged(self): self.messages.append(f'sizeChanged to {(self.width, self.height)}') def redrawAll(self, canvas): font = 'Arial 20 bold' canvas.create_text(self.width/2, 30, text='Events Demo', font=font) n = min(10, len(self.messages)) i0 = len(self.messages)-n for i in range(i0, len(self.messages)): canvas.create_text(self.width/2, 100+50*(i-i0), text=f'#{i}: {self.messages[i]}', font=font) MyApp(width=600, height=600)

  5. Input and Output Methods
    # This demos app.getUserInput(prompt) and app.showMessage(message) from cmu_112_graphics import * from tkinter import * class MyApp(App): def appStarted(self): self.message = 'Click the mouse to enter your name!' def mousePressed(self, event): name = self.getUserInput('What is your name?') if (name == None): self.message = 'You canceled!' else: self.showMessage('You entered: ' + name) self.message = f'Hi, {name}!' def redrawAll(self, canvas): font = 'Arial 24 bold' canvas.create_text(self.width/2, self.height/2, text=self.message, font=font) MyApp(width=500, height=300)

  6. Image Methods
    1. loadImage and scaleImage (using url)
      # This demos loadImage and scaleImage from a url from cmu_112_graphics import * from tkinter import * class MyApp(App): def appStarted(self): url = 'https://tinyurl.com/great-pitch-gif' self.image1 = self.loadImage(url) self.image2 = self.scaleImage(self.image1, 2/3) def redrawAll(self, canvas): canvas.create_image(200, 300, image=ImageTk.PhotoImage(self.image1)) canvas.create_image(500, 300, image=ImageTk.PhotoImage(self.image2)) MyApp(width=700, height=600)

    2. loadImage and scaleImage (using local file)
      Let's do that again, only this time using an image stored locally. To run this version, you must first download this image (testImage2.gif) and save it in the same folder as your Python code:
      # This demos loadImage and scaleImage from a local file from cmu_112_graphics import * from tkinter import * class MyApp(App): def appStarted(self): self.image1 = self.loadImage('testImage2.gif') self.image2 = self.scaleImage(self.image1, 2/3) def redrawAll(self, canvas): canvas.create_image(200, 300, image=ImageTk.PhotoImage(self.image1)) canvas.create_image(500, 300, image=ImageTk.PhotoImage(self.image2)) MyApp(width=700, height=600)

    3. Using image.size
      # This demos using image.size from cmu_112_graphics import * from tkinter import * class MyApp(App): def appStarted(self): url = 'https://tinyurl.com/great-pitch-gif' self.image1 = self.loadImage(url) self.image2 = self.scaleImage(self.image1, 2/3) def drawImageWithSizeBelowIt(self, canvas, image, cx, cy): canvas.create_image(cx, cy, image=ImageTk.PhotoImage(image)) imageWidth, imageHeight = image.size msg = f'Image size: {imageWidth} x {imageHeight}' canvas.create_text(cx, cy + imageHeight/2 + 20, text=msg, font='Arial 20 bold') def redrawAll(self, canvas): self.drawImageWithSizeBelowIt(canvas, self.image1, 200, 300) self.drawImageWithSizeBelowIt(canvas, self.image2, 500, 300) MyApp(width=700, height=600)

    4. Using transpose to flip an image
      # This demos using transpose to flip an image from cmu_112_graphics import * from tkinter import * from PIL import Image # <-- need to do this after tkinter import class MyApp(App): def appStarted(self): url = 'https://tinyurl.com/great-pitch-gif' self.image1 = self.loadImage(url) self.image2 = self.image1.transpose(Image.FLIP_LEFT_RIGHT) def redrawAll(self, canvas): canvas.create_image(200, 300, image=ImageTk.PhotoImage(self.image1)) canvas.create_image(500, 300, image=ImageTk.PhotoImage(self.image2)) MyApp(width=700, height=600)

    5. getSnapshot and saveSnapshot
      Now let's look at getSnapshot and saveSnapshot:
      # This demos getSnapshot and saveSnapshot from cmu_112_graphics import * from tkinter import * class MyApp(App): def appStarted(self): self.image = None def keyPressed(self, event): if (event.key == 'g'): snapshotImage = self.getSnapshot() self.image = self.scaleImage(snapshotImage, 0.4) elif (event.key == 's'): self.saveSnapshot() def redrawAll(self, canvas): canvas.create_text(350, 20, text='Press g to getSnapshot') canvas.create_text(350, 40, text='Press s to saveSnapshot') canvas.create_rectangle(50, 100, 250, 500, fill='cyan') if (self.image != None): canvas.create_image(525, 300, image=ImageTk.PhotoImage(self.image)) MyApp(width=700, height=600)

    6. Spritesheets using Pillow/PIL methods (such as image.crop)
      We can use Pillow/PIL methods such as image.crop() (among many others!), which we use here to use a spritestrip (a kind of spritesheet) by cropping each sub-image from this single image:
      # This demos sprites using Pillow/PIL images # See here for more details: # https://pillow.readthedocs.io/en/stable/reference/Image.html # This uses a spritestrip from this tutorial: # https://www.codeandweb.com/texturepacker/tutorials/how-to-create-a-sprite-sheet from cmu_112_graphics import * from tkinter import * class MyApp(App): def appStarted(self): url = 'http://www.cs.cmu.edu/~112/notes/sample-spritestrip.png' spritestrip = self.loadImage(url) self.sprites = [ ] for i in range(6): sprite = spritestrip.crop((30+260*i, 30, 230+260*i, 250)) self.sprites.append(sprite) self.spriteCounter = 0 def timerFired(self): self.spriteCounter = (1 + self.spriteCounter) % len(self.sprites) def redrawAll(self, canvas): sprite = self.sprites[self.spriteCounter] canvas.create_image(200, 200, image=ImageTk.PhotoImage(sprite)) MyApp(width=400, height=400)

    7. Caching PhotoImages for increased speed
      If you are using a lot of images, then the call to ImageTk.PhotoImage(image) can slow things down, so we can cache the results of that, like so:
      # This demos caching PhotoImages for increased speed # when using a LOT of images (2500 here) from cmu_112_graphics import * from tkinter import * import time def make2dList(rows, cols): return [ ([0] * cols) for row in range(rows) ] class MyApp(App): def appStarted(self): url = 'https://tinyurl.com/great-pitch-gif' self.image1 = self.loadImage(url) self.margin = 20 self.rows = self.cols = 50 self.images = make2dList(self.rows, self.cols) for row in range(self.rows): for col in range(self.cols): self.images[row][col] = self.scaleImage(self.image1, 0.1) self.counter = 0 self.timerDelay = 1 self.timerResult = 'Counting to 10...' self.useCachedImages = False self.resetTimer() def resetTimer(self): self.time0 = time.time() self.counter = 0 def timerFired(self): self.counter += 1 if (self.counter == 10): duration = time.time() - self.time0 self.timerResult = f'Last time to 10: {round(duration,1)}s' self.useCachedImages = not self.useCachedImages self.resetTimer() # from www.cs.cmu.edu/~112/notes/notes-animations-part1.html#exampleGrids def getCellBounds(app, row, col): # aka "modelToView" # returns (x0, y0, x1, y1) corners/bounding box of given cell in grid gridWidth = app.width - 2*app.margin gridHeight = app.height - 2*app.margin columnWidth = gridWidth / app.cols rowHeight = gridHeight / app.rows x0 = app.margin + col * columnWidth x1 = app.margin + (col+1) * columnWidth y0 = app.margin + row * rowHeight y1 = app.margin + (row+1) * rowHeight return (x0, y0, x1, y1) def getCachedPhotoImage(self, image): # stores a cached version of the PhotoImage in the PIL/Pillow image if ('cachedPhotoImage' not in image.__dict__): image.cachedPhotoImage = ImageTk.PhotoImage(image) return image.cachedPhotoImage def redrawAll(self, canvas): for row in range(self.rows): for col in range(self.cols): (x0, y0, x1, y1) = self.getCellBounds(row, col) cx, cy = (x0 + x1)/2, (y0 + y1)/2 image = self.images[row][col] if (self.useCachedImages): photoImage = self.getCachedPhotoImage(image) else: photoImage = ImageTk.PhotoImage(image) canvas.create_image(cx, cy, image=photoImage) canvas.create_rectangle(self.width/2-250, self.height/2-100, self.width/2+250, self.height/2+100, fill='lightYellow') canvas.create_text(self.width/2, self.height/2-50, text=f'Using cached images = {self.useCachedImages}', font='Arial 30 bold') canvas.create_text(self.width/2, self.height/2, text=self.timerResult, font='Arial 30 bold') canvas.create_text(self.width/2, self.height/2+50, text=str(self.counter), font='Arial 30 bold') MyApp(width=700, height=600)

  7. Subclassing ModalApp and Mode
    from cmu_112_graphics import * from tkinter import * import random class SplashScreenMode(Mode): def redrawAll(mode, canvas): font = 'Arial 26 bold' canvas.create_text(mode.width/2, 150, text='This demos a ModalApp!', font=font) canvas.create_text(mode.width/2, 200, text='This is a modal splash screen!', font=font) canvas.create_text(mode.width/2, 250, text='Press any key for the game!', font=font) def keyPressed(mode, event): mode.app.setActiveMode(mode.app.gameMode) class GameMode(Mode): def appStarted(mode): mode.score = 0 mode.randomizeDot() def randomizeDot(mode): mode.x = random.randint(20, mode.width-20) mode.y = random.randint(20, mode.height-20) mode.r = random.randint(10, 20) mode.color = random.choice(['red', 'orange', 'yellow', 'green', 'blue']) mode.dx = random.choice([+1,-1])*random.randint(3,6) mode.dy = random.choice([+1,-1])*random.randint(3,6) def moveDot(mode): mode.x += mode.dx if (mode.x < 0) or (mode.x > mode.width): mode.dx = -mode.dx mode.y += mode.dy if (mode.y < 0) or (mode.y > mode.height): mode.dy = -mode.dy def timerFired(mode): mode.moveDot() def mousePressed(mode, event): d = ((mode.x - event.x)**2 + (mode.y - event.y)**2)**0.5 if (d <= mode.r): mode.score += 1 mode.randomizeDot() elif (mode.score > 0): mode.score -= 1 def keyPressed(mode, event): if (event.key == 'h'): mode.app.setActiveMode(mode.app.helpMode) def redrawAll(mode, canvas): font = 'Arial 26 bold' canvas.create_text(mode.width/2, 20, text=f'Score: {mode.score}', font=font) canvas.create_text(mode.width/2, 50, text='Click on the dot!', font=font) canvas.create_text(mode.width/2, 80, text='Press h for help screen!', font=font) canvas.create_oval(mode.x-mode.r, mode.y-mode.r, mode.x+mode.r, mode.y+mode.r, fill=mode.color) class HelpMode(Mode): def redrawAll(mode, canvas): font = 'Arial 26 bold' canvas.create_text(mode.width/2, 150, text='This is the help screen!', font=font) canvas.create_text(mode.width/2, 250, text='(Insert helpful message here)', font=font) canvas.create_text(mode.width/2, 350, text='Press any key to return to the game!', font=font) def keyPressed(mode, event): mode.app.setActiveMode(mode.app.gameMode) class MyModalApp(ModalApp): def appStarted(app): app.splashScreenMode = SplashScreenMode() app.gameMode = GameMode() app.helpMode = HelpMode() app.setActiveMode(app.splashScreenMode) app.timerDelay = 50 app = MyModalApp(width=500, height=500)

  8. Example: Sidescrollers 1-3
    • SideScroller1
      # SideScroller1: from cmu_112_graphics import * from tkinter import * import random class SideScroller1(App): def appStarted(self): self.scrollX = 0 self.dots = [(random.randrange(self.width), random.randrange(60, self.height)) for _ in range(50)] def keyPressed(self, event): if (event.key == "Left"): self.scrollX -= 5 elif (event.key == "Right"): self.scrollX += 5 def redrawAll(self, canvas): # draw the player fixed to the center of the scrolled canvas cx, cy, r = self.width/2, self.height/2, 10 canvas.create_oval(cx-r, cy-r, cx+r, cy+r, fill='cyan') # draw the dots, shifted by the scrollX offset for (cx, cy) in self.dots: cx -= self.scrollX # <-- This is where we scroll each dot!!! canvas.create_oval(cx-r, cy-r, cx+r, cy+r, fill='lightGreen') # draw the x and y axes x = self.width/2 - self.scrollX # <-- This is where we scroll the axis! y = self.height/2 canvas.create_line(x, 0, x, self.height) canvas.create_line(0, y, self.width, y) # draw the instructions and the current scrollX x = self.width/2 canvas.create_text(x, 20, text='Use arrows to move left or right') canvas.create_text(x, 40, text=f'app.scrollX = {self.scrollX}') SideScroller1(width=300, height=300)

    • SideScroller2
      # SideScroller2: # Now with a scroll margin, so player does not stay fixed # at the center of the scrolled canvas, and we only scroll # if the player's center (in this case) gets closer than the # margin to the left or right edge of the canvas. from cmu_112_graphics import * from tkinter import * import random class SideScroller2(App): def appStarted(self): self.scrollX = 0 self.scrollMargin = 50 self.playerX = self.width//2 # player's center self.dots = [(random.randrange(self.width), random.randrange(60, self.height)) for _ in range(50)] def makePlayerVisible(self): # scroll to make player visible as needed if (self.playerX < self.scrollX + self.scrollMargin): self.scrollX = self.playerX - self.scrollMargin if (self.playerX > self.scrollX + self.width - self.scrollMargin): self.scrollX = self.playerX - self.width + self.scrollMargin def movePlayer(self, dx, dy): self.playerX += dx self.makePlayerVisible() def keyPressed(self, event): if (event.key == "Left"): self.movePlayer(-5, 0) elif (event.key == "Right"): self.movePlayer(+5, 0) def redrawAll(self, canvas): # draw the player, shifted by the scrollX offset cx, cy, r = self.playerX, self.height/2, 10 cx -= self.scrollX # <-- This is where we scroll the player!!! canvas.create_oval(cx-r, cy-r, cx+r, cy+r, fill='cyan') # draw the dots, shifted by the scrollX offset for (cx, cy) in self.dots: cx -= self.scrollX # <-- This is where we scroll each dot!!! canvas.create_oval(cx-r, cy-r, cx+r, cy+r, fill='lightGreen') # draw the x and y axes x = self.width/2 - self.scrollX # <-- This is where we scroll the axis! y = self.height/2 canvas.create_line(x, 0, x, self.height) canvas.create_line(0, y, self.width, y) # draw the instructions and the current scrollX x = self.width/2 canvas.create_text(x, 20, text='Use arrows to move left or right') canvas.create_text(x, 40, text=f'app.scrollX = {self.scrollX}') SideScroller2(width=300, height=300)

    • SideScroller3
      # SideScroller3: # Now with walls that track when you run into them (but # ignore while you are still crossing them). from cmu_112_graphics import * from tkinter import * class SideScroller3(App): def appStarted(self): self.scrollX = 0 self.scrollMargin = 50 self.playerX = self.scrollMargin self.playerY = 0 self.playerWidth = 10 self.playerHeight = 20 self.walls = 5 self.wallPoints = [0]*self.walls self.wallWidth = 20 self.wallHeight = 40 self.wallSpacing = 90 # wall left edges are at 90, 180, 270,... self.currentWallHit = -1 # start out not hitting a wall def getPlayerBounds(self): # returns absolute bounds, not taking scrollX into account (x0, y1) = (self.playerX, self.height/2 - self.playerY) (x1, y0) = (x0 + self.playerWidth, y1 - self.playerHeight) return (x0, y0, x1, y1) def getWallBounds(self, wall): # returns absolute bounds, not taking scrollX into account (x0, y1) = ((1+wall) * self.wallSpacing, self.height/2) (x1, y0) = (x0 + self.wallWidth, y1 - self.wallHeight) return (x0, y0, x1, y1) def getWallHit(self): # return wall that player is currently hitting # note: this should be optimized to only check the walls that are visible # or even just directly compute the wall without a loop playerBounds = self.getPlayerBounds() for wall in range(self.walls): wallBounds = self.getWallBounds(wall) if (self.boundsIntersect(playerBounds, wallBounds) == True): return wall return -1 def boundsIntersect(self, boundsA, boundsB): # return l2<=r1 and t2<=b1 and l1<=r2 and t1<=b2 (ax0, ay0, ax1, ay1) = boundsA (bx0, by0, bx1, by1) = boundsB return ((ax1 >= bx0) and (bx1 >= ax0) and (ay1 >= by0) and (by1 >= ay0)) def checkForNewWallHit(self): # check if we are hitting a new wall for the first time wall = self.getWallHit() if (wall != self.currentWallHit): self.currentWallHit = wall if (wall >= 0): self.wallPoints[wall] += 1 def makePlayerVisible(self): # scroll to make player visible as needed if (self.playerX < self.scrollX + self.scrollMargin): self.scrollX = self.playerX - self.scrollMargin if (self.playerX > self.scrollX + self.width - self.scrollMargin): self.scrollX = self.playerX - self.width + self.scrollMargin def movePlayer(self, dx, dy): self.playerX += dx self.playerY += dy self.makePlayerVisible() self.checkForNewWallHit() def sizeChanged(self): self.makePlayerVisible() def mousePressed(self, event): self.playerX = event.x + self.scrollX self.checkForNewWallHit() def keyPressed(self, event): if (event.key == "Left"): self.movePlayer(-5, 0) elif (event.key == "Right"): self.movePlayer(+5, 0) elif (event.key == "Up"): self.movePlayer(0, +5) elif (event.key == "Down"): self.movePlayer(0, -5) def redrawAll(self, canvas): # draw the base line lineY = self.height/2 lineHeight = 5 canvas.create_rectangle(0, lineY, self.width, lineY+lineHeight,fill="black") # draw the walls # (Note: should optimize to only consider walls that can be visible now!) sx = self.scrollX for wall in range(self.walls): (x0, y0, x1, y1) = self.getWallBounds(wall) fill = "orange" if (wall == self.currentWallHit) else "pink" canvas.create_rectangle(x0-sx, y0, x1-sx, y1, fill=fill) (cx, cy) = ((x0+x1)/2 - sx, (y0 + y1)/2) canvas.create_text(cx, cy, text=str(self.wallPoints[wall])) cy = lineY + 5 canvas.create_text(cx, cy, text=str(wall), anchor=N) # draw the player (x0, y0, x1, y1) = self.getPlayerBounds() canvas.create_oval(x0 - sx, y0, x1 - sx, y1, fill="cyan") # draw the instructions msg = "Use arrows to move, hit walls to score" canvas.create_text(self.width/2, 20, text=msg) SideScroller3(width=300, height=300)