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()

tkinter: EnhancedText (last edited 2010-07-26 11:59:11 by localhost)