-
Notifications
You must be signed in to change notification settings - Fork 0
/
cmu_112_graphics.py
768 lines (675 loc) · 33.7 KB
/
cmu_112_graphics.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
# cmu_112_graphics.py
# version 0.9.2
# Pre-release for CMU 15-112-f21
# Require Python 3.6 or later
import sys
if ((sys.version_info[0] != 3) or (sys.version_info[1] < 6)):
raise Exception('cmu_112_graphics.py requires Python version 3.6 or later.')
# Track version and file update timestamp
import datetime
MAJOR_VERSION = 0
MINOR_VERSION = 9.2 # version 0.9.2
LAST_UPDATED = datetime.date(year=2022, month=3, day=29)
# Pending changes:
# * Fix Windows-only bug: Position popup dialog box over app window (already works fine on Macs)
# * Add documentation
# * integrate sounds (probably from pyGame)
# * Improved methodIsOverridden to TopLevelApp and ModalApp
# * Save to animated gif and/or mp4 (with audio capture?)
# Deferred changes:
# * replace/augment tkinter canvas with PIL/Pillow imageDraw (perhaps with our own fn names)
# Changes in v0.9.2
# * added event.ctrl, event.alt, event.shift
# Changes in v0.9.1
# * If we are in a mode when we call appStopped, then also call the non-modal appStopped
# Changes in v0.9.0
# * added simpler top-level modes implementation that does not include mode objects
# * added ImageDraw and ImageFont to PIL imports
# Changes in v0.8.8
# * added __repr__ methods so:
# * print(event) works and prints event.key or event.x + event.y
# * print(app) works and prints just the user defined app fields
# Changes in v0.8.7
# * removed modes (for now)
# Changes in v0.8.6
# * f21
# Changes in v0.8.5
# * Support loadImage from Modes
# Changes in v0.8.3 + v0.8.4
# * Use default empty Mode if none is provided
# * Add KeyRelease event binding
# * Drop user32.SetProcessDPIAware (caused window to be really tiny on some Windows machines)
# Changes in v0.8.1 + v0.8.2
# * print version number and last-updated date on load
# * restrict modifiers to just control key (was confusing with NumLock, etc)
# * replace hasModifiers with 'control-' prefix, as in 'control-A'
# * replace app._paused with app.paused, etc (use app._ for private variables)
# * use improved ImageGrabber import for linux
# Changes in v0.8.0
# * suppress more modifier keys (Super_L, Super_R, ...)
# * raise exception on event.keysym or event.char + works with key = 'Enter'
# * remove tryToInstall
# Changes in v0.7.4
# * renamed drawAll back to redrawAll :-)
# Changes in v0.7.3
# * Ignore mousepress-drag-release and defer configure events for drags in titlebar
# * Extend deferredRedrawAll to 100ms with replace=True and do not draw while deferred
# (together these hopefully fix Windows-only bug: file dialog makes window not moveable)
# * changed sizeChanged to not take event (use app.width and app.height)
# Changes in v0.7.2
# * Singleton App._theRoot instance (hopefully fixes all those pesky Tkinter errors-on-exit)
# * Use user32.SetProcessDPIAware to get resolution of screen grabs right on Windows-only (fine on Macs)
# * Replaces showGraphics() with runApp(...), which is a veneer for App(...) [more intuitive for pre-OOP part of course]
# * Fixes/updates images:
# * disallows loading images in redrawAll (raises exception)
# * eliminates cache from loadImage
# * eliminates app.getTkinterImage, so user now directly calls ImageTk.PhotoImage(image))
# * also create_image allows magic pilImage=image instead of image=ImageTk.PhotoImage(app.image)
# Changes in v0.7.1
# * Added keyboard shortcut:
# * cmd/ctrl/alt-x: hard exit (uses os._exit() to exit shell without tkinter error messages)
# * Fixed bug: shortcut keys stopped working after an MVC violation (or other exception)
# * In app.saveSnapshot(), add .png to path if missing
# * Added: Print scripts to copy-paste into shell to install missing modules (more automated approaches proved too brittle)
# Changes in v0.7
# * Added some image handling (requires PIL (retained) and pyscreenshot (later removed):
# * app.loadImage() # loads PIL/Pillow image from file, with file dialog, or from URL (http or https)
# * app.scaleImage() # scales a PIL/Pillow image
# * app.getTkinterImage() # converts PIL/Pillow image to Tkinter PhotoImage for use in create_image(...)
# * app.getSnapshot() # get a snapshot of the canvas as a PIL/Pillow image
# * app.saveSnapshot() # get and save a snapshot
# * Added app._paused, app.togglePaused(), and paused highlighting (red outline around canvas when paused)
# * Added keyboard shortcuts:
# * cmd/ctrl/alt-s: save a snapshot
# * cmd/ctrl/alt-p: pause/unpause
# * cmd/ctrl/alt-q: quit
# Changes in v0.6:
# * Added fnPrefix option to TopLevelApp (so multiple TopLevelApp's can be in one file)
# * Added showGraphics(drawFn) (for graphics-only drawings before we introduce animations)
# Changes in v0.5:
# * Added:
# * app.winx and app.winy (and add winx,winy parameters to app.__init__, and sets these on configure events)
# * app.setSize(width, height)
# * app.setPosition(x, y)
# * app.quit()
# * app.showMessage(message)
# * app.getUserInput(prompt)
# * App.lastUpdated (instance of datetime.date)
# * Show popup dialog box on all exceptions (not just for MVC violations)
# * Draw (in canvas) "Exception! App Stopped! (See console for details)" for any exception
# * Replace callUserMethod() with more-general @_safeMethod decorator (also handles exceptions outside user methods)
# * Only include lines from user's code (and not our framework nor tkinter) in stack traces
# * Require Python version (3.6 or greater)
# Changes in v0.4:
# * Added __setattr__ to enforce Type 1A MVC Violations (setting app.x in redrawAll) with better stack trace
# * Added app._deferredRedrawAll() (avoids resizing drawing/crashing bug on some platforms)
# * Added deferredMethodCall() and app._afterIdMap to generalize afterId handling
# * Use (_ is None) instead of (_ == None)
# Changes in v0.3:
# * Fixed "event not defined" bug in sizeChanged handlers.
# * draw "MVC Violation" on Type 2 violation (calling draw methods outside redrawAll)
# Changes in v0.2:
# * Handles another MVC violation (now detects drawing on canvas outside of redrawAll)
# * App stops running when an exception occurs (in user code) (stops cascading errors)
# Changes in v0.1:
# * OOPy + supports inheritance + supports multiple apps in one file + etc
# * uses import instead of copy-paste-edit starter code + no "do not edit code below here!"
# * no longer uses Struct (which was non-Pythonic and a confusing way to sort-of use OOP)
# * Includes an early version of MVC violation handling (detects model changes in redrawAll)
# * added events:
# * appStarted (no init-vs-__init__ confusion)
# * appStopped (for cleanup)
# * keyReleased (well, sort of works) + mouseReleased
# * mouseMoved + mouseDragged
# * sizeChanged (when resizing window)
# * improved key names (just use event.key instead of event.char and/or event.keysym + use names for 'Enter', 'Escape', ...)
# * improved function names (renamed redrawAll to drawAll)
# * improved (if not perfect) exiting without that irksome Tkinter error/bug
# * app has a title in the titlebar (also shows window's dimensions)
# * supports Modes and ModalApp (see ModalApp and Mode, and also see TestModalApp example)
# * supports TopLevelApp (using top-level functions instead of subclasses and methods)
# * supports version checking with App.majorVersion, App.minorVersion, and App.version
# * logs drawing calls to support autograding views (still must write that autograder, but this is a very helpful first step)
from tkinter import *
from tkinter import messagebox, simpledialog, filedialog
import inspect, copy, traceback
import sys, os
from io import BytesIO
def failedImport(importName, installName=None):
installName = installName or importName
print('**********************************************************')
print(f'** Cannot import {importName} -- it seems you need to install {installName}')
print(f'** This may result in limited functionality or even a runtime error.')
print('**********************************************************')
print()
try: from PIL import Image, ImageTk, ImageDraw, ImageFont
except ModuleNotFoundError: failedImport('PIL', 'pillow')
if sys.platform.startswith('linux'):
try: import pyscreenshot as ImageGrabber
except ModuleNotFoundError: failedImport('pyscreenshot')
else:
try: from PIL import ImageGrab as ImageGrabber
except ModuleNotFoundError: pass # Our PIL warning is already printed above
try: import requests
except ModuleNotFoundError: failedImport('requests')
def getHash(obj):
# This is used to detect MVC violations in redrawAll
# @TODO: Make this more robust and efficient
try:
return getHash(obj.__dict__)
except:
if (isinstance(obj, list)): return getHash(tuple([getHash(v) for v in obj]))
elif (isinstance(obj, set)): return getHash(sorted(obj))
elif (isinstance(obj, dict)): return getHash(tuple([obj[key] for key in sorted(obj)]))
else:
try: return hash(obj)
except: return getHash(repr(obj))
class WrappedCanvas(Canvas):
# Enforces MVC: no drawing outside calls to redrawAll
# Logs draw calls (for autograder) in canvas.loggedDrawingCalls
def __init__(wrappedCanvas, app):
wrappedCanvas.loggedDrawingCalls = [ ]
wrappedCanvas.logDrawingCalls = True
wrappedCanvas.inRedrawAll = False
wrappedCanvas.app = app
super().__init__(app._root, width=app.width, height=app.height)
def log(self, methodName, args, kwargs):
if (not self.inRedrawAll):
self.app._mvcViolation('you may not use the canvas (the view) outside of redrawAll')
if (self.logDrawingCalls):
self.loggedDrawingCalls.append((methodName, args, kwargs))
def create_arc(self, *args, **kwargs): self.log('create_arc', args, kwargs); return super().create_arc(*args, **kwargs)
def create_bitmap(self, *args, **kwargs): self.log('create_bitmap', args, kwargs); return super().create_bitmap(*args, **kwargs)
def create_line(self, *args, **kwargs): self.log('create_line', args, kwargs); return super().create_line(*args, **kwargs)
def create_oval(self, *args, **kwargs): self.log('create_oval', args, kwargs); return super().create_oval(*args, **kwargs)
def create_polygon(self, *args, **kwargs): self.log('create_polygon', args, kwargs); return super().create_polygon(*args, **kwargs)
def create_rectangle(self, *args, **kwargs): self.log('create_rectangle', args, kwargs); return super().create_rectangle(*args, **kwargs)
def create_text(self, *args, **kwargs): self.log('create_text', args, kwargs); return super().create_text(*args, **kwargs)
def create_window(self, *args, **kwargs): self.log('create_window', args, kwargs); return super().create_window(*args, **kwargs)
def create_image(self, *args, **kwargs):
self.log('create_image', args, kwargs);
usesImage = 'image' in kwargs
usesPilImage = 'pilImage' in kwargs
if ((not usesImage) and (not usesPilImage)):
raise Exception('create_image requires an image to draw')
elif (usesImage and usesPilImage):
raise Exception('create_image cannot use both an image and a pilImage')
elif (usesPilImage):
pilImage = kwargs['pilImage']
del kwargs['pilImage']
if (not isinstance(pilImage, Image.Image)):
raise Exception('create_image: pilImage value is not an instance of a PIL/Pillow image')
image = ImageTk.PhotoImage(pilImage)
else:
image = kwargs['image']
if (isinstance(image, Image.Image)):
raise Exception('create_image: image must not be an instance of a PIL/Pillow image\n' +
'You perhaps meant to convert from PIL to Tkinter, like so:\n' +
' canvas.create_image(x, y, image=ImageTk.PhotoImage(image))')
kwargs['image'] = image
return super().create_image(*args, **kwargs)
class App(object):
majorVersion = MAJOR_VERSION
minorVersion = MINOR_VERSION
version = f'{majorVersion}.{minorVersion}'
lastUpdated = LAST_UPDATED
_theRoot = None # singleton Tkinter root object
####################################
# User Methods:
####################################
def redrawAll(app, canvas): pass # draw (view) the model in the canvas
def appStarted(app): pass # initialize the model (app.xyz)
def appStopped(app): pass # cleanup after app is done running
def keyPressed(app, event): pass # use event.key
def keyReleased(app, event): pass # use event.key
def mousePressed(app, event): pass # use event.x and event.y
def mouseReleased(app, event): pass # use event.x and event.y
def mouseMoved(app, event): pass # use event.x and event.y
def mouseDragged(app, event): pass # use event.x and event.y
def timerFired(app): pass # respond to timer events
def sizeChanged(app): pass # respond to window size changes
####################################
# Implementation:
####################################
def __init__(app, width=300, height=300, x=0, y=0, title=None, autorun=True, mvcCheck=True, logDrawingCalls=True):
app.winx, app.winy, app.width, app.height = x, y, width, height
app.timerDelay = 100 # milliseconds
app.mouseMovedDelay = 50 # ditto
app._title = title
app._mvcCheck = mvcCheck
app._logDrawingCalls = logDrawingCalls
app._running = app._paused = False
app._mousePressedOutsideWindow = False
if autorun: app.run()
def __repr__(app):
keys = set(app.__dict__.keys())
keyValues = [ ]
for key in sorted(keys - app._ignoredFields):
keyValues.append(f'{key}={app.__dict__[key]}')
return f'App({", ".join(keyValues)})'
def setSize(app, width, height):
app._root.geometry(f'{width}x{height}')
def setPosition(app, x, y):
app._root.geometry(f'+{x}+{y}')
def showMessage(app, message):
messagebox.showinfo('showMessage', message, parent=app._root)
def getUserInput(app, prompt):
return simpledialog.askstring('getUserInput', prompt)
def loadImage(app, path=None):
if (app._canvas.inRedrawAll):
raise Exception('Cannot call loadImage in redrawAll')
if (path is None):
path = filedialog.askopenfilename(initialdir=os.getcwd(), title='Select file: ',filetypes = (('Image files','*.png *.gif *.jpg'),('all files','*.*')))
if (not path): return None
if (path.startswith('http')):
response = requests.request('GET', path) # path is a URL!
image = Image.open(BytesIO(response.content))
else:
image = Image.open(path)
return image
def scaleImage(app, image, scale, antialias=False):
# antialiasing is higher-quality but slower
resample = Image.ANTIALIAS if antialias else Image.NEAREST
return image.resize((round(image.width*scale), round(image.height*scale)), resample=resample)
def getSnapshot(app):
app._showRootWindow()
x0 = app._root.winfo_rootx() + app._canvas.winfo_x()
y0 = app._root.winfo_rooty() + app._canvas.winfo_y()
result = ImageGrabber.grab((x0,y0,x0+app.width,y0+app.height))
return result
def saveSnapshot(app):
path = filedialog.asksaveasfilename(initialdir=os.getcwd(), title='Select file: ',filetypes = (('png files','*.png'),('all files','*.*')))
if (path):
# defer call to let filedialog close (and not grab those pixels)
if (not path.endswith('.png')): path += '.png'
app._deferredMethodCall(afterId='saveSnapshot', afterDelay=0, afterFn=lambda:app.getSnapshot().save(path))
def _togglePaused(app):
app._paused = not app._paused
def quit(app):
app._running = False
app._root.quit() # break out of root.mainloop() without closing window!
def __setattr__(app, attr, val):
d = app.__dict__
d[attr] = val
canvas = d.get('_canvas', None)
if (d.get('running', False) and
d.get('mvcCheck', False) and
(canvas is not None) and
canvas.inRedrawAll):
app._mvcViolation(f'you may not change app.{attr} in the model while in redrawAll (the view)')
def _printUserTraceback(app, exception, tb):
stack = traceback.extract_tb(tb)
lines = traceback.format_list(stack)
inRedrawAllWrapper = False
printLines = [ ]
for line in lines:
if (('"cmu_112_graphics.py"' not in line) and
('/cmu_112_graphics.py' not in line) and
('\\cmu_112_graphics.py' not in line) and
('/tkinter/' not in line) and
('\\tkinter\\' not in line)):
printLines.append(line)
if ('redrawAllWrapper' in line):
inRedrawAllWrapper = True
if (len(printLines) == 0):
# No user code in trace, so we have to use all the code (bummer),
# but not if we are in a redrawAllWrapper...
if inRedrawAllWrapper:
printLines = [' No traceback available. Error occurred in redrawAll.\n']
else:
printLines = lines
print('Traceback (most recent call last):')
for line in printLines: print(line, end='')
print(f'Exception: {exception}')
def _safeMethod(appMethod):
def m(*args, **kwargs):
app = args[0]
try:
return appMethod(*args, **kwargs)
except Exception as e:
app._running = False
app._printUserTraceback(e, sys.exc_info()[2])
if ('_canvas' in app.__dict__):
app._canvas.inRedrawAll = True # not really, but stops recursive MVC Violations!
app._canvas.create_rectangle(0, 0, app.width, app.height, fill=None, width=10, outline='red')
app._canvas.create_rectangle(10, app.height-50, app.width-10, app.height-10,
fill='white', outline='red', width=4)
app._canvas.create_text(app.width/2, app.height-40, text=f'Exception! App Stopped!', fill='red', font='Arial 12 bold')
app._canvas.create_text(app.width/2, app.height-20, text=f'See console for details', fill='red', font='Arial 12 bold')
app._canvas.update()
app.showMessage(f'Exception: {e}\nClick ok then see console for details.')
return m
def _methodIsOverridden(app, methodName):
return (getattr(type(app), methodName) is not getattr(App, methodName))
def _mvcViolation(app, errMsg):
app._running = False
raise Exception('MVC Violation: ' + errMsg)
@_safeMethod
def _redrawAllWrapper(app):
if (not app._running): return
if ('deferredRedrawAll' in app._afterIdMap): return # wait for pending call
app._canvas.inRedrawAll = True
app._canvas.delete(ALL)
width,outline = (10,'red') if app._paused else (0,'white')
app._canvas.create_rectangle(0, 0, app.width, app.height, fill='white', width=width, outline=outline)
app._canvas.loggedDrawingCalls = [ ]
app._canvas.logDrawingCalls = app._logDrawingCalls
hash1 = getHash(app) if app._mvcCheck else None
try:
app.redrawAll(app._canvas)
hash2 = getHash(app) if app._mvcCheck else None
if (hash1 != hash2):
app._mvcViolation('you may not change the app state (the model) in redrawAll (the view)')
finally:
app._canvas.inRedrawAll = False
app._canvas.update()
def _deferredMethodCall(app, afterId, afterDelay, afterFn, replace=False):
def afterFnWrapper():
app._afterIdMap.pop(afterId, None)
afterFn()
id = app._afterIdMap.get(afterId, None)
if ((id is None) or replace):
if id: app._root.after_cancel(id)
app._afterIdMap[afterId] = app._root.after(afterDelay, afterFnWrapper)
def _deferredRedrawAll(app):
app._deferredMethodCall(afterId='deferredRedrawAll', afterDelay=100, afterFn=app._redrawAllWrapper, replace=True)
@_safeMethod
def _appStartedWrapper(app):
app.appStarted()
app._redrawAllWrapper()
_keyNameMap = { '\t':'Tab', '\n':'Enter', '\r':'Enter', '\b':'Backspace',
chr(127):'Delete', chr(27):'Escape', ' ':'Space' }
@staticmethod
def _useEventKey(attr):
raise Exception(f'Use event.key instead of event.{attr}')
@staticmethod
def _getEventKeyInfo(event, keysym, char):
key = c = char
hasControlKey = (event.state & 0x4 != 0)
if ((c in [None, '']) or (len(c) > 1) or (ord(c) > 255)):
key = keysym
if (key.endswith('_L') or
key.endswith('_R') or
key.endswith('_Lock')):
key = 'Modifier_Key'
elif (c in App._keyNameMap):
key = App._keyNameMap[c]
elif ((len(c) == 1) and (1 <= ord(c) <= 26)):
key = chr(ord('a')-1 + ord(c))
hasControlKey = True
if hasControlKey and (len(key) == 1):
# don't add control- prefix to Enter, Tab, Escape, ...
key = 'control-' + key
return key
class EventWrapper(Event):
def __init__(self, event):
for key in event.__dict__:
if (not key.startswith('__')):
self.__dict__[key] = event.__dict__[key]
class MouseEventWrapper(EventWrapper):
def __repr__(self):
return f'Event(x={self.x}, y={self.y})'
class KeyEventWrapper(EventWrapper):
def __init__(self, event):
keysym, char = event.keysym, event.char
del event.keysym
del event.char
super().__init__(event)
self.key = App._getEventKeyInfo(event, keysym, char)
self.ctrl = (event.state & 0x4) != 0
self.alt = (event.state & 0x8) != 0 or (event.state & 0x80) != 0
self.shift = (event.state & 0x1) != 0
def __repr__(self):
return f'Event(key={repr(self.key)})'
keysym = property(lambda *args: App._useEventKey('keysym'),
lambda *args: App._useEventKey('keysym'))
char = property(lambda *args: App._useEventKey('char'),
lambda *args: App._useEventKey('char'))
@_safeMethod
def _keyPressedWrapper(app, event):
event = App.KeyEventWrapper(event)
if (event.key == 'control-s'):
app.saveSnapshot()
elif (event.key == 'control-p'):
app._togglePaused()
app._redrawAllWrapper()
elif (event.key == 'control-q'):
app.quit()
elif (event.key == 'control-x'):
os._exit(0) # hard exit avoids tkinter error messages
elif (app._running and
(not app._paused) and
app._methodIsOverridden('keyPressed') and
(not event.key == 'Modifier_Key')):
app.keyPressed(event)
app._redrawAllWrapper()
@_safeMethod
def _keyReleasedWrapper(app, event):
if (not app._running) or app._paused or (not app._methodIsOverridden('keyReleased')): return
event = App.KeyEventWrapper(event)
if (not event.key == 'Modifier_Key'):
app.keyReleased(event)
app._redrawAllWrapper()
@_safeMethod
def _mousePressedWrapper(app, event):
if (not app._running) or app._paused: return
if ((event.x < 0) or (event.x > app.width) or
(event.y < 0) or (event.y > app.height)):
app._mousePressedOutsideWindow = True
else:
app._mousePressedOutsideWindow = False
app._mouseIsPressed = True
app._lastMousePosn = (event.x, event.y)
if (app._methodIsOverridden('mousePressed')):
event = App.MouseEventWrapper(event)
app.mousePressed(event)
app._redrawAllWrapper()
@_safeMethod
def _mouseReleasedWrapper(app, event):
if (not app._running) or app._paused: return
app._mouseIsPressed = False
if app._mousePressedOutsideWindow:
app._mousePressedOutsideWindow = False
app._sizeChangedWrapper()
else:
app._lastMousePosn = (event.x, event.y)
if (app._methodIsOverridden('mouseReleased')):
event = App.MouseEventWrapper(event)
app.mouseReleased(event)
app._redrawAllWrapper()
@_safeMethod
def _timerFiredWrapper(app):
if (not app._running) or (not app._methodIsOverridden('timerFired')): return
if (not app._paused):
app.timerFired()
app._redrawAllWrapper()
app._deferredMethodCall(afterId='_timerFiredWrapper', afterDelay=app.timerDelay, afterFn=app._timerFiredWrapper)
@_safeMethod
def _sizeChangedWrapper(app, event=None):
if (not app._running): return
if (event and ((event.width < 2) or (event.height < 2))): return
if (app._mousePressedOutsideWindow): return
app.width,app.height,app.winx,app.winy = [int(v) for v in app._root.winfo_geometry().replace('x','+').split('+')]
if (app._lastWindowDims is None):
app._lastWindowDims = (app.width, app.height, app.winx, app.winy)
else:
newDims =(app.width, app.height, app.winx, app.winy)
if (app._lastWindowDims != newDims):
app._lastWindowDims = newDims
app.updateTitle()
app.sizeChanged()
app._deferredRedrawAll() # avoid resize crashing on some platforms
@_safeMethod
def _mouseMotionWrapper(app):
if (not app._running): return
mouseMovedExists = app._methodIsOverridden('mouseMoved')
mouseDraggedExists = app._methodIsOverridden('mouseDragged')
if ((not app._paused) and
(not app._mousePressedOutsideWindow) and
(((not app._mouseIsPressed) and mouseMovedExists) or
(app._mouseIsPressed and mouseDraggedExists))):
class MouseMotionEvent(object): pass
event = MouseMotionEvent()
root = app._root
event.x = root.winfo_pointerx() - root.winfo_rootx()
event.y = root.winfo_pointery() - root.winfo_rooty()
event = App.MouseEventWrapper(event)
if ((app._lastMousePosn != (event.x, event.y)) and
(event.x >= 0) and (event.x <= app.width) and
(event.y >= 0) and (event.y <= app.height)):
if (app._mouseIsPressed): app.mouseDragged(event)
else: app.mouseMoved(event)
app._lastMousePosn = (event.x, event.y)
app._redrawAllWrapper()
if (mouseMovedExists or mouseDraggedExists):
app._deferredMethodCall(afterId='mouseMotionWrapper', afterDelay=app.mouseMovedDelay, afterFn=app._mouseMotionWrapper)
def updateTitle(app):
app._title = app._title or type(app).__name__
app._root.title(f'{app._title} ({app.width} x {app.height})')
def getQuitMessage(app):
appLabel = type(app).__name__
if (app._title != appLabel):
if (app._title.startswith(appLabel)):
appLabel = app._title
else:
appLabel += f" '{app._title}'"
return f"*** Closing {appLabel}. Bye! ***\n"
def _showRootWindow(app):
root = app._root
root.update(); root.deiconify(); root.lift(); root.focus()
def _hideRootWindow(app):
root = app._root
root.withdraw()
@_safeMethod
def run(app):
app._mouseIsPressed = False
app._lastMousePosn = (-1, -1)
app._lastWindowDims= None # set in sizeChangedWrapper
app._afterIdMap = dict()
# create the singleton root window
if (App._theRoot is None):
App._theRoot = Tk()
App._theRoot.createcommand('exit', lambda: '') # when user enters cmd-q, ignore here (handled in keyPressed)
App._theRoot.protocol('WM_DELETE_WINDOW', lambda: App._theRoot.app.quit()) # when user presses 'x' in title bar
App._theRoot.bind("<Button-1>", lambda event: App._theRoot.app._mousePressedWrapper(event))
App._theRoot.bind("<B1-ButtonRelease>", lambda event: App._theRoot.app._mouseReleasedWrapper(event))
App._theRoot.bind("<KeyPress>", lambda event: App._theRoot.app._keyPressedWrapper(event))
App._theRoot.bind("<KeyRelease>", lambda event: App._theRoot.app._keyReleasedWrapper(event))
App._theRoot.bind("<Configure>", lambda event: App._theRoot.app._sizeChangedWrapper(event))
else:
App._theRoot.canvas.destroy()
app._root = root = App._theRoot # singleton root!
root.app = app
root.geometry(f'{app.width}x{app.height}+{app.winx}+{app.winy}')
app.updateTitle()
# create the canvas
root.canvas = app._canvas = WrappedCanvas(app)
app._canvas.pack(fill=BOTH, expand=YES)
# initialize, start the timer, and launch the app
app._running = True
app._paused = False
app._ignoredFields = set(app.__dict__.keys()) | {'_ignoredFields'}
app._appStartedWrapper()
app._timerFiredWrapper()
app._mouseMotionWrapper()
app._showRootWindow()
root.mainloop()
app._hideRootWindow()
app._running = False
for afterId in app._afterIdMap: app._root.after_cancel(app._afterIdMap[afterId])
app._afterIdMap.clear() # for safety
app.appStopped()
print(app.getQuitMessage())
####################################
# TopLevelApp:
# (with top-level functions not subclassses and methods)
####################################
class TopLevelApp(App):
_apps = dict() # maps fnPrefix to app
def __init__(app, fnPrefix='', **kwargs):
if (fnPrefix in TopLevelApp._apps):
print(f'Quitting previous version of {fnPrefix} TopLevelApp.')
TopLevelApp._apps[fnPrefix].quit()
if ((fnPrefix != '') and ('title' not in kwargs)):
kwargs['title'] = f"TopLevelApp '{fnPrefix}'"
TopLevelApp._apps[fnPrefix] = app
app._fnPrefix = fnPrefix
app._callersGlobals = inspect.stack()[1][0].f_globals
app.mode = None
super().__init__(**kwargs)
def _callFn(app, fn, *args):
isAppStopped = fn == 'appStopped'
isUsingMode = (app.mode != None) and (app.mode != '')
if isUsingMode:
fn = app.mode + '_' + fn
fn = app._fnPrefix + fn
if (fn in app._callersGlobals): app._callersGlobals[fn](*args)
if (isAppStopped and isUsingMode):
# call the non-mode appStopped if there is one
fn = app._fnPrefix + 'appStopped'
if (fn in app._callersGlobals): app._callersGlobals[fn](*args)
def redrawAll(app, canvas): app._callFn('redrawAll', app, canvas)
def appStarted(app): app._callFn('appStarted', app)
def appStopped(app): app._callFn('appStopped', app)
def keyPressed(app, event): app._callFn('keyPressed', app, event)
def keyReleased(app, event): app._callFn('keyReleased', app, event)
def mousePressed(app, event): app._callFn('mousePressed', app, event)
def mouseReleased(app, event): app._callFn('mouseReleased', app, event)
def mouseMoved(app, event): app._callFn('mouseMoved', app, event)
def mouseDragged(app, event): app._callFn('mouseDragged', app, event)
def timerFired(app): app._callFn('timerFired', app)
def sizeChanged(app): app._callFn('sizeChanged', app)
####################################
# ModalApp + Mode:
####################################
'''
# For now, only include modes in top-level apps (see above).
class Mode(object):
def __repr__(self): return f'<{self.__class__.__name__} object>'
class ModalApp(App):
def __init__(app, *args, **kwargs):
app._mode = None
super().__init__(*args, **kwargs)
def setMode(app, mode):
if (not isinstance(mode, Mode)):
raise Exception('mode must be an instance of Mode')
app._mode = mode
def _callFn(app, fn, *args):
if (app._mode == None):
raise Exception('ModalApp must have a mode (use app.setMode())')
mode = app._mode
# method = getattr(mode, fn, None)
method = mode.__class__.__dict__.get(fn) # get method as fn
if (method != None):
method(*args)
def redrawAll(app, canvas): app._callFn('redrawAll', app, canvas)
#def appStarted(app): app._callFn('appStarted', app)
#def appStopped(app): app._callFn('appStopped', app)
def keyPressed(app, event): app._callFn('keyPressed', app, event)
def keyReleased(app, event): app._callFn('keyReleased', app, event)
def mousePressed(app, event): app._callFn('mousePressed', app, event)
def mouseReleased(app, event): app._callFn('mouseReleased', app, event)
def mouseMoved(app, event): app._callFn('mouseMoved', app, event)
def mouseDragged(app, event): app._callFn('mouseDragged', app, event)
def timerFired(app): app._callFn('timerFired', app)
def sizeChanged(app): app._callFn('sizeChanged', app)
'''
####################################
# runApp()
####################################
'''
def showGraphics(drawFn, **kwargs):
class GraphicsApp(App):
def __init__(app, **kwargs):
if ('title' not in kwargs):
kwargs['title'] = drawFn.__name__
super().__init__(**kwargs)
def redrawAll(app, canvas):
drawFn(app, canvas)
app = GraphicsApp(**kwargs)
'''
runApp = TopLevelApp
print(f'Loaded cmu_112_graphics version {App.version} (last updated {App.lastUpdated})')
if (__name__ == '__main__'):
try: import cmu_112_graphics_tests
except: pass