Introduction
I recently bought a new laptop. Since I haven't used VB.Net in years (nothing but Python now) I did not install Visual Studio. That meant rewriting all my VB apps in Python/wxPython. One of my most often used apps was a shell extension I wrote to add a folder right-click option to launch a small image viewer. This short tutorial will explain how the pieces work. I will not get into how to write an app in wxPython. You will find other tutorials on that here on Daniweb.
I'll post the entire project as three files
- ZoomPic.pyw (main code)
- ZoomPic.reg (Windows registry mod - need to be edited)
- perCeivedType.py (Utility method for file screening)
I'll check this thread periodically for questions. I likely glossed over some things that may need further explanation. There's a reason I don't get paid to do this.
Drawing the display
The images are displayed in one of two modes, windowed, or full-screen. Normally, if you wanted to display an image you would use a wx.StaticBitmap control. The process would be
- Read the data from the file into a wx.Image object
- Scale the image to fit the size of the display control
- Convert the image to a bitmap and copy to the wx.StaticBitmap
There are a few problems with this approach.
- The image will be distorted unless the container has the same aspect ratio as the image
- You will usually get an annoying flicker when changing files
We'll handle the first problem by writing a rescale method to ensure that the image will fit in the container with no distortion. We'll handle the second by using a method called double buffering to do the redrawing of the display.
Here is the rescale method.
cw,ch the height and width of the container
iw,ih the height and width of the image
aspect the aspect ratio h/w of the original image
nw,nh the new width and height adjusting for the container
After we calculate the new width and height based on the width of the container we then check to see if the new height is bigger than the container height. If so we recalculate the new width and height based on the container height.
Note that this only creates a scaled image. It does not display it.
def ScaleToFit(self, image):
"""Scale an image to fit parent control"""
# Get image size, container size, and calculate aspect ratio
cw, ch = self.Size
iw, ih = image.GetSize()
aspect = ih / iw
# Determine new size with aspect and adjust if any cropping
nw = cw
nh = int(nw * aspect)
if nh > ch:
nh = ch
nw = int(nh / aspect)
# Return the newly scaled image
return image.Scale(int(nw*self.zoom), int(nh*self.zoom))
Now comes the part I still find confusing - direct draw using double buffering. Normally you let wxPython handle refreshing the display but in our case we are going to force a redraw on several events. We'll get to those events in a minute. For now, let's see how to do a direct draw.
We are going to create something called a device context. Consider it like a printer driver, but for the screen. You can only create a device context within the event handler for an EVT_PAINT event which we will trigger manually by calling the Refresh() method. If it sounds confusing, it is. But it will make more sense in a minute. We are going to use double buffering.
I'm going to use an analogy to explain what double buffering is. I'm either going to insult your intelligence, or expose my ignorance (based on my limited understanding). You've all seen the flip-style animation where you draw a separate picture on the sheets of a book. When you flip the pages you see an animated scene. Imagine that every time you flipped to a new page you had to wait for the artist to draw the image. That would introduce an annoying delay. Now imagine that while you are looking at one image the artist is busy rendering the next (he's really fast) so that when you flip to the next page it is already rendered.
That is a simple-minded way of looking at double buffering. The new image is not displayed until it is ready. Here's how we do it. First we create the device context. Then we clear it and draw the bitmap into the device at the given starting x and y coordinates. Then, as a bonus, we add some text to the display.
dc = wx.AutoBufferedPaintDC(self)
dc.Clear()
dc.DrawBitmap(bitmap, sx, sy, True)
dc.SetTextForeground(wx.WHITE)
dc.SetTextBackground(wx.BLACK)
dc.DrawText(self.files[self.index], 5, 5)
Note that for this to work we must have set
self.SetBackgroundStyle(wx.BG_STYLE_CUSTOM)
in the MyFrame initialization. Why this is necessary was explained to me by "you just have to". I eventually found this in the wxPython official docs.
There are a lot of methods for the device context object for drawing all sorts of shapes, icons, etc. For the first iteration of our drawing method we are going to assume an image has already been loaded from the file and that we are not concerned about zoom/pan. Here is the first iteration of the on_paint method
def on_paint(self, evt):
"""Draw the current image"""
# Scale the image to fit and convert to bitmap
image = self.ScaleToFit(self.image)
bitmap = image.ConvertToBitmap()
# Determine upper left corner to centre image on screen
iw, ih = bitmap.GetSize()
sw, sh = self.Size
sx, sy = int((sw - iw) / 2), int((sh - ih) / 2)
# Draw the image
dc = wx.AutoBufferedPaintDC(self)
dc.Clear()
dc.DrawBitmap(bitmap, sx, sy, True)
dc.SetTextForeground(wx.WHITE)
dc.SetTextBackground(wx.BLACK)
dc.DrawText(self.files[self.index], 5, 5)
evt.Skip()
Now we want to add a few controls to the interface. I like to use hotkeys. For one, navigating with things like arrow keys is easier (for me) than constantly moving the mouse and clicking, plus, putting buttons or menus on the display takes away from the picture. The hotkeys I've defined are
arrow left/up prev image
arrow right/down next image
f toggle full-screen/windowed (later)
esc quit
Mouse controls are
wheel prev/next image
left-click zoom
left-click/drag zoom/pan
Hotkeys (or accelerators) can be linked to menu items and buttons. Because we want the buttons to be invisible we will make them zero-sized. Then we bind them to our custom event handlers. Each event handler ends with a call to Skip() to allow wxPython to do whatever default actions it does. Our button setup looks like
# Display previous picture
self.btnPrev = wx.Button(self, wx.ID_ANY, size=(0,0))
self.btnPrev.Visible = False
self.Bind(wx.EVT_BUTTON, self.evt_prev, self.btnPrev)
# Display next picture
self.btnNext = wx.Button(self, wx.ID_ANY, size=(0,0))
self.btnNext.Visible = False
self.Bind(wx.EVT_BUTTON, self.evt_next, self.btnNext)
# Toggle fullscreen
self.btnFull = wx.Button(self, wx.ID_ANY, size=(0,0))
self.btnFull.Visible = False
self.Bind(wx.EVT_BUTTON, self.evt_full, self.btnFull)
# Exit app
self.btnExit = wx.Button(self, wx.ID_ANY, size=(0,0))
self.btnExit.Visible = False
self.Bind(wx.EVT_BUTTON, self.evt_exit, self.btnExit)
Linking the controls to hotkeys is done by
#Define hotkeys
hotkeys = [wx.AcceleratorEntry() for i in range(6)]
hotkeys[0].Set(wx.ACCEL_NORMAL, wx.WXK_DOWN, self.btnNext.Id)
hotkeys[1].Set(wx.ACCEL_NORMAL, wx.WXK_UP, self.btnPrev.Id)
hotkeys[2].Set(wx.ACCEL_NORMAL, wx.WXK_RIGHT, self.btnNext.Id)
hotkeys[3].Set(wx.ACCEL_NORMAL, wx.WXK_LEFT, self.btnPrev.Id)
hotkeys[4].Set(wx.ACCEL_NORMAL, wx.WXK_ESCAPE, self.btnExit.Id)
hotkeys[5].Set(wx.ACCEL_NORMAL, ord('f'), self.btnFull.Id)
accel = wx.AcceleratorTable(hotkeys)
self.SetAcceleratorTable(accel)
And the mouse setup looks like
# Connect Mouse events to event handlers
self.Bind(wx.EVT_LEFT_DOWN, self.on_left_down)
self.Bind(wx.EVT_LEFT_UP, self.on_left_up)
self.Bind(wx.EVT_MOTION, self.on_motion)
self.Bind(wx.EVT_MOUSEWHEEL, self.on_scroll)
The EVT_MOTION event handler will be used when zooming/panning.
Finally, we bind our handlers for painting and resizing
# Connect events to event handlers
self.Bind(wx.EVT_SIZE, self.on_size)
self.Bind(wx.EVT_PAINT, self.on_paint)
How it works
When the application starts, it scans the current working directory for image files. As a simple test, we can look for all files with an extension matching a list of known image files. That's what I use in this example in the isImage method. I'll include here a more general version which checks the Windows Registry to see what files Windows recognizes as images.
The file names are saved in a list and the first file is read into self.image. Since this is done before we bind the event handlers, when on_paint is called the first time the Window is displayed it will already have access to the first image. After that, any time we want to display a new image, all we have to do is read it into self.image then call Refresh to trigger on_paint.
By maintaining a flag (self.left_down) that keeps track of when the left mouse button is pressed, we can determine whether to display the image full size (relative to the window) or zoomed in.
One thing in particular I had trouble with was switching between windowed and full-screen. If I started the application in windowed mode I was able to toggle between modes with no problem. However, If I started in full-screen, toggling to windowed mode caused the app to hang. User Zig_Zag at the wxPython forum showed me how to fix it, but I still don't see why the fix works. Originally I had
self.ShowFullScreen(True)
in the initialization of MyFrame. When moved to OnInit for MyApp as
self.frame.ShowFullScreen(True)
the problem went away.
Adding as a shell extension
You can run ZoomPic from the command line by giving it a folder name as a parameter. More useful is having it available in the context menu for a folder. On my computer, the registry location for a folder context menu is under HKEY_CLASSES_ROOT at
Directory
Shell
ZoomPic
command
(Default) REG_SZ "C:\Users\rjdeg\AppData\Local\Programs\Python\Python310\pythonw.exe" "D:\apps\ZoomPic\ZoomPic.pyw" "%1"
There are likely other entries under Directory at the same level as Shell so we just add ZoomPic as a new one. This will not cause anything else to stop working. Note that the value of the REG_SZ value will change from system to system. You'll have to put in the full path for your pythonw.exe and zoompic.pyw. To make it easier, just take the attached file, ZoomPic.reg, modify it for your system, then run it (or double click on it) to add it to your registry.
More general file types
""" Name:
Name:
perceivedType.py
Description:
This is a set of methods that use the Windows registry to return a string
describing how Windows interprets the given file. The current methods will
return a string description as provided by Windows, or "unknown" if Windows
does not have an associated file type. The auxiliary functions return True
or False for tests for specific file types.
Auxiliary Functions:
isVideo(file) - returns True if PerceivedType = "video"
isAudio(file) - returns True if PerceivedType = "audio"
isImage(file) - returns True if PerceivedType = "image"
isText (file) - returns True if PerceivedType = "text"
Parameters:
file:str a file name
degug:bool print debug info if True (default=False)
Audit:
2021-07-17 rj original code
"""
import os
import winreg
def perceivedType(file: str, debug: bool = False) -> str:
"""Returns the windows registry perceived type string for the given file"""
if debug:
print(f'\nchecking {file=}')
try:
key = winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, os.path.splitext(file)[-1])
inf = winreg.QueryInfoKey(key)
for i in range(0, inf[1]):
res = winreg.EnumValue(key, i)
if debug:
print(f' {res=}')
if res[0] == 'PerceivedType':
return res[1].lower()
except:
pass
return "unknown"
def isVideo(file: str) -> str: return perceivedType(file) == 'video'
def isAudio(file: str) -> str: return perceivedType(file) == 'audio'
def isImage(file: str) -> str: return perceivedType(file) == 'image'
def isText(file: str) -> str: return perceivedType(file) == 'text'
if __name__ == '__main__':
for file in ('file.avi', 'file.mov', 'file.txt', 'file.jpg', 'file.mp3', 'file.pdf', 'file.xyz'):
print('Perceived type of "%s" is %s' % (file, perceivedType(file, debug=True)))
Recommended Reading
Two excellent books on developing applications in wxPython are
wxPython 2.8 Application Development Cookbook by Cody Precord.
Creating GUI Applications With wxPython by Michael Driscoll
The second book is more recent and uses wxPython features that may not have been available in earlier books.
Another excellent resource is wxPython Forum. The experts there were very helpful when I got stuck.