Abstract
In my continuing efforts to make the Text widget more useable I have created EnhancedText. This widget is a subclass of Text which reimplements all of the event handling of the text widget to correct for some minor problems. This widget is intended to work as a drop-in replacement for Text.
The Problem
The Text widget as supplied by Tkinter is an amazingly powerful widget. Yet once you begin to use it, you discover that it has some quirks. These quirks are minor in the sense that they aren't bugs, your data won't be corrupted. However, they are major enough to actually make you reconsider using the widget altogether. These involve navigation on the part of the user. Much like a word processor, the text widget is paragraph oriented rather than line oriented. This means that the widget will handle such things as line wrapping and justification. However, if you attempt to move the carrot (the editing cursor) around the contents of the widget using the arrow keys, something very annoying will quickly become apparent. When moving up or down, the carrot jumps from paragraph to paragraph rather than display-line to display-line. This can be a problem with long paragraphs because it can take some time to navigate to a desired location without the use of a mouse. In this same theme, the 'Home' and 'End' keys don't move the carrot to the beginning and end of a display-line but the beginning and end of a paragraph. EnhancedText is an attempt to correct this.
This fix applies to Tkinter for Tk 8.4 and before. To my knowledge these problems are corrected in Tk 8.5.
Enhanced API
EnhancedText is a subclass of Text and all superclass methods are safe to use unchanged. EnhancedText also introduces four new methods. t.dline_prev( index ), t.dline_next( index ), t.dline_start( index ) and t.dline_end( index ). Each returns an index to a location relative to the index provided. _prev returns the index one display-line up from the argument _next returns the index one display-line down. _start returns the index of the first character on the display-line of the argument _end returns the index of the last character on the display line of the argument. The _prev and _next methods include an additional useThisX argument. If provided, the returned index with be based upon the X coordinate provided (pixels from the left edge). If omitted, the x returned from calling bbox on the index argument will be used.
The remaining methods in the class are intended to support the implementation though should be usable with little fear of corrupting the object's state.
Known Issues
* Pressing 'End' or calling dline_end will return the index of the last character on a display line. Consequently, the carror, being repositioned using the 'End' key is actually placed before the last character of the display line. For some, currently unknown reason, setting EnhancedText's wrap option to WORD produces the expected behavior, the carrot moves after the last character on the display line.
* This widget has only been tested on Windows XP.
Acknowledgements
* The Controller class and bind decorator function come directly from Fredrik Lundh's Tkinter Controller Module. Thanks Fredrik!
Code
1 import Tkinter
2
3 PREFIX = "tkController"
4
5 class Controller(object):
6 def __init__(self, master=None):
7 if master is None:
8 master = Tkinter._default_root
9 assert master is not None
10 self.tag = PREFIX + str(id(self))
11 def bind(event, handler):
12 master.bind_class(self.tag, event, handler)
13 self.create(bind)
14
15 def install(self, widget):
16 widgetclass = widget.winfo_class()
17 # remove widget class bindings and other controllers
18 tags = [self.tag]
19 for tag in widget.bindtags():
20 if tag != widgetclass and tag[:len(PREFIX)] != PREFIX:
21 tags.append(tag)
22 widget.bindtags(tuple(tags))
23
24 def create(self, handle):
25 # override if necessary
26 # the default implementation looks for decorated methods
27 for key in dir(self):
28 method = getattr(self, key)
29 if hasattr(method, "tkevent") and callable(method):
30 for eventSequence in method.tkevent:
31 handle(eventSequence, method)
32
33 def bind(*events):
34 def decorator(func):
35 func.tkevent = events
36 return func
37 return decorator
38
39 class KBController( Controller ):
40 '''This class watches keypress events and records presses and releases of
41 keys used for key combinations (shift, alt, control, etc.). When one of
42 these keys is held down, this class's corresponding state variable is set
43 to True.'''
44 def __init__( self ):
45 Controller.__init__( self )
46 self._alt = False
47 self._control = False
48 self._lock = False
49 self._meta = False
50 self._shift = False
51
52 @bind("<KeyPress>") # type 2
53 def KeyPress(self, event):
54 if event.keysym in ('Alt_L', 'Alt_R'):
55 self._alt = True
56 elif event.keysym in ('Control_L', 'Control_R'):
57 self._control = True
58 elif event.keysym == 'Caps_Lock':
59 self._lock = True
60 elif event.keysym in ('Meta_L', 'Meta_R'):
61 self._meta = True
62 elif event.keysym in ( 'Shift_L', 'Shift_R'):
63 self._shift = True
64 elif (len(event.char) > 0) and (32 <= ord(event.char) <= 127):
65 self.onTypedCharacterKey( event )
66 else:
67 self.onTypedSpecialKey( event )
68
69 @bind("<KeyRelease>") # type 3
70 def KeyRelease( self, event ):
71 if event.keysym in ('Alt_L', 'Alt_R'):
72 self._alt = False
73 elif event.keysym in ('Control_L', 'Control_R'):
74 self._control = False
75 elif event.keysym == 'Caps_Lock':
76 self._lock = False
77 elif event.keysym in ('Meta_L', 'Meta_R'):
78 self._meta = False
79 elif event.keysym in ('Shift_L', 'Shift_R'):
80 self._shift = False
81
82 def onTypedCharacterKey( self, event ):
83 '''Override to handle typing of any printable keyboard character,
84 The typed character is in event.char (which accounts for shift).'''
85 pass
86
87 def onTypedSpecialKey( self, event ):
88 '''Override to handle typing of any special characters (tab, \n, backspace,
89 delete, home, prior, insert, etc. And any key combinations involving
90 Alt or Control.'''
91 pass
92
93
94 class EnhancedTextController( KBController ):
95 def __init__( self ):
96 KBController.__init__( self )
97 self._insert_x_pos = None
98
99 def onTypedCharacterKey( self, event ):
100 try:
101 event.widget.sel_delete( )
102 except:
103 pass
104
105 event.widget.insert( 'insert', event.char )
106 event.widget.sel_clear( )
107
108 self._insert_x_pos = event.widget.bbox( 'insert' )[0]
109
110 def moveCarrot( self, event ):
111 widget = event.widget
112
113 if self._shift:
114 if not widget.sel_isAnchorSet():
115 widget.sel_setAnchor( 'insert' )
116 else:
117 widget.sel_clear( )
118
119 if event.keysym in ( 'Home', 'KP_Home' ):
120 if self._control:
121 # Move to beginning of text
122 widget.mark_set( 'insert', '1.0' )
123 else:
124 # Move to front of line
125 widget.mark_set( 'insert', widget.dline_start('insert') )
126 elif event.keysym in ( 'End', 'KP_End' ):
127 if self._control:
128 # Move to end of text
129 widget.mark_set( 'insert', 'end' )
130 else:
131 # Move to end of line
132 widget.mark_set( 'insert', widget.dline_end('insert') )
133 elif event.keysym == 'Right':
134 if self._control:
135 # Move by word
136 currentPos = widget.index( 'insert' )
137 maxPos = widget.index( 'end wordstart' )
138
139 if currentPos == maxPos:
140 return
141
142 offset = 1
143 while widget.compare( currentPos, '==', widget.index('insert') ):
144 widget.mark_set( 'insert', 'insert wordend +%dc wordstart' % offset )
145 offset += 1
146 else:
147 # Move by character
148 widget.mark_set( 'insert', 'insert +1 chars' )
149 elif event.keysym == 'Left':
150 if self._control:
151 # Move by word
152 currentPos = widget.index( 'insert' )
153 minPos = widget.index( '1.0 wordstart' )
154
155 if currentPos == minPos:
156 return
157
158 offset = 2
159 widget.mark_set( 'insert', 'insert wordstart' )
160 while widget.compare( currentPos, '==', widget.index('insert') ):
161 widget.mark_set( 'insert', 'insert -%dc wordstart' % offset )
162 offset += 1
163 else:
164 # Move by character
165 widget.mark_set( 'insert', 'insert -1 chars' )
166 elif event.keysym == 'Down':
167 if self._control:
168 # Move by Paragraph
169 widget.mark_set( 'insert', 'insert +1 lines' )
170 else:
171 # Move by line
172 widget.mark_set( 'insert', widget.dline_next( 'insert', useThisX=self._insert_x_pos ) )
173 elif event.keysym == 'Up':
174 if self._control:
175 # Move by Paragraph
176 widget.mark_set( 'insert', 'insert -1 lines' )
177 pass
178 else:
179 # Move by line
180 widget.mark_set( 'insert', widget.dline_prev( 'insert', useThisX=self._insert_x_pos ) )
181 elif event.keysym == 'Prior':
182 if self._control:
183 pass
184 else:
185 # Move by page
186 event.widget.yview_scroll( -1, 'pages' )
187 elif event.keysym == 'Next':
188 if self._control:
189 pass
190 else:
191 # Move by page
192 event.widget.yview_scroll( 1, 'pages' )
193
194 widget.see( 'insert' )
195
196 if event.keysym not in ('Up','Down'):
197 self._insert_x_pos = event.widget.bbox( 'insert' )[0]
198
199 def typeSpecial( self, event ):
200 widget = event.widget
201
202 if event.keysym in ( 'Return', 'Enter', 'KP_Enter' ):
203 try:
204 widget.sel_delete( )
205 finally:
206 widget.insert( 'insert', '\n' )
207 elif event.keysym == 'Tab':
208 widget.insert( 'insert', '\t' )
209 elif event.keysym == 'BackSpace':
210 try:
211 widget.delete( 'sel.first', 'sel.last' )
212 except:
213 widget.delete( 'insert -1 chars', 'insert' )
214 elif event.keysym in ( 'Delete', 'KP_Delete' ):
215 try:
216 widget.delete( 'sel.first', 'sel.last' )
217 except:
218 widget.delete( 'insert', 'insert +1 chars' )
219
220 widget.sel_clear()
221 self._insert_x_pos = event.widget.bbox( 'insert' )[0]
222
223 def onTypedSpecialKey( self, event ):
224 widget = event.widget
225
226 if event.keysym in ( 'Return','Enter','KP_Enter','Tab','BackSpace','Delete','Insert' ):
227 self.typeSpecial( event )
228
229 elif event.keysym in ( 'Up', 'Down', 'Left', 'Right', 'Home', 'End', 'Prior', 'Next'
230 'KP_Up', 'KP_Down', 'KP_Left', 'KP_Right', 'KP_Home', 'KP_End', 'KP_Prior', 'KP_Next' ):
231 self.moveCarrot( event )
232
233 elif self._control:
234 if event.keysym == 'a':
235 # select all
236 self._selectionAnchor = '1.0'
237 widget.mark_set( 'insert', 'end' )
238 elif event.keysym == 'c':
239 # copy
240 try:
241 widget.clipboard_append( widget.get( 'sel.first', 'sel.last' ) )
242 except:
243 pass
244 elif event.keysym == 'r':
245 # Redo
246 try:
247 widget.edit_redo( )
248 except:
249 pass
250 widget.sel_clear( )
251 elif event.keysym == 'v':
252 # paste
253 try:
254 widget.mark_set( 'insert', 'sel.first' )
255 widget.delete( 'sel.first', 'sel.last' )
256 except:
257 pass
258
259 widget.insert( 'insert', widget.clipboard_get( ) )
260 self.sel_clear( )
261 elif event.keysym == 'x':
262 # cut
263 try:
264 widget.clipboard_append( widget.get( 'sel.first', 'sel.last' ) )
265 widget.delete( 'sel.first', 'sel.last' )
266 widget.ins_updateTags( )
267 except:
268 pass
269 widget.sel_clear( )
270 elif event.keysym == 'z':
271 # Undo
272 try:
273 widget.edit_undo( )
274 except:
275 pass
276 widget.sel_clear( )
277
278 self._insert_x_pos = event.widget.bbox( 'insert' )[0]
279
280 @bind( '<ButtonPress-1>' )
281 def click( self, event ):
282 event.widget.focus_set( )
283
284 if not self._shift and not self._control:
285 event.widget.sel_clear( )
286 event.widget.sel_setAnchor( 'current' )
287
288 self._insert_x_pos = event.x
289
290 @bind( '<B1-Motion>', '<Shift-Button1-Motion>' )
291 def dragSelection( self, event ):
292 widget = event.widget
293
294 if event.y < 0:
295 widget.yview_scroll( -1, 'units' )
296 elif event.y >= widget.winfo_height():
297 widget.yview_scroll( 1, 'units' )
298
299 if not widget.sel_isAnchorSet( ):
300 widget.self_setAnchor( '@%d,%d' % (event.x+2, event.y) )
301
302 widget.mark_set( 'insert', '@%d,%d' % (event.x+2, event.y) )
303 self._insert_x_pos = event.x
304
305 @bind( '<ButtonRelease-1>' )
306 def moveCarrot_deselect( self, event ):
307 widget = event.widget
308
309 widget.grab_release()
310 widget.mark_set( 'insert', 'current' )
311 self._insert_x_pos = event.x
312
313 @bind( '<Double-ButtonPress-1>' )
314 def selectWord( self, event ):
315 event.widget.sel_setAnchor( 'insert wordstart' )
316 event.widget.mark_set( 'insert', 'insert wordend' )
317 self._insert_x_pos = event.x
318
319 @bind( '<Triple-ButtonPress-1>' )
320 def selectLine( self, event ):
321 event.widget.sel_setAnchor( 'insert linestart' )
322 event.widget.mark_set( 'insert', 'insert lineend' )
323 self._insert_x_pos = event.x
324
325 @bind( '<Button1-Leave>' )
326 def scrollView( self, event ):
327 widget = event.widget
328
329 if event.y < 0:
330 widget.yview_scroll( -1, 'units' )
331 elif event.y >= widget.winfo_height():
332 widget.yview_scroll( 1, 'units' )
333
334 widget.grab_set( )
335
336 @bind( '<MouseWheel>' )
337 def wheelScroll( self, event ):
338 widget = event.widget
339
340 if event.delta < 0:
341 widget.yview_scroll( 1, 'units' )
342 else:
343 widget.yview_scroll( -1, 'units' )
344
345
346 class EnhancedText( Tkinter.Text ):
347 def __init__( self, parent, **options ):
348 Tkinter.Text.__init__( self, parent, **options )
349
350 controller = EnhancedTextController( )
351 controller.install( self )
352
353 # Selection Operations
354 def sel_clear( self ):
355 try:
356 self.tag_remove( 'sel', '1.0', 'end' )
357 except:
358 pass
359
360 try:
361 self.mark_unset( 'sel.anchor', 'sel.first', 'sel.last' )
362 except:
363 pass
364
365 def sel_setAnchor( self, index ):
366 self.mark_set( 'sel.anchor', index )
367
368 def sel_isAnchorSet( self ):
369 try:
370 self.index( 'sel.anchor' )
371 return True
372 except:
373 return False
374
375 def sel_isSelection( self ):
376 try:
377 self.index( 'sel.first' )
378 return True
379 except:
380 return False
381
382 def sel_update( self ):
383 if widget.compare( 'sel.anchor', '<', 'insert' ):
384 widget.mark_set( 'sel.first', 'sel.anchor' )
385 widget.mark_set( 'sel.last', 'insert' )
386 elif widget.compare( 'sel.anchor', '>', 'insert' ):
387 widget.mark_set( 'sel.first', 'insert' )
388 widget.mark_set( 'sel.last', 'sel.anchor' )
389 else:
390 return
391
392 widget.tag_remove( 'sel', '1.0', 'end' )
393 widget.tag_add( 'sel', 'sel.first', 'sel.last' )
394
395 def sel_delete( self ):
396 try:
397 Tkinter.Text.delete( self, 'sel.first', 'sel.last' )
398 except:
399 pass
400 self.sel_clear( )
401
402 # Display Lines
403 def dline_prev( self, index, useThisX=None ):
404 delta = 10
405 spacing = self.bbox( self.index( '@1,1' ) )[1]
406 minY = delta + spacing
407
408 x,y,width,height = self.bbox( index )
409
410 if useThisX:
411 x = useThisX
412
413 insertIndex = self.index( index )
414 newIndex = self.index( '@%d,%d' % ( x, y ) )
415 while (self.compare( newIndex, '==', insertIndex)) and (self.compare( newIndex, '>', '1.0' )):
416 if y < delta:
417 self.yview_scroll( -1, 'units' )
418 x,y,width,height = self.bbox( insertIndex )
419 y -= delta
420 newIndex = self.index( '@%d,%d' % ( x, y ) )
421
422 if self.index('@1,1') == '1.0':
423 lastIndexOnTopDisplayLine = self.dline_end( '1.0' )
424 if self.compare( newIndex, '<=', lastIndexOnTopDisplayLine ):
425 break
426
427 return newIndex
428
429 def dline_next( self, index, useThisX=None ):
430 delta = 10
431
432 x,y,width,height = self.bbox( index )
433 insertIndex = self.index( index )
434
435 if useThisX:
436 x = useThisX
437
438 newIndex = self.index( '@%d,%d' % ( x, y ) )
439 while (self.compare( newIndex, '==', insertIndex)) and (self.compare( newIndex, '<', 'end -1 chars' )):
440 y += delta
441 newIndex = self.index( '@%d,%d' % ( x, y ) )
442
443 return newIndex
444
445 def dline_start( self, index ):
446 x,y,width,height = self.bbox( index )
447 return self.index( '@%d,%d' % (0, y) )
448
449 def dline_end( self, index ):
450 x,y,width,height = self.bbox( index )
451 return self.index( '@%d,%d' % ( self.winfo_width(), y ) )
452
453 # Overloads
454 def mark_set( self, name, index ):
455 Tkinter.Text.mark_set( self, name, index )
456
457 if name == 'insert':
458 try:
459 if self.compare( 'sel.anchor', '<', 'insert' ):
460 self.mark_set( 'sel.first', 'sel.anchor' )
461 self.mark_set( 'sel.last', 'insert' )
462 elif self.compare( 'sel.anchor', '>', 'insert' ):
463 self.mark_set( 'sel.first', 'insert' )
464 self.mark_set( 'sel.last', 'sel.anchor' )
465 else:
466 return
467
468 self.tag_remove( 'sel', '1.0', 'end' )
469 self.tag_add( 'sel', 'sel.first', 'sel.last' )
470 except:
471 pass
472
473
474 sample = '''zero one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty.
475 twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six twenty-seven.
476 thirty-one thirty-two thirty-three thirty-four thirty-five thirty-six thirty-seven thirty-eight.'''
477
478 root = Tkinter.Tk()
479 text = EnhancedText( root )
480 text.pack()
481
482 text.insert( 'end', sample )
483
484 root.mainloop()
485