Skip to content

Commit 5fc5860

Browse files
authored
Merge pull request #72 from tmontes/window-bind-events
Window bind events implementation.
2 parents c431fdd + 3ac77a3 commit 5fc5860

File tree

4 files changed

+349
-2
lines changed

4 files changed

+349
-2
lines changed

README.rst

+4-2
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ Thanks
2929

3030
.. marker-start-thanks-dont-remove
3131
32-
.. todo::
32+
* To the creators and maintainers of `Tcl/Tk <https://www.tcl.tk/>`_, a programming language I used for a while in the late 1990's, in particular for the seriously under-appreciated `Canvas widget <https://www.tcl.tk/man/tcl8.6/TkCmd/canvas.htm>`_ which is amazingly powerful. Python embeds the Tcl/Tk interpreter in the form of the `tkinter <https://docs.python.org/3/library/tkinter.html>`_ Standard Library module. Long live Tcl/Tk and `tkinter`!
33+
34+
* To whoever `Quarks <https://www.daniweb.com/members/228139/quarks>`_ is, for sharing an effective technique for debouncing Tk `KeyPress` / `KeyRelease` events in towards the end of `this thread <https://www.daniweb.com/programming/software-development/threads/70746/keypress-event-with-holding-down-the-key>`_.
3335

34-
Write thanks section, if applicable.
36+
* To `Terry Pratchet <https://en.wikipedia.org/wiki/Terry_Pratchett>`_, for his `Discworld <https://en.wikipedia.org/wiki/Discworld>`_ novels and, in particular, for `Great A'Tuin <https://en.wikipedia.org/wiki/Discworld_%28world%29#Great_A%27Tuin>`_, the *"Giant Star Turtle (...) who travels through the Discworld universe's space, carrying four giant elephants (...) who in turn carry the Discworld."*, which has come to my mind often while creating this.
3537

3638
.. marker-end-thanks-dont-remove
3739

src/aturtle/window.py

+115
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ def __init__(self, width=320, height=320, x=None, y=None,
5353
self._tk_window = tk_window
5454
self.canvas = canvas
5555

56+
# Event bindings.
57+
self._binds = {}
58+
59+
# Direct key support.
60+
self._direct_key_callbacks = {}
61+
self._direct_key_after_ids = {}
62+
5663

5764
@property
5865
def x(self):
@@ -121,6 +128,114 @@ def _resize_handler(self, event):
121128
self._y_scroll = new_y_scroll
122129

123130

131+
def bind(self, sequence, cb):
132+
"""
133+
Binds Tk event `sequence` to the `cb` callable, such that `cb` is
134+
called with a single event argument, when the event is triggered.
135+
136+
Existing bindings for the same `sequence` are replaced.
137+
"""
138+
self._binds[sequence] = self._tk_window.bind(sequence, cb)
139+
140+
141+
def unbind(self, sequence=None):
142+
"""
143+
Unbinds the Tk event `sequence`. If `sequence` is None, all events
144+
previously bound via `bind` are unbound.
145+
146+
Raises ValueError if the `sequence` isn't bound.
147+
"""
148+
if sequence is None:
149+
sequences = list(self._binds)
150+
for sequence in sequences:
151+
self.unbind(sequence)
152+
elif sequence in self._binds:
153+
self._tk_window.unbind(sequence, self._binds[sequence])
154+
del self._binds[sequence]
155+
else:
156+
raise ValueError(f'Unknown bound sequence: {sequence!r}.')
157+
158+
159+
def bind_direct_key(self, keysym, press_cb=None, release_cb=None):
160+
"""
161+
Binds "direct-key" events such that when the key defined by `keysym` is
162+
pressed the `press_cb` is called with a single event argument, and when
163+
that same key is released the `release_cb` is called, again with a
164+
single event argument.
165+
166+
The difference between bind_direct_key('a') and bind('<KeyPress-a>') is
167+
that the former ensures that pressing and holding the A key triggers
168+
the `press_cb` just once, while the latter triggers the associated
169+
callback multiple times depending on OS level key delay and repeat.
170+
"""
171+
if not press_cb and not release_cb:
172+
raise ValueError(f'Missing event handler argument.')
173+
174+
self.bind(f'<KeyPress-{keysym}>', self._direct_key_press)
175+
self.bind(f'<KeyRelease-{keysym}>', self._direct_key_release)
176+
177+
self._direct_key_callbacks[keysym] = (press_cb, release_cb)
178+
179+
180+
def _direct_key_press(self, event):
181+
"""
182+
Handles "direct-key" KeyPress events.
183+
May be called multiple times while a key is held down.
184+
"""
185+
keysym = event.keysym
186+
if keysym in self._direct_key_after_ids:
187+
# 2nd and subsequent KeyPress events: cancel idle handler.
188+
self._tk_window.after_cancel(self._direct_key_after_ids[keysym])
189+
del self._direct_key_after_ids[keysym]
190+
else:
191+
# First KeyPress event: trigger press_cb, if any.
192+
press_cb, _release_cb = self._direct_key_callbacks[keysym]
193+
if press_cb:
194+
press_cb(event)
195+
196+
197+
def _direct_key_release(self, event):
198+
"""
199+
Handles "direct-key" KeyRelease events.
200+
May be called multiple times while a key is held down.
201+
"""
202+
keysym = event.keysym
203+
# Idle handler will only run when key is raised.
204+
after_id = self._tk_window.after_idle(self._direct_key_idle, event)
205+
self._direct_key_after_ids[keysym] = after_id
206+
207+
208+
def _direct_key_idle(self, event):
209+
"""
210+
Idle handler, called when keys are raised.
211+
"""
212+
keysym = event.keysym
213+
del self._direct_key_after_ids[keysym]
214+
# Trigger release_cb, if any.
215+
_press_cb, release_cb = self._direct_key_callbacks[keysym]
216+
if release_cb:
217+
release_cb(event)
218+
219+
220+
def unbind_direct_key(self, keysym=None):
221+
"""
222+
Unbinds the "direct-key" events associated with `keysym`. If `keysym`
223+
is None, all "direct-key" events are unbound.
224+
225+
Raises ValueError if `keysym` isn't "direct-key" bound.
226+
"""
227+
if keysym is None:
228+
keysyms = list(self._direct_key_callbacks)
229+
for keysym in keysyms:
230+
self.unbind_direct_key(keysym)
231+
elif keysym in self._direct_key_callbacks:
232+
self.unbind(f'<KeyPress-{keysym}>')
233+
self.unbind(f'<KeyRelease-{keysym}>')
234+
del self._direct_key_callbacks[keysym]
235+
else:
236+
raise ValueError(f'Unknown bound direct key: {keysym!r}.')
237+
238+
124239
def close(self):
125240

126241
is_root = self._tk_window is Window._windows[0]._tk_window

tests/fake_tkinter.py

+3
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ def __init__(self, screen_width, screen_height):
4848
self._w = None
4949
self._h = None
5050
self.bind = mock.Mock()
51+
self.unbind = mock.Mock()
52+
self.after_idle = mock.Mock()
53+
self.after_cancel = mock.Mock()
5154
self.update = mock.Mock()
5255
self.destroy = mock.Mock()
5356

tests/test_window.py

+227
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,233 @@ def test_no_canvas_after_close_all(self):
294294
self.assertIsNone(w.canvas)
295295

296296

297+
298+
class TestWindowEventHandling(FakedTkinterTestCase):
299+
300+
def setUp(self):
301+
302+
super().setUp()
303+
self.w = window.Window()
304+
305+
306+
def test_bind_calls_tk_window_bind(self):
307+
308+
handler = lambda e: None
309+
self.w.bind('<KeyPress-a>', handler)
310+
311+
self.w._tk_window.bind.assert_called_with('<KeyPress-a>', handler)
312+
313+
314+
def test_bind_unbind_calls_tk_window_bind_unbind(self):
315+
316+
handler = lambda e: None
317+
self.w.bind('<KeyPress-a>', handler)
318+
self.w.unbind('<KeyPress-a>')
319+
320+
self.w._tk_window.bind.assert_called_with('<KeyPress-a>', handler)
321+
self.w._tk_window.unbind.assert_called_with('<KeyPress-a>', mock.ANY)
322+
323+
324+
def test_unbind_unbound_raises_ValueError(self):
325+
326+
with self.assertRaises(ValueError):
327+
self.w.unbind('<KeyPress-a>')
328+
329+
330+
def test_unbind_default_works_with_no_bindings(self):
331+
332+
self.w.unbind()
333+
self.w._tk_window.unbind.assert_not_called()
334+
335+
336+
def test_unbind_default_unbinds_all_bindings(self):
337+
338+
self.w.bind('<KeyPress-a>', lambda e: None)
339+
self.w.bind('<KeyPress-b>', lambda e: None)
340+
341+
self.w.unbind()
342+
unbind_call_args = self.w._tk_window.unbind.call_args_list
343+
self.assertEqual(len(unbind_call_args), 2, 'unbind call count')
344+
345+
346+
def test_bind_direct_key_with_no_cbs_raises_ValueError(self):
347+
348+
with self.assertRaises(ValueError):
349+
self.w.bind_direct_key('a')
350+
351+
352+
def test_bind_direct_key_with_press_cb_calls_window_bind_twice(self):
353+
354+
# Ignore any window setup bind calls that may have taken place.
355+
self.w._tk_window.bind.reset_mock()
356+
357+
press_cb = lambda e: None
358+
self.w.bind_direct_key('x', press_cb)
359+
360+
bind_call_args = self.w._tk_window.bind.call_args_list
361+
self.assertEqual(len(bind_call_args), 2, 'unbind call count')
362+
363+
first_call, second_call = bind_call_args
364+
self.assertEqual(first_call, mock.call('<KeyPress-x>', mock.ANY))
365+
self.assertEqual(second_call, mock.call('<KeyRelease-x>', mock.ANY))
366+
367+
368+
def test_bind_direct_key_with_release_cb_calls_window_bind_twice(self):
369+
370+
# Ignore any window setup bind calls that may have taken place.
371+
self.w._tk_window.bind.reset_mock()
372+
373+
release_cb = lambda e: None
374+
self.w.bind_direct_key('y', None, release_cb)
375+
376+
bind_call_args = self.w._tk_window.bind.call_args_list
377+
self.assertEqual(len(bind_call_args), 2, 'unbind call count')
378+
379+
first_call, second_call = bind_call_args
380+
self.assertEqual(first_call, mock.call('<KeyPress-y>', mock.ANY))
381+
self.assertEqual(second_call, mock.call('<KeyRelease-y>', mock.ANY))
382+
383+
384+
def test_bind_direct_key_with_both_cbs_calls_window_bind_twice(self):
385+
386+
# Ignore any window setup bind calls that may have taken place.
387+
self.w._tk_window.bind.reset_mock()
388+
389+
press_cb = lambda e: None
390+
release_cb = lambda e: None
391+
self.w.bind_direct_key('z', press_cb, release_cb)
392+
393+
bind_call_args = self.w._tk_window.bind.call_args_list
394+
self.assertEqual(len(bind_call_args), 2, 'bind call count')
395+
396+
first_call, second_call = bind_call_args
397+
self.assertEqual(first_call, mock.call('<KeyPress-z>', mock.ANY))
398+
self.assertEqual(second_call, mock.call('<KeyRelease-z>', mock.ANY))
399+
400+
401+
def test_bind_unbind_direct_key_calls_window_bind_unbind_twice(self):
402+
403+
# Ignore any window setup bind calls that may have taken place.
404+
self.w._tk_window.bind.reset_mock()
405+
406+
press_cb = lambda e: None
407+
release_cb = lambda e: None
408+
self.w.bind_direct_key('a', press_cb, release_cb)
409+
self.w.unbind_direct_key('a')
410+
411+
bind_call_args = self.w._tk_window.bind.call_args_list
412+
self.assertEqual(len(bind_call_args), 2, 'bind call count')
413+
414+
first_call, second_call = bind_call_args
415+
self.assertEqual(first_call, mock.call('<KeyPress-a>', mock.ANY))
416+
self.assertEqual(second_call, mock.call('<KeyRelease-a>', mock.ANY))
417+
418+
unbind_call_args = self.w._tk_window.unbind.call_args_list
419+
self.assertEqual(len(unbind_call_args), 2, 'unbind call count')
420+
421+
first_call, second_call = unbind_call_args
422+
self.assertEqual(first_call, mock.call('<KeyPress-a>', mock.ANY))
423+
self.assertEqual(second_call, mock.call('<KeyRelease-a>', mock.ANY))
424+
425+
426+
def test_unbind_direct_key_unknown_raises_ValueError(self):
427+
428+
with self.assertRaises(ValueError):
429+
self.w.unbind_direct_key('b')
430+
431+
432+
def test_unbind_default_works_with_no_bindings(self):
433+
434+
self.w.unbind_direct_key()
435+
self.w._tk_window.unbind.assert_not_called()
436+
437+
438+
def test_unbind_direct_key_default_unbinds_all_direct_keys(self):
439+
440+
press_cb = lambda e: None
441+
release_cb = lambda e: None
442+
self.w.bind_direct_key('a', press_cb, release_cb)
443+
self.w.bind_direct_key('b', press_cb, release_cb)
444+
445+
self.w.unbind_direct_key()
446+
447+
# Expect 4 unbind calls: KeyPress/KeyRelease for 2 keys.
448+
unbind_call_args = self.w._tk_window.unbind.call_args_list
449+
self.assertEqual(len(unbind_call_args), 4, 'unbind call count')
450+
451+
452+
def _event(self, keysym):
453+
454+
event = mock.Mock()
455+
event.keysym = keysym
456+
return event
457+
458+
459+
def test_bind_direct_key_press_and_release(self):
460+
461+
press_cb = mock.Mock()
462+
release_cb = mock.Mock()
463+
self.w.bind_direct_key('a', press_cb, release_cb)
464+
465+
# KeyPress event.
466+
event = self._event('a')
467+
self.w._direct_key_press(event)
468+
469+
# Press callback called.
470+
press_cb.assert_called_once()
471+
press_cb.assert_called_with(event)
472+
release_cb.assert_not_called()
473+
474+
press_cb.reset_mock()
475+
476+
# KeyRelease event. Not held down so _direct_key_idle called.
477+
self.w._direct_key_release(event)
478+
self.w._direct_key_idle(event)
479+
480+
# Release callback called.
481+
press_cb.assert_not_called()
482+
release_cb.assert_called_once()
483+
release_cb.assert_called_with(event)
484+
485+
486+
def test_bind_direct_key_press_hold_and_release(self):
487+
488+
press_cb = mock.Mock()
489+
release_cb = mock.Mock()
490+
self.w.bind_direct_key('a', press_cb, release_cb)
491+
492+
# KeyPress event.
493+
event = self._event('a')
494+
self.w._direct_key_press(event)
495+
496+
# Press callback called.
497+
press_cb.assert_called_once()
498+
press_cb.assert_called_with(event)
499+
release_cb.assert_not_called()
500+
501+
press_cb.reset_mock()
502+
503+
# Holding the key triggers KeyPress/KeyReleases repeatedly.
504+
# But never idle, so _direct_key_idle never called.
505+
for _ in range(10):
506+
self.w._direct_key_release(event)
507+
self.w._direct_key_press(event)
508+
509+
# No callbacks while key held down.
510+
press_cb.assert_not_called()
511+
release_cb.assert_not_called()
512+
513+
# Last KeyRelease event. Not held down so _direct_key_idle called.
514+
self.w._direct_key_release(event)
515+
self.w._direct_key_idle(event)
516+
517+
# Release callback called.
518+
press_cb.assert_not_called()
519+
release_cb.assert_called_once()
520+
release_cb.assert_called_with(event)
521+
522+
523+
297524
class TestMultipleWindows(FakedTkinterTestCase):
298525

299526
def test_create_two_windows(self):

0 commit comments

Comments
 (0)