Home | History | Annotate | Download | only in lib-tk
      1 """Drag-and-drop support for Tkinter.
      2 
      3 This is very preliminary.  I currently only support dnd *within* one
      4 application, between different windows (or within the same window).
      5 
      6 I am trying to make this as generic as possible -- not dependent on
      7 the use of a particular widget or icon type, etc.  I also hope that
      8 this will work with Pmw.
      9 
     10 To enable an object to be dragged, you must create an event binding
     11 for it that starts the drag-and-drop process. Typically, you should
     12 bind <ButtonPress> to a callback function that you write. The function
     13 should call Tkdnd.dnd_start(source, event), where 'source' is the
     14 object to be dragged, and 'event' is the event that invoked the call
     15 (the argument to your callback function).  Even though this is a class
     16 instantiation, the returned instance should not be stored -- it will
     17 be kept alive automatically for the duration of the drag-and-drop.
     18 
     19 When a drag-and-drop is already in process for the Tk interpreter, the
     20 call is *ignored*; this normally averts starting multiple simultaneous
     21 dnd processes, e.g. because different button callbacks all
     22 dnd_start().
     23 
     24 The object is *not* necessarily a widget -- it can be any
     25 application-specific object that is meaningful to potential
     26 drag-and-drop targets.
     27 
     28 Potential drag-and-drop targets are discovered as follows.  Whenever
     29 the mouse moves, and at the start and end of a drag-and-drop move, the
     30 Tk widget directly under the mouse is inspected.  This is the target
     31 widget (not to be confused with the target object, yet to be
     32 determined).  If there is no target widget, there is no dnd target
     33 object.  If there is a target widget, and it has an attribute
     34 dnd_accept, this should be a function (or any callable object).  The
     35 function is called as dnd_accept(source, event), where 'source' is the
     36 object being dragged (the object passed to dnd_start() above), and
     37 'event' is the most recent event object (generally a <Motion> event;
     38 it can also be <ButtonPress> or <ButtonRelease>).  If the dnd_accept()
     39 function returns something other than None, this is the new dnd target
     40 object.  If dnd_accept() returns None, or if the target widget has no
     41 dnd_accept attribute, the target widget's parent is considered as the
     42 target widget, and the search for a target object is repeated from
     43 there.  If necessary, the search is repeated all the way up to the
     44 root widget.  If none of the target widgets can produce a target
     45 object, there is no target object (the target object is None).
     46 
     47 The target object thus produced, if any, is called the new target
     48 object.  It is compared with the old target object (or None, if there
     49 was no old target widget).  There are several cases ('source' is the
     50 source object, and 'event' is the most recent event object):
     51 
     52 - Both the old and new target objects are None.  Nothing happens.
     53 
     54 - The old and new target objects are the same object.  Its method
     55 dnd_motion(source, event) is called.
     56 
     57 - The old target object was None, and the new target object is not
     58 None.  The new target object's method dnd_enter(source, event) is
     59 called.
     60 
     61 - The new target object is None, and the old target object is not
     62 None.  The old target object's method dnd_leave(source, event) is
     63 called.
     64 
     65 - The old and new target objects differ and neither is None.  The old
     66 target object's method dnd_leave(source, event), and then the new
     67 target object's method dnd_enter(source, event) is called.
     68 
     69 Once this is done, the new target object replaces the old one, and the
     70 Tk mainloop proceeds.  The return value of the methods mentioned above
     71 is ignored; if they raise an exception, the normal exception handling
     72 mechanisms take over.
     73 
     74 The drag-and-drop processes can end in two ways: a final target object
     75 is selected, or no final target object is selected.  When a final
     76 target object is selected, it will always have been notified of the
     77 potential drop by a call to its dnd_enter() method, as described
     78 above, and possibly one or more calls to its dnd_motion() method; its
     79 dnd_leave() method has not been called since the last call to
     80 dnd_enter().  The target is notified of the drop by a call to its
     81 method dnd_commit(source, event).
     82 
     83 If no final target object is selected, and there was an old target
     84 object, its dnd_leave(source, event) method is called to complete the
     85 dnd sequence.
     86 
     87 Finally, the source object is notified that the drag-and-drop process
     88 is over, by a call to source.dnd_end(target, event), specifying either
     89 the selected target object, or None if no target object was selected.
     90 The source object can use this to implement the commit action; this is
     91 sometimes simpler than to do it in the target's dnd_commit().  The
     92 target's dnd_commit() method could then simply be aliased to
     93 dnd_leave().
     94 
     95 At any time during a dnd sequence, the application can cancel the
     96 sequence by calling the cancel() method on the object returned by
     97 dnd_start().  This will call dnd_leave() if a target is currently
     98 active; it will never call dnd_commit().
     99 
    100 """
    101 
    102 
    103 import Tkinter
    104 
    105 
    106 # The factory function
    107 
    108 def dnd_start(source, event):
    109     h = DndHandler(source, event)
    110     if h.root:
    111         return h
    112     else:
    113         return None
    114 
    115 
    116 # The class that does the work
    117 
    118 class DndHandler:
    119 
    120     root = None
    121 
    122     def __init__(self, source, event):
    123         if event.num > 5:
    124             return
    125         root = event.widget._root()
    126         try:
    127             root.__dnd
    128             return # Don't start recursive dnd
    129         except AttributeError:
    130             root.__dnd = self
    131             self.root = root
    132         self.source = source
    133         self.target = None
    134         self.initial_button = button = event.num
    135         self.initial_widget = widget = event.widget
    136         self.release_pattern = "<B%d-ButtonRelease-%d>" % (button, button)
    137         self.save_cursor = widget['cursor'] or ""
    138         widget.bind(self.release_pattern, self.on_release)
    139         widget.bind("<Motion>", self.on_motion)
    140         widget['cursor'] = "hand2"
    141 
    142     def __del__(self):
    143         root = self.root
    144         self.root = None
    145         if root:
    146             try:
    147                 del root.__dnd
    148             except AttributeError:
    149                 pass
    150 
    151     def on_motion(self, event):
    152         x, y = event.x_root, event.y_root
    153         target_widget = self.initial_widget.winfo_containing(x, y)
    154         source = self.source
    155         new_target = None
    156         while target_widget:
    157             try:
    158                 attr = target_widget.dnd_accept
    159             except AttributeError:
    160                 pass
    161             else:
    162                 new_target = attr(source, event)
    163                 if new_target:
    164                     break
    165             target_widget = target_widget.master
    166         old_target = self.target
    167         if old_target is new_target:
    168             if old_target:
    169                 old_target.dnd_motion(source, event)
    170         else:
    171             if old_target:
    172                 self.target = None
    173                 old_target.dnd_leave(source, event)
    174             if new_target:
    175                 new_target.dnd_enter(source, event)
    176                 self.target = new_target
    177 
    178     def on_release(self, event):
    179         self.finish(event, 1)
    180 
    181     def cancel(self, event=None):
    182         self.finish(event, 0)
    183 
    184     def finish(self, event, commit=0):
    185         target = self.target
    186         source = self.source
    187         widget = self.initial_widget
    188         root = self.root
    189         try:
    190             del root.__dnd
    191             self.initial_widget.unbind(self.release_pattern)
    192             self.initial_widget.unbind("<Motion>")
    193             widget['cursor'] = self.save_cursor
    194             self.target = self.source = self.initial_widget = self.root = None
    195             if target:
    196                 if commit:
    197                     target.dnd_commit(source, event)
    198                 else:
    199                     target.dnd_leave(source, event)
    200         finally:
    201             source.dnd_end(target, event)
    202 
    203 
    204 
    205 # ----------------------------------------------------------------------
    206 # The rest is here for testing and demonstration purposes only!
    207 
    208 class Icon:
    209 
    210     def __init__(self, name):
    211         self.name = name
    212         self.canvas = self.label = self.id = None
    213 
    214     def attach(self, canvas, x=10, y=10):
    215         if canvas is self.canvas:
    216             self.canvas.coords(self.id, x, y)
    217             return
    218         if self.canvas:
    219             self.detach()
    220         if not canvas:
    221             return
    222         label = Tkinter.Label(canvas, text=self.name,
    223                               borderwidth=2, relief="raised")
    224         id = canvas.create_window(x, y, window=label, anchor="nw")
    225         self.canvas = canvas
    226         self.label = label
    227         self.id = id
    228         label.bind("<ButtonPress>", self.press)
    229 
    230     def detach(self):
    231         canvas = self.canvas
    232         if not canvas:
    233             return
    234         id = self.id
    235         label = self.label
    236         self.canvas = self.label = self.id = None
    237         canvas.delete(id)
    238         label.destroy()
    239 
    240     def press(self, event):
    241         if dnd_start(self, event):
    242             # where the pointer is relative to the label widget:
    243             self.x_off = event.x
    244             self.y_off = event.y
    245             # where the widget is relative to the canvas:
    246             self.x_orig, self.y_orig = self.canvas.coords(self.id)
    247 
    248     def move(self, event):
    249         x, y = self.where(self.canvas, event)
    250         self.canvas.coords(self.id, x, y)
    251 
    252     def putback(self):
    253         self.canvas.coords(self.id, self.x_orig, self.y_orig)
    254 
    255     def where(self, canvas, event):
    256         # where the corner of the canvas is relative to the screen:
    257         x_org = canvas.winfo_rootx()
    258         y_org = canvas.winfo_rooty()
    259         # where the pointer is relative to the canvas widget:
    260         x = event.x_root - x_org
    261         y = event.y_root - y_org
    262         # compensate for initial pointer offset
    263         return x - self.x_off, y - self.y_off
    264 
    265     def dnd_end(self, target, event):
    266         pass
    267 
    268 class Tester:
    269 
    270     def __init__(self, root):
    271         self.top = Tkinter.Toplevel(root)
    272         self.canvas = Tkinter.Canvas(self.top, width=100, height=100)
    273         self.canvas.pack(fill="both", expand=1)
    274         self.canvas.dnd_accept = self.dnd_accept
    275 
    276     def dnd_accept(self, source, event):
    277         return self
    278 
    279     def dnd_enter(self, source, event):
    280         self.canvas.focus_set() # Show highlight border
    281         x, y = source.where(self.canvas, event)
    282         x1, y1, x2, y2 = source.canvas.bbox(source.id)
    283         dx, dy = x2-x1, y2-y1
    284         self.dndid = self.canvas.create_rectangle(x, y, x+dx, y+dy)
    285         self.dnd_motion(source, event)
    286 
    287     def dnd_motion(self, source, event):
    288         x, y = source.where(self.canvas, event)
    289         x1, y1, x2, y2 = self.canvas.bbox(self.dndid)
    290         self.canvas.move(self.dndid, x-x1, y-y1)
    291 
    292     def dnd_leave(self, source, event):
    293         self.top.focus_set() # Hide highlight border
    294         self.canvas.delete(self.dndid)
    295         self.dndid = None
    296 
    297     def dnd_commit(self, source, event):
    298         self.dnd_leave(source, event)
    299         x, y = source.where(self.canvas, event)
    300         source.attach(self.canvas, x, y)
    301 
    302 def test():
    303     root = Tkinter.Tk()
    304     root.geometry("+1+1")
    305     Tkinter.Button(command=root.quit, text="Quit").pack()
    306     t1 = Tester(root)
    307     t1.top.geometry("+1+60")
    308     t2 = Tester(root)
    309     t2.top.geometry("+120+60")
    310     t3 = Tester(root)
    311     t3.top.geometry("+240+60")
    312     i1 = Icon("ICON1")
    313     i2 = Icon("ICON2")
    314     i3 = Icon("ICON3")
    315     i1.attach(t1.canvas)
    316     i2.attach(t2.canvas)
    317     i3.attach(t3.canvas)
    318     root.mainloop()
    319 
    320 if __name__ == '__main__':
    321     test()
    322